"""Amazon specific utilities.""" from __future__ import annotations import datetime import email import logging import re from email.header import decode_header from functools import partial from pathlib import Path from typing import Any import aiohttp import dateparser from aioimaplib import IMAP4_SSL from custom_components.mail_and_packages.const import ( AMAZON_DELIVERED_SUBJECT, AMAZON_DOMAINS, AMAZON_EMAIL, AMAZON_IMG_LIST, AMAZON_IMG_PATTERN, AMAZON_ORDERED_SUBJECT, AMAZON_SHIPMENT_SUBJECT, AMAZON_SHIPMENT_TRACKING, AMAZON_TIME_PATTERN, AMAZON_TIME_PATTERN_END, AMAZON_TIME_PATTERN_REGEX, DEFAULT_AMAZON_DAYS, ) 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 io_save_file from custom_components.mail_and_packages.utils.imap import ( email_fetch, email_search, ) _LOGGER = logging.getLogger(__name__) _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB DOMAIN_LANG_MAP = { "amazon.de": ["versandbestaetigung", "Geliefert:", "Zugestellt:"], "amazon.it": ["conferma-spedizione", "Consegna effettuata:", "Arriverà"], "amazon.nl": [ "update-bestelling", "verzending-volgen", "auto-bevestiging", "Bezorgd:", ], "amazon.fr": ["confirmation-commande", "Livré", "Livraison : Votre", "Arrivée :"], "amazon.ca": ["confirmation-commande", "Livré", "Livraison : Votre", "Arrivée :"], "amazon.es": [ "confirmar-envio", "Entregado:", "Enviado:", "Pedido efetuado:", "Chega ", ], "amazon.pl": ["Dostarczono:"], } def filter_amazon_strings(strings: list[str], domain: str) -> list[str]: """Filter list of strings based on the domain language.""" all_mapped_strings = [] for lang_list in DOMAIN_LANG_MAP.values(): all_mapped_strings.extend(lang_list) def is_mapped(s: str) -> bool: return any(m in s for m in all_mapped_strings) base_strings = [s for s in strings if not is_mapped(s)] domain_strings = [] if domain in DOMAIN_LANG_MAP: mapped_for_domain = DOMAIN_LANG_MAP[domain] domain_strings = [s for s in strings if any(m in s for m in mapped_for_domain)] # Only amazon.ca and unmapped domains (like .com, .co.uk) should use base English strings if domain in DOMAIN_LANG_MAP and domain != "amazon.ca": return list(dict.fromkeys(domain_strings)) return list(dict.fromkeys(base_strings + domain_strings)) def get_decoded_subject(msg: email.message.Message) -> str: """Decode email subject.""" header_val = msg["subject"] if header_val: decoded_parts = [] for subject_bytes, encoding in decode_header(header_val): if isinstance(subject_bytes, bytes): if encoding: try: decoded_parts.append(subject_bytes.decode(encoding, "ignore")) continue except (LookupError, UnicodeError): pass decoded_parts.append(subject_bytes.decode("utf-8", "ignore")) else: decoded_parts.append(str(subject_bytes)) return "".join(decoded_parts) body = get_email_body(msg) if not body: return "" title_match = re.search( r"]*>([^<]+)", body, re.IGNORECASE | re.DOTALL ) if not title_match: return "" return title_match.group(1).strip() def get_email_body(msg: email.message.Message) -> str: """Extract and decode the email body safely.""" try: if msg.is_multipart(): # Standard practice is to look for text/plain then text/html for part in msg.walk(): if part.get_content_type() == "text/plain": return part.get_payload(decode=True).decode("utf-8", "ignore") # If no text/plain, fall back to first part payload = msg.get_payload(0) if isinstance(payload, email.message.Message): return payload.get_payload(decode=True).decode("utf-8", "ignore") return str(payload) return msg.get_payload(decode=True).decode("utf-8", "ignore") except (ValueError, TypeError, IndexError, AttributeError) as err: _LOGGER.debug("Problem decoding email message: %s", err) return "" def extract_order_numbers(text: str, pattern: re.Pattern | str) -> list[str]: """Extract order numbers from text.""" if isinstance(pattern, str): pattern = re.compile(pattern) return pattern.findall(text) async def parse_amazon_arrival_date( hass: Any, email_msg: str, email_date: datetime.date, ) -> datetime.date | None: """Determine arrival date from email.""" today_date = get_today() # Try using regex for more precise extraction of the arrival date string if date_str := amazon_date_regex(email_msg): base_datetime = datetime.datetime.combine( email_date or today_date, datetime.time(), ) # 1. Try parsing without PREFER_DATES_FROM: future to handle relative terms (any language) dateobj = await hass.async_add_executor_job( partial( dateparser.parse, date_str, settings={ "RELATIVE_BASE": base_datetime, "RETURN_AS_TIMEZONE_AWARE": False, }, ), ) if dateobj: parsed_date = dateobj.date() base_date = email_date or today_date # Only accept matches that resolve to email_date (today) or email_date + 1 day (tomorrow) if ( parsed_date == base_date or parsed_date == base_date + datetime.timedelta(days=1) ): return parsed_date # 2. Fall back to parsing with PREFER_DATES_FROM: future for absolute dates dateobj = await hass.async_add_executor_job( partial( dateparser.parse, date_str, settings={ "PREFER_DATES_FROM": "future", "RELATIVE_BASE": base_datetime, "RETURN_AS_TIMEZONE_AWARE": False, }, ), ) if dateobj: return dateobj.date() # Fallback to chunk-based parsing for search in AMAZON_TIME_PATTERN: if search not in email_msg: continue start = email_msg.find(search) + len(search) chunk = email_msg[start : start + 50] dateobj = await hass.async_add_executor_job( partial( dateparser.parse, chunk, settings={ "PREFER_DATES_FROM": "future", "RELATIVE_BASE": datetime.datetime.combine( email_date or today_date, datetime.time(), ), "RETURN_AS_TIMEZONE_AWARE": False, }, ), ) if dateobj: return dateobj.date() return None def amazon_email_addresses( fwds: list[str] | str | None = None, domain: str | None = None, ) -> list[str]: """Generate Amazon email addresses.""" if isinstance(fwds, str): fwds = [fwds] elif not isinstance(fwds, (list, tuple)): fwds = None if domain is None: domain = "amazon.com" # Use both AMAZON_EMAIL and AMAZON_SHIPMENT_TRACKING for prefixes prefixes = list(AMAZON_EMAIL) for p in AMAZON_SHIPMENT_TRACKING: if f"{p}@" not in prefixes: prefixes.append(f"{p}@") prefixes = filter_amazon_strings(prefixes, domain) value = [f"{e}{domain}" for e in prefixes] if fwds: for fwd in fwds: if "@" in fwd: value.append(fwd) elif any(f in fwd for f in AMAZON_DOMAINS): value.extend(f"{e}{fwd}" for e in prefixes) return value async def search_amazon_emails( account: IMAP4_SSL, address_list: list[str], days: int, domain: str | None = None, cache: EmailCache | None = None, forwarding_header: str = "", ) -> list[bytes]: """Search for Amazon emails.""" if not isinstance(days, int): try: days = int(days) except (ValueError, TypeError): days = DEFAULT_AMAZON_DAYS past_date = get_today() - datetime.timedelta(days=days) tfmt = past_date.strftime("%d-%b-%Y") amazon_subjects = ( AMAZON_DELIVERED_SUBJECT + AMAZON_SHIPMENT_SUBJECT + AMAZON_ORDERED_SUBJECT ) if domain: amazon_subjects = filter_amazon_strings(amazon_subjects, domain) (server_response, sdata) = await email_search( account=account, address=address_list, date=tfmt, subject=amazon_subjects, header=forwarding_header, ) if server_response != "OK" or not sdata[0]: return [] return sdata[0].split() async def download_amazon_img( img_url: str, img_path: str, img_name: str, hass: Any, ) -> None: """Download image from url.""" img_path = Path(img_path) / "amazon" filepath = img_path / img_name timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: try: async with session.get(img_url.replace("&", "&")) as resp: if resp.status != 200: return content_type = resp.headers.get("content-type", "") if "image" not in content_type: return content_length = int(resp.headers.get("content-length", 0)) if content_length > _MAX_IMAGE_SIZE: _LOGGER.warning( "Amazon image too large to download (%d bytes), skipping", content_length, ) return data = await resp.read() if len(data) > _MAX_IMAGE_SIZE: _LOGGER.warning( "Amazon image exceeds size limit after download, discarding" ) return await hass.async_add_executor_job(io_save_file, filepath, data) except aiohttp.ClientError as err: _LOGGER.error("Problem downloading file: %s", err) async def get_amazon_image_urls( sdata: Any, account: IMAP4_SSL, cache: EmailCache | None = None, ) -> list[str]: """Find all Amazon delivery image URLs.""" mail_list = sdata.split() pattern = re.compile(rf"{AMAZON_IMG_PATTERN}") urls = [] for i in mail_list: if cache: data = (await cache.fetch(i, "(RFC822)"))[1] else: data = (await email_fetch(account, i, "(RFC822)"))[1] for response_part in data: if isinstance(response_part, (bytes, bytearray)): msg = email.message_from_bytes(response_part) for part in msg.walk(): if part.get_content_type() != "text/html": continue part_payload = part.get_payload(decode=True) part_content = part_payload.decode("utf-8", "ignore") found = pattern.findall(part_content) for url in found: if url[1] not in AMAZON_IMG_LIST: continue full_url = url[0] + url[1] + url[2] if full_url not in urls: urls.append(full_url) return urls def _extract_hub_code( body: str, hub_pattern: str, subject: str, subject_pattern: str, ) -> str: """Extract Amazon Hub code from email body or subject.""" # Check subject first if (found := re.compile(subject_pattern).search(subject)) is not None: return found.group(3) # Check body if (found := re.compile(hub_pattern).search(body)) is not None: return found.group(2) return "" def amazon_date_search(email_msg: str, patterns: list[str] | None = None) -> int: """Search for a date pattern in an email message and return its index.""" if patterns is None: patterns = AMAZON_TIME_PATTERN_END for pattern in patterns: if (index := email_msg.find(pattern)) != -1: return index return -1 def amazon_date_regex(email_msg: str, patterns: list[str] | None = None) -> str | None: """Search for a date pattern using regex and return the first capture group.""" if patterns is None: patterns = AMAZON_TIME_PATTERN_REGEX for pattern in patterns: if (found := re.compile(pattern, re.IGNORECASE).search(email_msg)) is not None: if found.groups(): return found.group(1) return None