613 lines
22 KiB
Python
613 lines
22 KiB
Python
"""Base Shipper class."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import email
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from shutil import copyfile
|
|
from typing import Any
|
|
|
|
import anyio
|
|
import dateparser
|
|
import homeassistant.helpers.config_validation as cv
|
|
from aioimaplib import IMAP4_SSL
|
|
|
|
from custom_components.mail_and_packages import const
|
|
from custom_components.mail_and_packages.const import (
|
|
AMAZON_DELIVERED,
|
|
AMAZON_DELIVERED_SUBJECT,
|
|
AMAZON_EXCEPTION,
|
|
AMAZON_EXCEPTION_BODY,
|
|
AMAZON_EXCEPTION_ORDER,
|
|
AMAZON_EXCEPTION_SUBJECT,
|
|
AMAZON_HUB,
|
|
AMAZON_HUB_BODY,
|
|
AMAZON_HUB_CODE,
|
|
AMAZON_HUB_SUBJECT,
|
|
AMAZON_HUB_SUBJECT_SEARCH,
|
|
AMAZON_ORDER,
|
|
AMAZON_ORDERED_SUBJECT,
|
|
AMAZON_OTP,
|
|
AMAZON_OTP_CODE,
|
|
AMAZON_OTP_REGEX,
|
|
AMAZON_OTP_SUBJECT,
|
|
AMAZON_PACKAGES,
|
|
AMAZON_SHIPMENT_SUBJECT,
|
|
ATTR_COUNT,
|
|
CONF_AMAZON_DAYS,
|
|
CONF_AMAZON_DOMAIN,
|
|
CONF_AMAZON_FWDS,
|
|
CONF_DURATION,
|
|
CONF_FORWARDING_HEADER,
|
|
DEFAULT_AMAZON_DAYS,
|
|
)
|
|
from custom_components.mail_and_packages.utils.amazon import (
|
|
_extract_hub_code,
|
|
amazon_email_addresses,
|
|
download_amazon_img,
|
|
extract_order_numbers,
|
|
filter_amazon_strings,
|
|
get_decoded_subject,
|
|
get_email_body,
|
|
parse_amazon_arrival_date,
|
|
search_amazon_emails,
|
|
)
|
|
from custom_components.mail_and_packages.utils.cache import EmailCache
|
|
from custom_components.mail_and_packages.utils.date import get_today
|
|
from custom_components.mail_and_packages.utils.image import (
|
|
cleanup_images,
|
|
generate_delivery_gif,
|
|
random_filename,
|
|
resize_images,
|
|
)
|
|
from custom_components.mail_and_packages.utils.imap import (
|
|
email_fetch,
|
|
email_search,
|
|
)
|
|
|
|
from .base import Shipper
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class AmazonShipper(Shipper):
|
|
"""Amazon shipper implementation."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Return shipper name."""
|
|
return "amazon"
|
|
|
|
@classmethod
|
|
def handles_sensor(cls, sensor_type: str) -> bool:
|
|
"""Return True if this shipper handles the given sensor type."""
|
|
return sensor_type.startswith("amazon_") or sensor_type == AMAZON_PACKAGES
|
|
|
|
async def process(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
date: str,
|
|
sensor_type: str,
|
|
cache: EmailCache | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Process Amazon-specific emails."""
|
|
forwarding_header = self.config.get(CONF_FORWARDING_HEADER, "")
|
|
if forwarding_header and forwarding_header != "(none)":
|
|
# Header mode: use native Amazon addresses; fwds not needed
|
|
fwds = None
|
|
else:
|
|
forwarding_header = ""
|
|
fwds = cv.ensure_list_csv(self.config.get(CONF_AMAZON_FWDS))
|
|
days = self.config.get(CONF_AMAZON_DAYS, DEFAULT_AMAZON_DAYS)
|
|
domain = self.config.get(CONF_AMAZON_DOMAIN)
|
|
|
|
if sensor_type in [AMAZON_PACKAGES, AMAZON_ORDER]:
|
|
param = "count" if sensor_type == AMAZON_PACKAGES else "order"
|
|
result = await self._parse_amazon_emails(
|
|
account, param, fwds, days, domain, cache, forwarding_header
|
|
)
|
|
return {sensor_type: result}
|
|
|
|
if sensor_type == AMAZON_HUB:
|
|
return await self._amazon_hub(account, fwds, cache, forwarding_header)
|
|
|
|
if sensor_type == AMAZON_OTP:
|
|
result = await self._amazon_otp(account, fwds, cache, forwarding_header)
|
|
return {sensor_type: result}
|
|
|
|
if sensor_type == AMAZON_EXCEPTION:
|
|
return await self._amazon_exception(
|
|
account, fwds, domain, cache, forwarding_header
|
|
)
|
|
|
|
if sensor_type == AMAZON_DELIVERED:
|
|
image_path = self.config.get("image_path")
|
|
image_name = self.config.get("amazon_image")
|
|
result = await self._amazon_search(
|
|
account,
|
|
image_path,
|
|
image_name,
|
|
domain,
|
|
fwds,
|
|
cache,
|
|
forwarding_header,
|
|
)
|
|
return {
|
|
sensor_type: result,
|
|
const.ATTR_AMAZON_IMAGE: image_name,
|
|
const.ATTR_IMAGE_PATH: image_path,
|
|
}
|
|
|
|
return {ATTR_COUNT: 0}
|
|
|
|
async def process_batch(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
date: str,
|
|
sensors: list[str],
|
|
cache: EmailCache,
|
|
since_date: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Process multiple Amazon sensors in batch."""
|
|
res = {}
|
|
for sensor in sensors:
|
|
sensor_res = await self.process(account, date, sensor, cache)
|
|
res.update(sensor_res)
|
|
# Replicate coordinator dictionary logic
|
|
if sensor not in sensor_res:
|
|
if ATTR_COUNT in sensor_res:
|
|
res[sensor] = sensor_res[ATTR_COUNT]
|
|
return res
|
|
|
|
# Internal helper methods (migrated from helpers.py)
|
|
|
|
async def _parse_amazon_emails(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
param: str,
|
|
fwds: list[str] | None = None,
|
|
days: int = DEFAULT_AMAZON_DAYS,
|
|
domain: str | None = None,
|
|
cache: EmailCache | None = None,
|
|
forwarding_header: str = "",
|
|
) -> list[str] | int:
|
|
"""Parse Amazon emails for delivery date and order number."""
|
|
today_date = get_today()
|
|
address_list = amazon_email_addresses(fwds, domain)
|
|
unique_emails = await search_amazon_emails(
|
|
account, address_list, days, domain, cache, forwarding_header
|
|
)
|
|
order_pattern = re.compile(r"[0-9]{3}-[0-9]{7}-[0-9]{7}")
|
|
|
|
context = {
|
|
"today": today_date,
|
|
"packages_arriving_today": {},
|
|
"delivered_packages": {},
|
|
"amazon_delivered": [],
|
|
"deliveries_today": [],
|
|
"all_shipped_orders": set(),
|
|
"order_pattern": order_pattern,
|
|
}
|
|
|
|
for email_id in unique_emails:
|
|
await self._process_amazon_email(account, email_id, context, cache)
|
|
|
|
final_count = self._calculate_final_count(context)
|
|
|
|
if param == "count":
|
|
return final_count
|
|
return list(context["all_shipped_orders"])
|
|
|
|
async def _process_amazon_email(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
email_id: bytes | str,
|
|
ctx: dict,
|
|
cache: EmailCache | None = None,
|
|
):
|
|
"""Process a single Amazon email."""
|
|
fetch_id = email_id.decode() if isinstance(email_id, bytes) else email_id
|
|
if cache:
|
|
data = (await cache.fetch(fetch_id, "(RFC822)"))[1]
|
|
else:
|
|
data = (await email_fetch(account, fetch_id, "(RFC822)"))[1]
|
|
|
|
for response_part in data:
|
|
if not isinstance(response_part, (bytes, bytearray)):
|
|
continue
|
|
|
|
msg = email.message_from_bytes(response_part)
|
|
email_date = await self._parse_email_date(msg)
|
|
email_subject = get_decoded_subject(msg)
|
|
|
|
if any(s.lower() in email_subject.lower() for s in AMAZON_ORDERED_SUBJECT):
|
|
continue
|
|
|
|
email_msg = get_email_body(msg)
|
|
if any(
|
|
s.lower() in email_subject.lower() for s in AMAZON_DELIVERED_SUBJECT
|
|
):
|
|
self._handle_delivered_email(email_subject, email_msg, ctx)
|
|
continue
|
|
|
|
await self._handle_shipping_email(email_subject, email_msg, email_date, ctx)
|
|
|
|
async def _parse_email_date(
|
|
self,
|
|
msg: email.message.Message,
|
|
) -> datetime.date | None:
|
|
"""Parse the date from an email message."""
|
|
date_str = msg.get("Date")
|
|
if not date_str:
|
|
return None
|
|
parsed = await self.hass.async_add_executor_job(dateparser.parse, date_str)
|
|
return parsed.date() if parsed else None
|
|
|
|
def _handle_delivered_email(self, subject: str, body: str | None, ctx: dict):
|
|
"""Handle an Amazon 'delivered' email."""
|
|
orders = extract_order_numbers(subject, ctx["order_pattern"])
|
|
if not orders and body:
|
|
orders = extract_order_numbers(body, ctx["order_pattern"])
|
|
for o in orders:
|
|
ctx["delivered_packages"][o] = ctx["delivered_packages"].get(o, 0) + 1
|
|
if o not in ctx["amazon_delivered"]:
|
|
ctx["amazon_delivered"].append(o)
|
|
|
|
async def _handle_shipping_email(
|
|
self,
|
|
subject: str,
|
|
body: str | None,
|
|
date: datetime.date | None,
|
|
ctx: dict,
|
|
):
|
|
"""Handle an Amazon 'shipping' or 'arriving' email."""
|
|
order_id = self._extract_first_order_id(subject, body, ctx["order_pattern"])
|
|
if order_id:
|
|
ctx["all_shipped_orders"].add(order_id)
|
|
|
|
if body:
|
|
parsed_arrival = await parse_amazon_arrival_date(self.hass, body, date)
|
|
if parsed_arrival == ctx["today"]:
|
|
if order_id:
|
|
ctx["packages_arriving_today"][order_id] = (
|
|
ctx["packages_arriving_today"].get(order_id, 0) + 1
|
|
)
|
|
else:
|
|
ctx["deliveries_today"].append("Amazon Order")
|
|
|
|
def _extract_first_order_id(
|
|
self,
|
|
subject: str,
|
|
body: str | None,
|
|
pattern: re.Pattern,
|
|
) -> str | None:
|
|
"""Extract the first order number found in subject or body."""
|
|
orders = extract_order_numbers(subject, pattern)
|
|
if orders:
|
|
return orders[0]
|
|
if body:
|
|
orders = extract_order_numbers(body, pattern)
|
|
if orders:
|
|
return orders[0]
|
|
return None
|
|
|
|
def _calculate_final_count(self, ctx: dict) -> int:
|
|
"""Calculate the final count of packages arriving today."""
|
|
deliveries_today = [
|
|
item
|
|
for item in ctx["deliveries_today"]
|
|
if item not in ctx["amazon_delivered"]
|
|
]
|
|
final_count = 0
|
|
for order_id, arriving_count in ctx["packages_arriving_today"].items():
|
|
delivered_count = ctx["delivered_packages"].get(order_id, 0)
|
|
final_count += max(0, arriving_count - delivered_count)
|
|
return final_count + len(deliveries_today)
|
|
|
|
async def _amazon_search(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
image_path: str,
|
|
amazon_image_name: str,
|
|
amazon_domain: str,
|
|
fwds: list[str] | None = None,
|
|
cache: EmailCache | None = None,
|
|
forwarding_header: str = "",
|
|
) -> int:
|
|
"""Find Amazon Delivered email and handle images."""
|
|
_LOGGER.debug("=== AMAZON DELIVERED SEARCH START ===")
|
|
subjects = AMAZON_DELIVERED_SUBJECT
|
|
today = get_today().strftime("%d-%b-%Y")
|
|
count = 0
|
|
all_image_urls = []
|
|
|
|
await self.hass.async_add_executor_job(
|
|
cleanup_images,
|
|
f"{image_path or ''}amazon/",
|
|
)
|
|
|
|
address_list = amazon_email_addresses(fwds, amazon_domain)
|
|
_LOGGER.debug("Amazon email search addresses: %s", address_list)
|
|
if amazon_domain:
|
|
subjects = filter_amazon_strings(subjects, amazon_domain)
|
|
|
|
(server_response, data) = await email_search(
|
|
account=account,
|
|
address=address_list,
|
|
date=today,
|
|
subject=subjects,
|
|
header=forwarding_header,
|
|
)
|
|
if server_response == "OK" and data[0]:
|
|
for email_id in data[0].split():
|
|
fetch_id = (
|
|
email_id.decode() if isinstance(email_id, bytes) else email_id
|
|
)
|
|
if cache:
|
|
msg_data = (await cache.fetch(fetch_id, "(RFC822)"))[1]
|
|
else:
|
|
msg_data = (await email_fetch(account, fetch_id, "(RFC822)"))[1]
|
|
|
|
is_delivered, urls = self._is_amazon_delivered(msg_data, subjects)
|
|
if is_delivered:
|
|
count += 1
|
|
for url in urls:
|
|
if url not in all_image_urls:
|
|
all_image_urls.append(url)
|
|
|
|
await self._process_amazon_images(
|
|
all_image_urls, image_path, amazon_image_name, count
|
|
)
|
|
|
|
return count
|
|
|
|
def _is_amazon_delivered(
|
|
self, msg_data: list, subjects: list[str]
|
|
) -> tuple[bool, list[str]]:
|
|
"""Verify if email is a delivered notification and return image URLs."""
|
|
for response_part in msg_data:
|
|
if not isinstance(response_part, (bytes, bytearray)):
|
|
continue
|
|
msg = email.message_from_bytes(response_part)
|
|
subject = get_decoded_subject(msg)
|
|
if not subject:
|
|
continue
|
|
|
|
# Check if subject contains any delivered keyword (case-insensitive)
|
|
has_delivered = any(s.lower() in subject.lower() for s in subjects)
|
|
# Check if subject contains ordered or shipped keywords (case-insensitive)
|
|
has_ordered = any(
|
|
s.lower() in subject.lower() for s in AMAZON_ORDERED_SUBJECT
|
|
)
|
|
has_shipped = any(
|
|
s.lower() in subject.lower() for s in AMAZON_SHIPMENT_SUBJECT
|
|
)
|
|
|
|
if has_delivered and not has_ordered and not has_shipped:
|
|
urls = self._extract_amazon_image_urls(msg)
|
|
return True, urls
|
|
return False, []
|
|
|
|
def _extract_amazon_image_urls(self, msg: email.message.Message) -> list[str]:
|
|
"""Extract image URLs from Amazon email body."""
|
|
urls = []
|
|
pattern = re.compile(rf"{const.AMAZON_IMG_PATTERN}")
|
|
for part in msg.walk():
|
|
if part.get_content_type() != "text/html":
|
|
continue
|
|
part_payload = part.get_payload(decode=True)
|
|
if part_payload:
|
|
part_content = part_payload.decode("utf-8", "ignore")
|
|
found = pattern.findall(part_content)
|
|
for url in found:
|
|
if url[1] not in const.AMAZON_IMG_LIST:
|
|
continue
|
|
full_url = url[0] + url[1] + url[2]
|
|
if full_url not in urls:
|
|
urls.append(full_url)
|
|
return urls
|
|
|
|
async def _process_amazon_images(
|
|
self,
|
|
image_urls: list[str],
|
|
image_base_path: str,
|
|
image_name: str,
|
|
email_count: int,
|
|
) -> None:
|
|
"""Process and save Amazon delivery images."""
|
|
if not image_base_path or not image_name:
|
|
return
|
|
|
|
amazon_path = Path(image_base_path) / "amazon"
|
|
image_files = await self._download_all_images(image_urls, image_base_path)
|
|
|
|
if len(image_files) > 1:
|
|
await self._create_amazon_gif(image_files, amazon_path, image_name)
|
|
elif len(image_files) == 1:
|
|
await self._save_single_amazon_image(
|
|
image_files[0], amazon_path, image_name
|
|
)
|
|
else:
|
|
await self._copy_amazon_placeholder(amazon_path, image_name)
|
|
|
|
async def _download_all_images(self, urls: list[str], base_path: str) -> list[str]:
|
|
"""Download all image URLs to temporary files."""
|
|
image_files = []
|
|
amazon_path = Path(base_path) / "amazon"
|
|
for url in urls:
|
|
temp_filename = random_filename()
|
|
await download_amazon_img(url, base_path, temp_filename, self.hass)
|
|
full_temp_path = amazon_path / temp_filename
|
|
if await anyio.Path(full_temp_path).exists():
|
|
image_files.append(str(full_temp_path))
|
|
return image_files
|
|
|
|
async def _create_amazon_gif(
|
|
self, image_files: list[str], amazon_path: Path, image_name: str
|
|
) -> None:
|
|
"""Create animated GIF from multiple images."""
|
|
_LOGGER.debug("Combining %d Amazon images into GIF", len(image_files))
|
|
resized_images = await self.hass.async_add_executor_job(
|
|
resize_images, image_files, 724, 320
|
|
)
|
|
gif_path = str(amazon_path / image_name)
|
|
duration = self.config.get(CONF_DURATION, 5) * 1000
|
|
await self.hass.async_add_executor_job(
|
|
generate_delivery_gif, resized_images, gif_path, duration
|
|
)
|
|
# Cleanup
|
|
for img in image_files + resized_images:
|
|
if await anyio.Path(img).exists():
|
|
await self.hass.async_add_executor_job(
|
|
cleanup_images, str(Path(img).parent) + "/", Path(img).name
|
|
)
|
|
|
|
async def _save_single_amazon_image(
|
|
self, image_file: str, amazon_path: Path, image_name: str
|
|
) -> None:
|
|
"""Save a single image by renaming it to the final name."""
|
|
final_path = amazon_path / image_name
|
|
if await anyio.Path(final_path).exists():
|
|
await anyio.Path(final_path).unlink()
|
|
await self.hass.async_add_executor_job(Path(image_file).rename, final_path)
|
|
_LOGGER.debug("Single Amazon image saved: %s", image_name)
|
|
|
|
async def _copy_amazon_placeholder(
|
|
self, amazon_path: Path, image_name: str
|
|
) -> None:
|
|
"""Copy the Amazon no-delivery placeholder."""
|
|
nomail = f"{Path(__file__).parent.parent}/no_deliveries_amazon.jpg"
|
|
_LOGGER.debug("No Amazon images found in emails, using placeholder")
|
|
try:
|
|
await self.hass.async_add_executor_job(
|
|
copyfile, nomail, str(amazon_path / image_name)
|
|
)
|
|
except OSError as err:
|
|
_LOGGER.error("Error attempting to copy image: %s", err)
|
|
|
|
async def _amazon_hub(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
fwds: list[str] | None = None,
|
|
cache: EmailCache | None = None,
|
|
forwarding_header: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Find Amazon Hub code."""
|
|
_LOGGER.debug("=== AMAZON HUB SEARCH START ===")
|
|
count = 0
|
|
code = []
|
|
processed_ids = []
|
|
today = get_today().strftime("%d-%b-%Y")
|
|
address_list = amazon_email_addresses(fwds, "amazon.com")
|
|
for search_subject in AMAZON_HUB_SUBJECT:
|
|
(server_response, data) = await email_search(
|
|
account,
|
|
address_list,
|
|
today,
|
|
search_subject,
|
|
body=AMAZON_HUB_BODY,
|
|
header=forwarding_header,
|
|
)
|
|
if server_response == "OK" and data[0] is not None:
|
|
for num in data[0].split():
|
|
if num in processed_ids:
|
|
continue
|
|
processed_ids.append(num)
|
|
if cache:
|
|
msg_parts = (await cache.fetch(num, "(RFC822)"))[1]
|
|
else:
|
|
msg_parts = (await email_fetch(account, num, "(RFC822)"))[1]
|
|
for response_part in msg_parts:
|
|
if isinstance(response_part, (bytes, bytearray)):
|
|
msg = email.message_from_bytes(response_part)
|
|
actual_subject = get_decoded_subject(msg)
|
|
body = get_email_body(msg)
|
|
if hub_code := _extract_hub_code(
|
|
body,
|
|
AMAZON_HUB_BODY,
|
|
actual_subject,
|
|
AMAZON_HUB_SUBJECT_SEARCH,
|
|
):
|
|
count += 1
|
|
if hub_code not in code:
|
|
code.append(hub_code)
|
|
return {AMAZON_HUB: count, AMAZON_HUB_CODE: code}
|
|
|
|
async def _amazon_otp(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
fwds: list[str] | None = None,
|
|
cache: EmailCache | None = None,
|
|
forwarding_header: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Find Amazon OTP code."""
|
|
code = []
|
|
today = get_today().strftime("%d-%b-%Y")
|
|
address_list = amazon_email_addresses(fwds, "amazon.com")
|
|
(server_response, data) = await email_search(
|
|
account,
|
|
address_list,
|
|
today,
|
|
AMAZON_OTP_SUBJECT,
|
|
body=AMAZON_OTP_REGEX,
|
|
header=forwarding_header,
|
|
)
|
|
if server_response == "OK" and data[0] is not None:
|
|
for num in data[0].split():
|
|
if cache:
|
|
msg_parts = (await cache.fetch(num, "(RFC822)"))[1]
|
|
else:
|
|
msg_parts = (await email_fetch(account, num, "(RFC822)"))[1]
|
|
for response_part in msg_parts:
|
|
if isinstance(response_part, (bytes, bytearray)):
|
|
msg = email.message_from_bytes(response_part)
|
|
body = get_email_body(msg)
|
|
if (
|
|
found := re.compile(AMAZON_OTP_REGEX).search(body)
|
|
) is not None:
|
|
code.append(found.group(2))
|
|
return {AMAZON_OTP: len(code), AMAZON_OTP_CODE: code}
|
|
|
|
async def _amazon_exception(
|
|
self,
|
|
account: IMAP4_SSL,
|
|
fwds: list[str] | None = None,
|
|
domain: str | None = None,
|
|
cache: EmailCache | None = None,
|
|
forwarding_header: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Find Amazon exception emails."""
|
|
count = 0
|
|
orders = []
|
|
today = get_today().strftime("%d-%b-%Y")
|
|
address_list = amazon_email_addresses(fwds, domain)
|
|
(server_response, data) = await email_search(
|
|
account=account,
|
|
address=address_list,
|
|
date=today,
|
|
subject=AMAZON_EXCEPTION_SUBJECT,
|
|
header=forwarding_header,
|
|
)
|
|
if server_response == "OK" and data[0] is not None:
|
|
order_pattern = re.compile(r"[0-9]{3}-[0-9]{7}-[0-9]{7}")
|
|
for num in data[0].split():
|
|
if cache:
|
|
msg_parts = (await cache.fetch(num, "(RFC822)"))[1]
|
|
else:
|
|
msg_parts = (await email_fetch(account, num, "(RFC822)"))[1]
|
|
for response_part in msg_parts:
|
|
if isinstance(response_part, (bytes, bytearray)):
|
|
msg = email.message_from_bytes(response_part)
|
|
body = get_email_body(msg)
|
|
subject = get_decoded_subject(msg)
|
|
if AMAZON_EXCEPTION_BODY in body:
|
|
count += 1
|
|
if found := order_pattern.findall(body):
|
|
orders.extend(found)
|
|
if found := order_pattern.findall(subject):
|
|
orders.extend(found)
|
|
return {AMAZON_EXCEPTION: count, AMAZON_EXCEPTION_ORDER: orders}
|