397 lines
13 KiB
Python
397 lines
13 KiB
Python
"""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"<title[^>]*>([^<]+)</title>", 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
|