"""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