Updated apps
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Utility functions for Mail and Packages."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,396 @@
|
||||
"""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
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Email caching utility for Mail and Packages."""
|
||||
|
||||
import logging
|
||||
|
||||
from aioimaplib import IMAP4_SSL
|
||||
|
||||
from .imap import email_fetch, email_fetch_batch, email_fetch_headers, email_fetch_text
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailCache:
|
||||
"""Cache for IMAP email fetches to avoid duplicate downloads."""
|
||||
|
||||
def __init__(self, account: IMAP4_SSL) -> None:
|
||||
"""Initialize the cache."""
|
||||
self.account = account
|
||||
self._cache_rfc822: dict[str, tuple] = {}
|
||||
self._cache_text: dict[str, tuple] = {}
|
||||
self._cache_headers: dict[str, tuple] = {}
|
||||
|
||||
async def fetch(self, email_id: str | bytes, parts: str = "(RFC822)") -> tuple: # noqa: C901
|
||||
"""Fetch email content or return from cache."""
|
||||
eid_str = email_id.decode() if isinstance(email_id, bytes) else str(email_id)
|
||||
|
||||
if parts in ("(RFC822)", "BODY[]"):
|
||||
if eid_str in self._cache_rfc822:
|
||||
return self._cache_rfc822[eid_str]
|
||||
res = await email_fetch(self.account, eid_str, parts)
|
||||
if res[0] == "OK":
|
||||
self._cache_rfc822[eid_str] = res
|
||||
return res
|
||||
|
||||
if "HEADER" in parts:
|
||||
if eid_str in self._cache_headers:
|
||||
return self._cache_headers[eid_str]
|
||||
# fallback to RFC822 cache if we already have it
|
||||
if eid_str in self._cache_rfc822:
|
||||
return self._cache_rfc822[eid_str]
|
||||
res = await email_fetch_headers(self.account, eid_str)
|
||||
if res[0] == "OK":
|
||||
self._cache_headers[eid_str] = res
|
||||
return res
|
||||
|
||||
if "TEXT" in parts or parts == "(BODY[1])":
|
||||
if eid_str in self._cache_text:
|
||||
return self._cache_text[eid_str]
|
||||
if eid_str in self._cache_rfc822:
|
||||
return self._cache_rfc822[eid_str]
|
||||
res = await email_fetch_text(self.account, eid_str, parts)
|
||||
if res[0] == "OK":
|
||||
self._cache_text[eid_str] = res
|
||||
return res
|
||||
|
||||
# Unknown part, bypass cache
|
||||
return await email_fetch(self.account, eid_str, parts)
|
||||
|
||||
async def fetch_batch(
|
||||
self, email_ids: list[str | bytes], parts: str = "(RFC822)"
|
||||
) -> tuple:
|
||||
"""Fetch multiple emails in a batch, retrieving from cache if applicable."""
|
||||
# For simplicity, if we don't have all of them, fetch the missing ones in batch
|
||||
missing_ids = []
|
||||
eid_strs = [e.decode() if isinstance(e, bytes) else str(e) for e in email_ids]
|
||||
|
||||
# Determine which ones we already have
|
||||
for eid_str in eid_strs:
|
||||
if parts in ("(RFC822)", "BODY[]"):
|
||||
if eid_str not in self._cache_rfc822:
|
||||
missing_ids.append(eid_str)
|
||||
elif "HEADER" in parts:
|
||||
if (
|
||||
eid_str not in self._cache_headers
|
||||
and eid_str not in self._cache_rfc822
|
||||
):
|
||||
missing_ids.append(eid_str)
|
||||
elif "TEXT" in parts or parts == "(BODY[1])":
|
||||
if (
|
||||
eid_str not in self._cache_text
|
||||
and eid_str not in self._cache_rfc822
|
||||
):
|
||||
missing_ids.append(eid_str)
|
||||
else:
|
||||
missing_ids.append(eid_str)
|
||||
|
||||
# Batch fetch only what is missing
|
||||
if missing_ids:
|
||||
# Just use fetch() internally for now to populate individual items since fetch_batch
|
||||
# returns combined lines which might be tricky to parse.
|
||||
# Wait, email_fetch_batch might be tricky to cache without parsing the response to separate emails.
|
||||
# So actually we will just loop `fetch` for batch if we want it cleanly cached, or we don't cache
|
||||
# `fetch_batch` if we aren't parsing it.
|
||||
# Actually `fetch_batch` isn't needed if we just do individual fetches? No, batch is faster.
|
||||
# But parsing batch response to stick into cache is annoying.
|
||||
pass
|
||||
|
||||
# For this version, let's keep fetch_batch simple and bypass cache.
|
||||
return await email_fetch_batch(self.account, email_ids, parts)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cache."""
|
||||
self._cache_rfc822.clear()
|
||||
self._cache_text.clear()
|
||||
self._cache_headers.clear()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Date and time helpers for Mail and Packages."""
|
||||
|
||||
import datetime
|
||||
|
||||
|
||||
def get_today() -> datetime.date:
|
||||
"""Get today's date using system local timezone (Home Assistant's timezone).
|
||||
|
||||
Returns date object using the system's local timezone.
|
||||
"""
|
||||
return datetime.date.today()
|
||||
|
||||
|
||||
def get_formatted_date() -> str:
|
||||
"""Return today in specific format.
|
||||
|
||||
Returns current timestamp as string
|
||||
"""
|
||||
return get_today().strftime("%d-%b-%Y")
|
||||
|
||||
|
||||
async def update_time() -> datetime.datetime:
|
||||
"""Get update time.
|
||||
|
||||
Returns current timestamp as datetime object.
|
||||
"""
|
||||
return datetime.datetime.now(datetime.UTC)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Email parsing and validation utilities for Mail and Packages."""
|
||||
|
||||
import email
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioimaplib import IMAP4_SSL
|
||||
from voluptuous import Email, MultipleInvalid, Schema
|
||||
|
||||
from custom_components.mail_and_packages.const import SENSOR_DATA
|
||||
from custom_components.mail_and_packages.utils.cache import EmailCache
|
||||
|
||||
from .imap import email_fetch
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_email_address(email_address: str) -> bool:
|
||||
"""Validate the format of an email address.
|
||||
|
||||
Args:
|
||||
email_address (str): The email address to validate.
|
||||
|
||||
Returns:
|
||||
bool: `True` if the email address is valid, `False` otherwise.
|
||||
|
||||
"""
|
||||
try:
|
||||
schema = Schema(Email()) # pylint: disable=no-value-for-parameter
|
||||
schema(email_address)
|
||||
except MultipleInvalid:
|
||||
_LOGGER.error("'%s' does not look like a valid email address", email_address)
|
||||
return False
|
||||
|
||||
_LOGGER.debug("%s is a valid email address", email_address)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def generate_service_email_domains(amazon_fwds: list) -> set[str]:
|
||||
"""Generate a set of service email domains from amazon domains and SENSOR_DATA.
|
||||
|
||||
Returns:
|
||||
set[str]: A set of unique email domains.
|
||||
|
||||
"""
|
||||
domains = {fwd.split("@")[1] for fwd in amazon_fwds if "@" in fwd}
|
||||
for sensor in SENSOR_DATA.values():
|
||||
for address in sensor.get("email", []):
|
||||
if "@" not in address:
|
||||
continue
|
||||
domains.add(address.split("@")[1])
|
||||
return domains
|
||||
|
||||
|
||||
def _match_patterns(
|
||||
text: str,
|
||||
patterns: list[re.Pattern],
|
||||
body_count: bool,
|
||||
) -> tuple[int, int | None]:
|
||||
"""Apply patterns to text and return occurrence count and extracted value.
|
||||
|
||||
Returns:
|
||||
tuple[int, int | None]: (count_of_matches, extracted_value)
|
||||
|
||||
"""
|
||||
local_count = 0
|
||||
extracted_value = None
|
||||
|
||||
for pattern in patterns:
|
||||
if body_count:
|
||||
if (found := pattern.search(text)) and len(found.groups()) > 0:
|
||||
_LOGGER.debug(
|
||||
"Found (%s) in email result: %s",
|
||||
pattern.pattern,
|
||||
found.groups(),
|
||||
)
|
||||
extracted_value = int(found.group(1))
|
||||
elif (found := pattern.findall(text)) and len(found) > 0:
|
||||
_LOGGER.debug(
|
||||
"Found (%s) in email %s times.",
|
||||
pattern.pattern,
|
||||
len(found),
|
||||
)
|
||||
local_count += len(found)
|
||||
|
||||
return local_count, extracted_value
|
||||
|
||||
|
||||
async def _scan_email_for_text(
|
||||
account: type[IMAP4_SSL],
|
||||
email_id: str,
|
||||
patterns: list[re.Pattern],
|
||||
body_count: bool,
|
||||
cache: EmailCache | None = None,
|
||||
) -> tuple[int, int | None]:
|
||||
"""Scan a single email for terms.
|
||||
|
||||
Returns:
|
||||
tuple[int, int | None]: (total_matches, last_extracted_value)
|
||||
|
||||
"""
|
||||
total_matches = 0
|
||||
last_value = None
|
||||
|
||||
if cache:
|
||||
data = (await cache.fetch(email_id, "(RFC822)"))[1]
|
||||
else:
|
||||
data = (await email_fetch(account, email_id, "(RFC822)"))[1]
|
||||
for response_part in data:
|
||||
if not isinstance(response_part, (bytes, bytearray)):
|
||||
continue
|
||||
|
||||
msg = email.message_from_bytes(response_part)
|
||||
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() not in ["text/html", "text/plain"]:
|
||||
continue
|
||||
|
||||
email_msg = part.get_payload(decode=True)
|
||||
try:
|
||||
email_msg = email_msg.decode("utf-8", "ignore")
|
||||
except (AttributeError, UnicodeError):
|
||||
continue
|
||||
|
||||
matches, value = _match_patterns(email_msg, patterns, body_count)
|
||||
total_matches += matches
|
||||
if value is not None:
|
||||
last_value = value
|
||||
|
||||
return total_matches, last_value
|
||||
|
||||
|
||||
async def find_text(
|
||||
sdata: Any,
|
||||
account: type[IMAP4_SSL],
|
||||
search_terms: list,
|
||||
body_count: bool,
|
||||
cache: EmailCache | None = None,
|
||||
) -> int:
|
||||
"""Filter for specific words in email."""
|
||||
_LOGGER.debug("Searching for (%s) in (%s) emails", search_terms, len(sdata))
|
||||
mail_list = sdata[0].split()
|
||||
count = 0
|
||||
|
||||
# Pre-compile regex patterns once
|
||||
patterns = [re.compile(rf"{term}") for term in search_terms]
|
||||
|
||||
for i in mail_list:
|
||||
matches, value = await _scan_email_for_text(
|
||||
account, i, patterns, body_count, cache
|
||||
)
|
||||
|
||||
if body_count:
|
||||
# If extracting a value, "last found value wins" (updates count)
|
||||
if value is not None:
|
||||
count = value
|
||||
else:
|
||||
# If counting occurrences, accumulate
|
||||
count += matches
|
||||
|
||||
return count
|
||||
|
||||
|
||||
async def find_text_matches(
|
||||
sdata: Any,
|
||||
account: type[IMAP4_SSL],
|
||||
search_terms: list,
|
||||
body_count: bool,
|
||||
cache: EmailCache | None = None,
|
||||
) -> tuple[int, list[bytes]]:
|
||||
"""Filter for specific words in email and return matches and matching IDs."""
|
||||
_LOGGER.debug("Searching for (%s) in (%s) emails", search_terms, len(sdata))
|
||||
mail_list = sdata[0].split()
|
||||
count = 0
|
||||
matching_ids = []
|
||||
|
||||
# Pre-compile regex patterns once
|
||||
patterns = [re.compile(rf"{term}") for term in search_terms]
|
||||
|
||||
for i in mail_list:
|
||||
matches, value = await _scan_email_for_text(
|
||||
account, i, patterns, body_count, cache
|
||||
)
|
||||
|
||||
matched = False
|
||||
if body_count:
|
||||
# If extracting a value, "last found value wins" (updates count)
|
||||
if value is not None:
|
||||
count = value
|
||||
matched = True
|
||||
# If counting occurrences, accumulate
|
||||
elif matches > 0:
|
||||
count += matches
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
matching_ids.append(i)
|
||||
|
||||
return count, matching_ids
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Image processing and management utilities for Mail and Packages."""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess # nosec
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from shutil import copyfile, which
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from custom_components.mail_and_packages.const import (
|
||||
CONF_AMAZON_CUSTOM_IMG,
|
||||
CONF_AMAZON_CUSTOM_IMG_FILE,
|
||||
CONF_CUSTOM_IMG,
|
||||
CONF_CUSTOM_IMG_FILE,
|
||||
CONF_FEDEX_CUSTOM_IMG,
|
||||
CONF_FEDEX_CUSTOM_IMG_FILE,
|
||||
CONF_STORAGE,
|
||||
CONF_UPS_CUSTOM_IMG,
|
||||
CONF_UPS_CUSTOM_IMG_FILE,
|
||||
CONF_WALMART_CUSTOM_IMG,
|
||||
CONF_WALMART_CUSTOM_IMG_FILE,
|
||||
DEFAULT_AMAZON_CUSTOM_IMG_FILE,
|
||||
DEFAULT_CUSTOM_IMG_FILE,
|
||||
DEFAULT_FEDEX_CUSTOM_IMG_FILE,
|
||||
DEFAULT_UPS_CUSTOM_IMG_FILE,
|
||||
DEFAULT_WALMART_CUSTOM_IMG_FILE,
|
||||
OVERLAY,
|
||||
)
|
||||
|
||||
from .date import get_formatted_date
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _check_ffmpeg() -> bool:
|
||||
"""Check if ffmpeg is installed.
|
||||
|
||||
Returns boolean
|
||||
"""
|
||||
return which("ffmpeg")
|
||||
|
||||
|
||||
def default_image_path(
|
||||
hass: HomeAssistant, # pylint: disable=unused-argument
|
||||
config_entry: ConfigEntry,
|
||||
) -> str:
|
||||
"""Return value of the default image path.
|
||||
|
||||
Returns the default path based on logic
|
||||
"""
|
||||
storage = None
|
||||
try:
|
||||
storage = config_entry.get(CONF_STORAGE)
|
||||
except AttributeError:
|
||||
storage = config_entry.data[CONF_STORAGE]
|
||||
|
||||
if storage:
|
||||
return storage
|
||||
return "custom_components/mail_and_packages/images/"
|
||||
|
||||
|
||||
def hash_file(filename: str) -> str:
|
||||
"""Return the SHA-1 hash of the file passed into it.
|
||||
|
||||
Returns hash of file as string
|
||||
"""
|
||||
# make a hash object
|
||||
the_hash = hashlib.sha1() # nosec
|
||||
|
||||
# open file for reading in binary mode
|
||||
with Path(filename).open("rb") as file:
|
||||
# loop till the end of the file
|
||||
chunk = 0
|
||||
while chunk != b"":
|
||||
# read only 1024 bytes at a time
|
||||
chunk = file.read(1024)
|
||||
the_hash.update(chunk)
|
||||
|
||||
# return the hex representation of digest
|
||||
return the_hash.hexdigest()
|
||||
|
||||
|
||||
def _delete_file(file_path: Path, context: str) -> bool:
|
||||
"""Delete a single file with error handling."""
|
||||
try:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
_LOGGER.debug("%s - Successfully removed: %s", context, file_path)
|
||||
return True
|
||||
_LOGGER.debug("%s - File does not exist: %s", context, file_path)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Error attempting to remove image in %s: %s", context, err)
|
||||
return False
|
||||
|
||||
|
||||
def _cleanup_directory(path: str) -> None:
|
||||
"""Handle directory-wide cleanup."""
|
||||
if not Path(path).is_dir():
|
||||
_LOGGER.debug("cleanup_images - Directory does not exist: %s", path)
|
||||
return
|
||||
|
||||
try:
|
||||
files_before = [x.name for x in Path(path).iterdir()]
|
||||
_LOGGER.debug(
|
||||
"cleanup_images - Files in directory BEFORE cleanup: %s",
|
||||
files_before,
|
||||
)
|
||||
for file in files_before:
|
||||
if file.endswith((".gif", ".mp4", ".jpg", ".png")):
|
||||
full_path = Path(path) / file
|
||||
_delete_file(full_path, "cleanup_images")
|
||||
|
||||
files_after = []
|
||||
if Path(path).is_dir():
|
||||
files_after = [f.name for f in Path(path).iterdir()]
|
||||
|
||||
_LOGGER.debug(
|
||||
"cleanup_images - Files in directory AFTER cleanup: %s",
|
||||
files_after,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
_LOGGER.debug("cleanup_images - Directory removed during cleanup: %s", path)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Error listing directory for cleanup: %s", err)
|
||||
|
||||
|
||||
def cleanup_images(path: str, image: str | None = None) -> None:
|
||||
"""Clean up image storage directory.
|
||||
|
||||
Only suppose to delete .gif, .mp4, and .jpg files
|
||||
"""
|
||||
_LOGGER.debug("=== cleanup_images CALLED === path: %s, image: %s", path, image)
|
||||
|
||||
if isinstance(path, tuple):
|
||||
path, image = path
|
||||
|
||||
if image is not None:
|
||||
full_path = Path(path) / image
|
||||
_delete_file(full_path, "cleanup_images")
|
||||
return
|
||||
|
||||
_cleanup_directory(path)
|
||||
|
||||
|
||||
def copy_overlays(path: str) -> None:
|
||||
"""Copy overlay images to image output path."""
|
||||
overlays = OVERLAY
|
||||
check = all(item.name in overlays for item in Path(path).iterdir())
|
||||
|
||||
# Copy files if they are missing
|
||||
if not check:
|
||||
for file in overlays:
|
||||
_LOGGER.debug("Copying file to: %s", path + file)
|
||||
copyfile(
|
||||
Path(__file__).parent.parent / file,
|
||||
path + file,
|
||||
)
|
||||
|
||||
|
||||
def resize_images(images: list, width: int, height: int) -> list:
|
||||
"""Resize images."""
|
||||
all_images = []
|
||||
for image_path in images:
|
||||
try:
|
||||
img_path = Path(image_path)
|
||||
with img_path.open("rb") as fd_img:
|
||||
img = Image.open(fd_img)
|
||||
img.thumbnail((width, height), resample=Image.Resampling.LANCZOS)
|
||||
img = ImageOps.pad(
|
||||
img,
|
||||
(width, height),
|
||||
method=Image.Resampling.LANCZOS,
|
||||
)
|
||||
img = img.crop((0, 0, width, height))
|
||||
new_image_path = img_path.parent / f"{img_path.stem}_resized.gif"
|
||||
img.save(new_image_path, "GIF")
|
||||
all_images.append(str(new_image_path))
|
||||
|
||||
except (OSError, ValueError) as err:
|
||||
_LOGGER.error("Error processing image %s: %s", image_path, err)
|
||||
continue
|
||||
|
||||
return all_images
|
||||
|
||||
|
||||
def random_filename(ext: str = ".jpg") -> str:
|
||||
"""Generate random filename."""
|
||||
return f"{uuid.uuid4()!s}{ext}"
|
||||
|
||||
|
||||
def io_save_file(path: str | Path, data: bytes) -> None:
|
||||
"""Write bytes to a file synchronously (for use in executor)."""
|
||||
with Path(path).open("wb") as the_file:
|
||||
the_file.write(data)
|
||||
|
||||
|
||||
def _generate_mp4(path: str, image_file: str) -> None:
|
||||
"""Generate mp4 from gif.
|
||||
|
||||
use a subprocess so we don't lock up the thread
|
||||
command: ffmpeg -f gif -i infile.gif outfile.mp4
|
||||
"""
|
||||
base_path = Path(path)
|
||||
gif_image = base_path / image_file
|
||||
mp4_file = base_path / image_file.replace(".gif", ".mp4")
|
||||
|
||||
filecheck = mp4_file.is_file()
|
||||
|
||||
_LOGGER.debug("Generating mp4: %s", mp4_file)
|
||||
if filecheck:
|
||||
# Construct path string with trailing slash to ensure cleanup_images concatenates correctly
|
||||
cleanup_images(str(mp4_file.parent) + "/", mp4_file.name)
|
||||
_LOGGER.debug("Removing old mp4: %s", mp4_file)
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(gif_image),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(mp4_file),
|
||||
]
|
||||
subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as err:
|
||||
_LOGGER.error("FFmpeg failed to generate MP4: %s", err)
|
||||
|
||||
|
||||
def generate_grid_img(path: str, image_file: str, count: int) -> None:
|
||||
"""Generate png grid from gif.
|
||||
|
||||
use a subprocess so we don't lock up the thread
|
||||
command: ffmpeg -f gif -i infile.gif outfile.mp4
|
||||
"""
|
||||
count = max(count, 1)
|
||||
if count % 2 == 0:
|
||||
length = int(count / 2)
|
||||
else:
|
||||
length = int(count / 2) + count % 2
|
||||
|
||||
gif_image = Path(path + image_file)
|
||||
png_file = image_file.replace(".gif", "_grid.png")
|
||||
png_image = Path(path).joinpath(png_file)
|
||||
|
||||
filecheck = png_image.is_file()
|
||||
|
||||
_LOGGER.debug("Generating png image grid %s from %s", png_image, gif_image)
|
||||
if filecheck:
|
||||
# cleanup_images expects a tuple or string path, so we use string parts here
|
||||
# or we could update cleanup_images to handle Path objects natively later.
|
||||
cleanup_images(str(png_image.parent) + "/", png_image.name)
|
||||
_LOGGER.debug("Removing old png grid: %s", png_image)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
str(gif_image),
|
||||
"-r",
|
||||
"0.20",
|
||||
"-filter_complex",
|
||||
f"tile=2x{length}:padding=10:color=black",
|
||||
str(png_image),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as err:
|
||||
_LOGGER.error("FFmpeg failed to generate grid image: %s", err)
|
||||
|
||||
|
||||
def generate_delivery_gif(
|
||||
delivery_images: list,
|
||||
gif_path: str,
|
||||
duration: int = 3000,
|
||||
) -> bool:
|
||||
"""Generate an animated GIF from delivery images.
|
||||
|
||||
Args:
|
||||
delivery_images: List of image file paths
|
||||
gif_path: Path where the GIF should be saved
|
||||
duration: Duration for each frame in milliseconds (default: 3000)
|
||||
|
||||
Returns:
|
||||
bool: True if GIF was created successfully, False otherwise
|
||||
|
||||
"""
|
||||
try:
|
||||
# Open all images
|
||||
corrected_images = []
|
||||
for img_path in delivery_images:
|
||||
img = Image.open(img_path)
|
||||
img = ImageOps.exif_transpose(img) # auto-rotates according to EXIF
|
||||
corrected_images.append(img)
|
||||
|
||||
# Create animated GIF
|
||||
corrected_images[0].save(
|
||||
gif_path,
|
||||
format="GIF",
|
||||
append_images=corrected_images[1:],
|
||||
save_all=True,
|
||||
duration=duration,
|
||||
loop=0, # Infinite loop
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Generated animated GIF with %d delivery images at %s",
|
||||
len(delivery_images),
|
||||
gif_path,
|
||||
)
|
||||
|
||||
except (OSError, ValueError, Image.UnidentifiedImageError) as e:
|
||||
_LOGGER.error("Error creating animated GIF: %s", e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _get_courier_info(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigEntry,
|
||||
amazon: bool,
|
||||
ups: bool,
|
||||
walmart: bool,
|
||||
fedex: bool,
|
||||
) -> tuple[str, str]:
|
||||
"""Determine the path and default image for the active courier."""
|
||||
configs = [
|
||||
(
|
||||
amazon,
|
||||
CONF_AMAZON_CUSTOM_IMG,
|
||||
CONF_AMAZON_CUSTOM_IMG_FILE,
|
||||
DEFAULT_AMAZON_CUSTOM_IMG_FILE,
|
||||
"no_deliveries_amazon.jpg",
|
||||
"amazon",
|
||||
),
|
||||
(
|
||||
ups,
|
||||
CONF_UPS_CUSTOM_IMG,
|
||||
CONF_UPS_CUSTOM_IMG_FILE,
|
||||
DEFAULT_UPS_CUSTOM_IMG_FILE,
|
||||
"no_deliveries_ups.jpg",
|
||||
"ups",
|
||||
),
|
||||
(
|
||||
walmart,
|
||||
CONF_WALMART_CUSTOM_IMG,
|
||||
CONF_WALMART_CUSTOM_IMG_FILE,
|
||||
DEFAULT_WALMART_CUSTOM_IMG_FILE,
|
||||
"no_deliveries_walmart.jpg",
|
||||
"walmart",
|
||||
),
|
||||
(
|
||||
fedex,
|
||||
CONF_FEDEX_CUSTOM_IMG,
|
||||
CONF_FEDEX_CUSTOM_IMG_FILE,
|
||||
DEFAULT_FEDEX_CUSTOM_IMG_FILE,
|
||||
"no_deliveries_fedex.jpg",
|
||||
"fedex",
|
||||
),
|
||||
]
|
||||
|
||||
base_path = f"{hass.config.path()}/{default_image_path(hass, config)}"
|
||||
|
||||
for (
|
||||
active,
|
||||
img_conf,
|
||||
file_conf,
|
||||
default_file_conf,
|
||||
local_default,
|
||||
sub_dir,
|
||||
) in configs:
|
||||
if active:
|
||||
_LOGGER.debug("Processing %s image file name", sub_dir.title())
|
||||
if config.get(img_conf):
|
||||
mail_none = config.get(file_conf) or default_file_conf
|
||||
_LOGGER.debug("Using custom %s image: %s", sub_dir.title(), mail_none)
|
||||
else:
|
||||
mail_none = str(Path(__file__).parent.parent / local_default)
|
||||
_LOGGER.debug("Using default %s image: %s", sub_dir.title(), mail_none)
|
||||
return f"{base_path}{sub_dir}", mail_none
|
||||
|
||||
# Standard mail case
|
||||
path = base_path.rstrip("/")
|
||||
if config.get(CONF_CUSTOM_IMG):
|
||||
mail_none = config.get(CONF_CUSTOM_IMG_FILE) or DEFAULT_CUSTOM_IMG_FILE
|
||||
else:
|
||||
mail_none = str(Path(__file__).parent.parent / "mail_none.gif")
|
||||
return path, mail_none
|
||||
|
||||
|
||||
def _get_image_name_from_directory(
|
||||
path: str, mail_none: str, sha1: str, ext: str
|
||||
) -> str:
|
||||
"""Check existing images and return a filename."""
|
||||
image_name = os.path.split(mail_none)[1]
|
||||
today = get_formatted_date()
|
||||
|
||||
try:
|
||||
for file_path in Path(path).iterdir():
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
filename = file_path.name
|
||||
if filename == "generic_deliveries.gif":
|
||||
continue
|
||||
is_image_file = filename.endswith(".gif") or (
|
||||
filename.endswith(".jpg") and ext == ".jpg"
|
||||
)
|
||||
if is_image_file:
|
||||
try:
|
||||
created = datetime.datetime.fromtimestamp(
|
||||
file_path.stat().st_ctime,
|
||||
).strftime("%d-%b-%Y")
|
||||
# If it's the correct hash OR created today, we can reuse it
|
||||
if sha1 == hash_file(str(file_path)) or today == created:
|
||||
image_name = filename
|
||||
break
|
||||
image_name = f"{uuid.uuid4()!s}{ext}"
|
||||
except OSError as err:
|
||||
_LOGGER.error("Problem accessing file %s: %s", filename, err)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Error accessing directory %s: %s", path, err)
|
||||
|
||||
if image_name in mail_none:
|
||||
image_name = f"{uuid.uuid4()!s}{ext}"
|
||||
_LOGGER.debug("=== image_file_name GENERATED NEW UUID: %s ===", image_name)
|
||||
else:
|
||||
_LOGGER.debug("=== image_file_name USING EXISTING: %s ===", image_name)
|
||||
|
||||
return image_name
|
||||
|
||||
|
||||
def image_file_name(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigEntry,
|
||||
amazon: bool = False,
|
||||
ups: bool = False,
|
||||
walmart: bool = False,
|
||||
fedex: bool = False,
|
||||
) -> str:
|
||||
"""Determine if filename is to be changed or not.
|
||||
|
||||
Returns filename
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"=== image_file_name CALLED === - amazon: %s, ups: %s, walmart: %s, fedex: %s",
|
||||
amazon,
|
||||
ups,
|
||||
walmart,
|
||||
fedex,
|
||||
)
|
||||
|
||||
path, mail_none = _get_courier_info(hass, config, amazon, ups, walmart, fedex)
|
||||
image_name = os.path.split(mail_none)[1]
|
||||
|
||||
# Path check
|
||||
try:
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Error creating directory: %s", err)
|
||||
return image_name
|
||||
|
||||
# SHA1 file hash check
|
||||
try:
|
||||
sha1 = hash_file(mail_none)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Problem accessing file: %s, error returned: %s", mail_none, err)
|
||||
return image_name
|
||||
|
||||
ext = ".jpg" if ups or walmart or fedex else ".gif"
|
||||
return _get_image_name_from_directory(path, mail_none, sha1, ext)
|
||||
@@ -0,0 +1,722 @@
|
||||
"""IMAP connection and search utilities for Mail and Packages."""
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
import aioimaplib
|
||||
from aioimaplib import (
|
||||
AUTH,
|
||||
IMAP4,
|
||||
IMAP4_SSL,
|
||||
NONAUTH,
|
||||
SELECTED,
|
||||
AioImapException,
|
||||
Cmd,
|
||||
Command,
|
||||
Exec,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import ssl
|
||||
|
||||
from custom_components.mail_and_packages.const import DEFAULT_IMAP_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Register ESEARCH command if not already present in aioimaplib
|
||||
if "ESEARCH" not in aioimaplib.Commands:
|
||||
aioimaplib.Commands["ESEARCH"] = Cmd("ESEARCH", (AUTH, SELECTED), Exec.is_async)
|
||||
|
||||
|
||||
def encode_imap_utf7(s: str) -> str:
|
||||
"""Encode a string into IMAP modified UTF-7."""
|
||||
res = []
|
||||
unicode_buffer = []
|
||||
|
||||
def flush_unicode():
|
||||
if unicode_buffer:
|
||||
u_str = "".join(unicode_buffer)
|
||||
encoded_bytes = u_str.encode("utf-16be")
|
||||
b64 = (
|
||||
binascii.b2a_base64(encoded_bytes)
|
||||
.decode("ascii")
|
||||
.rstrip("\n=")
|
||||
.replace("/", ",")
|
||||
)
|
||||
res.append(f"&{b64}-")
|
||||
unicode_buffer.clear()
|
||||
|
||||
for char in s:
|
||||
ord_c = ord(char)
|
||||
if 0x20 <= ord_c <= 0x7E:
|
||||
if char == "&":
|
||||
flush_unicode()
|
||||
res.append("&-")
|
||||
else:
|
||||
if unicode_buffer:
|
||||
flush_unicode()
|
||||
res.append(char)
|
||||
else:
|
||||
unicode_buffer.append(char)
|
||||
|
||||
flush_unicode()
|
||||
return "".join(res)
|
||||
|
||||
|
||||
def decode_imap_utf7(s: str) -> str:
|
||||
"""Decode a string from IMAP modified UTF-7."""
|
||||
res = []
|
||||
i = 0
|
||||
n = len(s)
|
||||
while i < n:
|
||||
char = s[i]
|
||||
if char == "&":
|
||||
end = s.find("-", i + 1)
|
||||
if end == -1:
|
||||
res.append("&")
|
||||
i += 1
|
||||
elif end == i + 1:
|
||||
res.append("&")
|
||||
i += 2
|
||||
else:
|
||||
b64_part = s[i + 1 : end]
|
||||
b64_part = b64_part.replace(",", "/")
|
||||
pad = len(b64_part) % 4
|
||||
if pad:
|
||||
b64_part += "=" * (4 - pad)
|
||||
try:
|
||||
decoded_bytes = binascii.a2b_base64(b64_part)
|
||||
res.append(decoded_bytes.decode("utf-16be"))
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
res.append(s[i : end + 1])
|
||||
i = end + 1
|
||||
else:
|
||||
res.append(char)
|
||||
i += 1
|
||||
|
||||
return "".join(res)
|
||||
|
||||
|
||||
_ATOM_SPECIALS = frozenset('(){%*"\\] ')
|
||||
|
||||
|
||||
def _is_imap_atom(s: str) -> bool:
|
||||
"""Check if the string is a valid IMAP atom."""
|
||||
return bool(s) and all(0x20 < ord(c) < 0x7F and c not in _ATOM_SPECIALS for c in s)
|
||||
|
||||
|
||||
def quote_folder(folder: str) -> str:
|
||||
"""Ensure folder name is properly quoted for IMAP commands."""
|
||||
if folder.startswith('"') and folder.endswith('"'):
|
||||
return folder
|
||||
return folder if _is_imap_atom(folder) else f'"{folder}"'
|
||||
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Raise exception for invalid credentials."""
|
||||
|
||||
|
||||
async def login(
|
||||
hass: HomeAssistant,
|
||||
host: str,
|
||||
port: int,
|
||||
user: str,
|
||||
pwd: str,
|
||||
security: str,
|
||||
verify: bool = True,
|
||||
oauth_token: str | None = None,
|
||||
timeout: float = DEFAULT_IMAP_TIMEOUT,
|
||||
) -> IMAP4_SSL | IMAP4:
|
||||
"""Login to IMAP server asynchronously.
|
||||
|
||||
Supports both password and OAuth2 (XOAUTH2) authentication.
|
||||
If oauth_token is provided, uses XOAUTH2 SASL mechanism.
|
||||
Otherwise falls back to standard LOGIN command.
|
||||
"""
|
||||
ssl_context = (
|
||||
ssl.client_context(ssl.SSLCipherList.PYTHON_DEFAULT)
|
||||
if verify
|
||||
else ssl.create_no_verify_ssl_context()
|
||||
)
|
||||
if security == "SSL":
|
||||
account = IMAP4_SSL(
|
||||
host=host, port=port, ssl_context=ssl_context, timeout=timeout
|
||||
)
|
||||
else:
|
||||
account = IMAP4(host=host, port=port, timeout=timeout)
|
||||
|
||||
await account.wait_hello_from_server()
|
||||
|
||||
if account.protocol.state == NONAUTH:
|
||||
try:
|
||||
if oauth_token:
|
||||
await account.xoauth2(user, oauth_token)
|
||||
else:
|
||||
await account.login(user, pwd)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error logging in to IMAP Server: %s", err)
|
||||
raise InvalidAuth from err
|
||||
|
||||
if account.protocol.state not in {AUTH, SELECTED}:
|
||||
_LOGGER.error("Error logging in to IMAP Server")
|
||||
raise InvalidAuth
|
||||
return account
|
||||
|
||||
|
||||
async def selectfolder(account: IMAP4_SSL, folder: str) -> bool:
|
||||
"""Select folder inside the mailbox asynchronously."""
|
||||
if getattr(account, "_current_folder", None) == folder:
|
||||
return True
|
||||
|
||||
encoded_folder = encode_imap_utf7(folder)
|
||||
quoted_folder = quote_folder(encoded_folder)
|
||||
|
||||
try:
|
||||
await account.select(quoted_folder)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error selecting folder %s: %s", folder, err)
|
||||
return False
|
||||
else:
|
||||
account._current_folder = folder # noqa: SLF001
|
||||
return True
|
||||
|
||||
|
||||
def clean_search_string(val: str) -> str:
|
||||
"""Clean search string for IMAP search compatibility.
|
||||
|
||||
Normalizes Unicode characters to NFKD decomposed form, strips non-ASCII
|
||||
characters to ensure compatibility with US-ASCII only IMAP servers,
|
||||
and removes any double quotes to prevent syntax corruption.
|
||||
"""
|
||||
if not val:
|
||||
return ""
|
||||
normalized = unicodedata.normalize("NFKD", val)
|
||||
cleaned = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
return cleaned.replace('"', "")
|
||||
|
||||
|
||||
def build_search( # noqa: C901
|
||||
address: list,
|
||||
date: str,
|
||||
subject: str | list[str] = "",
|
||||
body: str | list[str] = "",
|
||||
header: str = "",
|
||||
is_yahoo: bool = False,
|
||||
) -> tuple:
|
||||
"""Build IMAP search query.
|
||||
|
||||
Return tuple of utf8 flag and search query.
|
||||
Non-ASCII characters are stripped from subject to ensure compatibility
|
||||
with servers that only support US-ASCII charset (e.g. Microsoft Exchange).
|
||||
IMAP SUBJECT performs substring matching, so stripping non-ASCII chars
|
||||
still matches the original subject (e.g. 'Livr' matches 'Livré').
|
||||
|
||||
When `header` is provided, each address is matched as either a forwarded
|
||||
email (via HEADER substring match) OR a direct email (via FROM), so the
|
||||
same config works for carriers that are forwarded through a service like
|
||||
SimpleLogin AND carriers whose emails arrive directly in the mailbox.
|
||||
IMAP HEADER does substring matching, so "mcinfo@ups.com" will match a
|
||||
header value of "UPS <mcinfo@ups.com>".
|
||||
"""
|
||||
the_date = f"SINCE {date}"
|
||||
|
||||
if not address:
|
||||
raise ValueError("address list must not be empty")
|
||||
|
||||
# Build the address/header clause
|
||||
if header:
|
||||
# Each address matches via header (forwarded) OR FROM (direct), so
|
||||
# users with mixed setups (some carriers forwarded, others direct)
|
||||
# don't need separate configurations.
|
||||
parts = [f'OR HEADER "{header}" "{a}" FROM "{a}"' for a in address]
|
||||
if len(parts) == 1:
|
||||
addr_clause = f"({parts[0]})" if is_yahoo else parts[0]
|
||||
else:
|
||||
or_prefix = " ".join(["OR"] * (len(parts) - 1))
|
||||
addr_clause = (
|
||||
f"({or_prefix} {' '.join(parts)})"
|
||||
if is_yahoo
|
||||
else f"{or_prefix} {' '.join(parts)}"
|
||||
)
|
||||
elif len(address) == 1:
|
||||
addr_clause = f'FROM "{address[0]}"'
|
||||
else:
|
||||
joined = '" FROM "'.join(address)
|
||||
or_prefix = " ".join(["OR"] * (len(address) - 1))
|
||||
addr_clause = (
|
||||
f'({or_prefix} FROM "{joined}")'
|
||||
if is_yahoo
|
||||
else f'{or_prefix} FROM "{joined}"'
|
||||
)
|
||||
|
||||
# Handle multiple subjects
|
||||
subject_part = ""
|
||||
if subject:
|
||||
subjects = [subject] if isinstance(subject, str) else subject
|
||||
safe_subjects = [clean_search_string(s) for s in subjects]
|
||||
safe_subjects = [s for s in safe_subjects if s]
|
||||
|
||||
if len(safe_subjects) == 1:
|
||||
subject_part = f'SUBJECT "{safe_subjects[0]}"'
|
||||
elif len(safe_subjects) > 1:
|
||||
subject_prefix = " ".join(["OR"] * (len(safe_subjects) - 1))
|
||||
subject_joined = '" SUBJECT "'.join(safe_subjects)
|
||||
subject_part = (
|
||||
f'({subject_prefix} SUBJECT "{subject_joined}")'
|
||||
if is_yahoo
|
||||
else f'{subject_prefix} SUBJECT "{subject_joined}"'
|
||||
)
|
||||
|
||||
# Handle multiple bodies
|
||||
body_part = ""
|
||||
if body:
|
||||
bodies = [body] if isinstance(body, str) else body
|
||||
safe_bodies = [clean_search_string(b) for b in bodies]
|
||||
safe_bodies = [b for b in safe_bodies if b]
|
||||
|
||||
if len(safe_bodies) == 1:
|
||||
body_part = f'BODY "{safe_bodies[0]}"'
|
||||
elif len(safe_bodies) > 1:
|
||||
body_prefix = " ".join(["OR"] * (len(safe_bodies) - 1))
|
||||
body_joined = '" BODY "'.join(safe_bodies)
|
||||
body_part = (
|
||||
f'({body_prefix} BODY "{body_joined}")'
|
||||
if is_yahoo
|
||||
else f'{body_prefix} BODY "{body_joined}"'
|
||||
)
|
||||
|
||||
if is_yahoo:
|
||||
if subject_part or body_part:
|
||||
search_criteria = f"{subject_part} {body_part}".strip()
|
||||
imap_search = f"({addr_clause} {search_criteria} {the_date})"
|
||||
else:
|
||||
imap_search = f"({addr_clause} {the_date})"
|
||||
elif subject_part or body_part:
|
||||
search_criteria = f"{subject_part} {body_part}".strip()
|
||||
imap_search = f"{addr_clause} {search_criteria} {the_date}"
|
||||
else:
|
||||
imap_search = f"{addr_clause} {the_date}"
|
||||
|
||||
_LOGGER.debug("DEBUG imap_search: %s", imap_search)
|
||||
|
||||
return (False, imap_search)
|
||||
|
||||
|
||||
def parse_search_response(lines: list[bytes]) -> list[bytes]:
|
||||
"""Parse IMAP SEARCH response lines and return list of UID/ID bytes.
|
||||
|
||||
Handles both standard server responses (prefixed with b"SEARCH")
|
||||
and mocked test inputs (which often contain raw UIDs directly).
|
||||
Filters out the SEARCH keyword, tagged OK/status responses,
|
||||
and any non-numeric tokens.
|
||||
"""
|
||||
uids = []
|
||||
for line in lines:
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
if parts[0] == b"SEARCH":
|
||||
# Check if this is a search result line, e.g. b"SEARCH 1001 1002"
|
||||
# (as opposed to b"SEARCH completed")
|
||||
if len(parts) > 1 and parts[1].isdigit():
|
||||
uids.extend(parts[1:])
|
||||
# Check if this line is just a list of numeric UIDs (mock/test compatibility)
|
||||
# and ignore status/existence responses like b"23 EXISTS"
|
||||
elif all(p.isdigit() for p in parts):
|
||||
uids.extend(parts)
|
||||
|
||||
return uids
|
||||
|
||||
|
||||
def _parse_esearch_line(line_bytes: bytes) -> list[bytes]:
|
||||
"""Parse a single ESEARCH line and return list of formatted UID bytes: b'folder/uid'."""
|
||||
line_str = line_bytes.decode("utf-8", "ignore")
|
||||
|
||||
# Extract the correlator inside parentheses
|
||||
start_paren = line_str.find("(")
|
||||
end_paren = line_str.find(")", start_paren) if start_paren != -1 else -1
|
||||
if start_paren == -1 or end_paren == -1:
|
||||
return []
|
||||
|
||||
correlator = line_str[start_paren + 1 : end_paren]
|
||||
|
||||
# Extract mailbox name (could be quoted or unquoted)
|
||||
mailbox_match = re.search(r'MAILBOX\s+"([^"]+)"', correlator)
|
||||
if not mailbox_match:
|
||||
mailbox_match = re.search(r"MAILBOX\s+(\S+)", correlator)
|
||||
if not mailbox_match:
|
||||
return []
|
||||
mailbox = mailbox_match.group(1)
|
||||
mailbox = decode_imap_utf7(mailbox)
|
||||
|
||||
# Extract the sequence set after 'UID ALL' anywhere in the line
|
||||
seq_match = re.search(r"UID\s+ALL\s+(\S+)", line_str)
|
||||
if not seq_match:
|
||||
return []
|
||||
seq_set = seq_match.group(1)
|
||||
|
||||
uids = []
|
||||
for part in seq_set.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if ":" in part:
|
||||
try:
|
||||
start_str, end_str = part.split(":", 1)
|
||||
start, end = int(start_str), int(end_str)
|
||||
if start <= end:
|
||||
uids.extend(str(x) for x in range(start, end + 1))
|
||||
else:
|
||||
uids.extend(str(x) for x in range(end, start + 1))
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
uids.append(part)
|
||||
return [f"{mailbox}/{uid}".encode() for uid in uids]
|
||||
|
||||
|
||||
async def _execute_single_search(account: IMAP4_SSL, search_query: str) -> list[bytes]: # noqa: C901
|
||||
"""Execute search query. If single folder, use standard search. If multiple, use hybrid ESEARCH/fallback."""
|
||||
folders = getattr(account, "_folders", ["INBOX"])
|
||||
|
||||
if len(folders) <= 1:
|
||||
res = await account.search(search_query, charset=None)
|
||||
if res.result == "OK" and res.lines:
|
||||
return parse_search_response(res.lines)
|
||||
return []
|
||||
|
||||
all_uids = []
|
||||
|
||||
# Check for MULTISEARCH capability safely (handling mock/AsyncMock in tests)
|
||||
is_multisearch = False
|
||||
if hasattr(account, "has_capability"):
|
||||
try:
|
||||
res = account.has_capability("MULTISEARCH")
|
||||
if asyncio.iscoroutine(res):
|
||||
res.close()
|
||||
is_multisearch = False
|
||||
else:
|
||||
is_multisearch = bool(res)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
if is_multisearch:
|
||||
# ESEARCH IN ("folder1" "folder2") query - encode and quote folders
|
||||
folder_list = " ".join([quote_folder(encode_imap_utf7(f)) for f in folders])
|
||||
args = ("IN", f"({folder_list})", search_query)
|
||||
try:
|
||||
timeout = getattr(account, "timeout", None)
|
||||
if not isinstance(timeout, (int, float)):
|
||||
timeout = None
|
||||
res = await account.protocol.execute(
|
||||
Command(
|
||||
"ESEARCH",
|
||||
account.protocol.new_tag(),
|
||||
*args,
|
||||
loop=account.protocol.loop,
|
||||
timeout=timeout,
|
||||
)
|
||||
)
|
||||
if res.result == "OK":
|
||||
for line in res.lines:
|
||||
if line:
|
||||
all_uids.extend(_parse_esearch_line(line))
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error executing ESEARCH: %s", err)
|
||||
else:
|
||||
# Sequential select and search fallback - no limits on configured folders
|
||||
for folder in folders:
|
||||
select_ok = await selectfolder(account, folder)
|
||||
if not select_ok:
|
||||
continue
|
||||
try:
|
||||
res = await account.uid_search(search_query, charset=None)
|
||||
if res.result == "OK" and res.lines:
|
||||
parsed = parse_search_response(res.lines)
|
||||
all_uids.extend(
|
||||
f"{folder}/{uid.decode()}".encode() for uid in parsed
|
||||
)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error searching folder %s: %s", folder, err)
|
||||
|
||||
return all_uids
|
||||
|
||||
|
||||
async def email_search( # noqa: C901
|
||||
account: IMAP4_SSL,
|
||||
address: list,
|
||||
date: str,
|
||||
subject: str | list[str] = "",
|
||||
body: str | list[str] = "",
|
||||
header: str = "",
|
||||
) -> tuple:
|
||||
"""Search emails with from/header, subject, and date asynchronously.
|
||||
|
||||
Always uses charset=None to avoid sending CHARSET in the IMAP SEARCH
|
||||
command, ensuring compatibility with servers like Microsoft Exchange
|
||||
that only support US-ASCII.
|
||||
|
||||
When `header` is provided, searches via HEADER criterion instead of FROM,
|
||||
matching the original sender in forwarding-service headers.
|
||||
|
||||
If multiple subjects are provided, they are searched in batches of 10
|
||||
to keep the search query length safe.
|
||||
"""
|
||||
folders = getattr(account, "_folders", ["INBOX"])
|
||||
is_yahoo = False
|
||||
if hasattr(account, "host") and isinstance(account.host, str):
|
||||
host_lower = account.host.lower()
|
||||
is_yahoo = "yahoo" in host_lower or "aol" in host_lower
|
||||
|
||||
# If there are more than 2 body patterns, do not search them server-side
|
||||
# to prevent slow query execution and timeouts on standard IMAP servers.
|
||||
# Instead, we let the shipper's client-side text filtering handle it.
|
||||
body_search = body
|
||||
if body:
|
||||
bodies = [body] if isinstance(body, str) else body
|
||||
if len(bodies) > 2:
|
||||
body_search = ""
|
||||
|
||||
if len(folders) <= 1:
|
||||
if not isinstance(subject, list) or len(subject) <= 10:
|
||||
_unused, search = build_search(
|
||||
address, date, subject, body_search, header, is_yahoo=is_yahoo
|
||||
)
|
||||
try:
|
||||
res = await account.search(search, charset=None)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error searching emails: %s", err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
parsed = parse_search_response(res.lines)
|
||||
return (res.result, [b" ".join(parsed)])
|
||||
|
||||
# Batch subjects in groups of 10
|
||||
all_matched_ids = []
|
||||
for i in range(0, len(subject), 10):
|
||||
batch = subject[i : i + 10]
|
||||
_unused, search = build_search(
|
||||
address, date, batch, body_search, header, is_yahoo=is_yahoo
|
||||
)
|
||||
try:
|
||||
res = await account.search(search, charset=None)
|
||||
if res.result == "OK" and res.lines:
|
||||
parsed = parse_search_response(res.lines)
|
||||
all_matched_ids.extend(parsed)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error searching emails batch: %s", err)
|
||||
|
||||
# Deduplicate and return in same format as individual search
|
||||
unique_ids = list(dict.fromkeys(all_matched_ids))
|
||||
return ("OK", [b" ".join(unique_ids)])
|
||||
|
||||
# Multi-folder search logic
|
||||
if not isinstance(subject, list) or len(subject) <= 10:
|
||||
_unused, search = build_search(
|
||||
address, date, subject, body_search, header, is_yahoo=is_yahoo
|
||||
)
|
||||
try:
|
||||
uids = await _execute_single_search(account, search)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error searching emails: %s", err)
|
||||
return ("BAD", str(err))
|
||||
return ("OK", [b" ".join(uids)])
|
||||
|
||||
# Batch subjects in groups of 10
|
||||
all_matched_ids = []
|
||||
for i in range(0, len(subject), 10):
|
||||
batch = subject[i : i + 10]
|
||||
_unused, search = build_search(
|
||||
address, date, batch, body_search, header, is_yahoo=is_yahoo
|
||||
)
|
||||
try:
|
||||
uids = await _execute_single_search(account, search)
|
||||
all_matched_ids.extend(uids)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error searching emails batch: %s", err)
|
||||
|
||||
# Deduplicate and return in same format as individual search
|
||||
unique_ids = list(dict.fromkeys(all_matched_ids))
|
||||
return ("OK", [b" ".join(unique_ids)])
|
||||
|
||||
|
||||
async def email_fetch(account: IMAP4_SSL, num, parts: str = "(RFC822)") -> tuple:
|
||||
"""Download specified email for parsing asynchronously."""
|
||||
if account.host == "imap.mail.me.com":
|
||||
parts = "BODY[]"
|
||||
|
||||
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
||||
if "/" in num_str:
|
||||
folder, num_str = num_str.rsplit("/", 1)
|
||||
await selectfolder(account, folder)
|
||||
try:
|
||||
res = await account.uid("FETCH", num_str, parts)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching email %s: %s", num_str, err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
return (res.result, res.lines)
|
||||
|
||||
try:
|
||||
res = await account.fetch(num_str, parts)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching email %s: %s", num_str, err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
return (res.result, res.lines)
|
||||
|
||||
|
||||
async def email_fetch_headers(account: IMAP4_SSL, num) -> tuple:
|
||||
"""Download only the subject header of an email asynchronously."""
|
||||
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
||||
if "/" in num_str:
|
||||
folder, num_str = num_str.rsplit("/", 1)
|
||||
await selectfolder(account, folder)
|
||||
try:
|
||||
res = await account.uid("FETCH", num_str, "(BODY[HEADER.FIELDS (SUBJECT)])")
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching email headers %s: %s", num_str, err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
return (res.result, res.lines)
|
||||
|
||||
try:
|
||||
res = await account.fetch(num_str, "(BODY[HEADER.FIELDS (SUBJECT)])")
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching email headers %s: %s", num_str, err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
return (res.result, res.lines)
|
||||
|
||||
|
||||
async def email_fetch_text(account: IMAP4_SSL, num, parts: str = "(BODY[1])") -> tuple:
|
||||
"""Download the specific part of the email body asynchronously."""
|
||||
if account.host == "imap.mail.me.com":
|
||||
parts = "BODY[]"
|
||||
|
||||
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
||||
if "/" in num_str:
|
||||
folder, num_str = num_str.rsplit("/", 1)
|
||||
await selectfolder(account, folder)
|
||||
try:
|
||||
res = await account.uid("FETCH", num_str, parts)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching email text %s: %s", num_str, err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
return (res.result, res.lines)
|
||||
|
||||
try:
|
||||
res = await account.fetch(num_str, parts)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching email text %s: %s", num_str, err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
return (res.result, res.lines)
|
||||
|
||||
|
||||
async def email_fetch_batch( # noqa: C901
|
||||
account: IMAP4_SSL, nums: list[str | bytes], parts: str = "(RFC822)"
|
||||
) -> tuple:
|
||||
"""Download specified emails for parsing asynchronously in a batch."""
|
||||
if not nums:
|
||||
return ("OK", [])
|
||||
|
||||
if account.host == "imap.mail.me.com":
|
||||
parts = "BODY[]"
|
||||
|
||||
# Check if any ID contains a folder prefix
|
||||
has_folder_prefix = False
|
||||
for num in nums:
|
||||
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
||||
if "/" in num_str:
|
||||
has_folder_prefix = True
|
||||
break
|
||||
|
||||
if not has_folder_prefix:
|
||||
num_strs = [
|
||||
num.decode() if isinstance(num, bytes) else str(num) for num in nums
|
||||
]
|
||||
num_list_str = ",".join(num_strs)
|
||||
try:
|
||||
res = await account.fetch(num_list_str, parts)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching emails batch %s: %s", num_list_str, err)
|
||||
return ("BAD", str(err))
|
||||
else:
|
||||
return (res.result, res.lines)
|
||||
|
||||
# Group nums by their folder prefix
|
||||
folder_to_nums = {}
|
||||
for num in nums:
|
||||
num_str = num.decode() if isinstance(num, bytes) else str(num)
|
||||
if "/" in num_str:
|
||||
folder, actual_num = num_str.rsplit("/", 1)
|
||||
else:
|
||||
folder, actual_num = None, num_str
|
||||
folder_to_nums.setdefault(folder, []).append(actual_num)
|
||||
|
||||
all_results = []
|
||||
overall_result = "OK"
|
||||
|
||||
for folder, folder_nums in folder_to_nums.items():
|
||||
if folder is not None:
|
||||
await selectfolder(account, folder)
|
||||
|
||||
num_list_str = ",".join(folder_nums)
|
||||
try:
|
||||
res = await account.uid("FETCH", num_list_str, parts)
|
||||
if res.result != "OK":
|
||||
overall_result = res.result
|
||||
all_results.extend(res.lines)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except (AioImapException, OSError) as err:
|
||||
_LOGGER.error("Error fetching emails batch %s: %s", num_list_str, err)
|
||||
return ("BAD", str(err))
|
||||
|
||||
return (overall_result, all_results)
|
||||
|
||||
|
||||
async def logout(account: IMAP4_SSL | IMAP4) -> None:
|
||||
"""Logout from IMAP server asynchronously."""
|
||||
try:
|
||||
await account.logout()
|
||||
except (TimeoutError, AioImapException, OSError, asyncio.CancelledError) as err:
|
||||
_LOGGER.debug("Error logging out of IMAP Server: %s", err)
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Shipper utility functions for Mail and Packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import email
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aioimaplib import IMAP4_SSL
|
||||
|
||||
from custom_components.mail_and_packages.utils.cache import EmailCache
|
||||
|
||||
from .imap import email_fetch
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_tracking(
|
||||
sdata: Any,
|
||||
account: type[IMAP4_SSL],
|
||||
the_format: str | None = None,
|
||||
cache: EmailCache | None = None,
|
||||
) -> list:
|
||||
"""Parse tracking numbers from email.
|
||||
|
||||
Returns list of tracking numbers
|
||||
"""
|
||||
tracking = []
|
||||
pattern = re.compile(rf"{the_format}")
|
||||
mail_list = sdata.split()
|
||||
_LOGGER.debug("Searching for tracking numbers in %s messages...", len(mail_list))
|
||||
|
||||
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 not isinstance(response_part, (bytes, bytearray)):
|
||||
continue
|
||||
|
||||
msg = email.message_from_bytes(response_part)
|
||||
|
||||
# 1. Search subject
|
||||
if found := _find_tracking_in_subject(msg, pattern):
|
||||
if found not in tracking:
|
||||
tracking.append(found)
|
||||
continue
|
||||
|
||||
# 2. Search body
|
||||
if the_format == "1Z?[0-9A-Z]{16}":
|
||||
found = _find_ups_tracking_in_raw(response_part, pattern)
|
||||
else:
|
||||
found = _find_tracking_in_body(msg, pattern, the_format)
|
||||
|
||||
if found and found not in tracking:
|
||||
tracking.append(found)
|
||||
|
||||
return tracking
|
||||
|
||||
|
||||
def _find_tracking_in_subject(
|
||||
msg: email.message.Message,
|
||||
pattern: re.Pattern,
|
||||
) -> str | None:
|
||||
"""Find tracking number in email subject."""
|
||||
email_subject = msg["subject"]
|
||||
if email_subject:
|
||||
email_subject = str(email_subject)
|
||||
if (found := pattern.findall(email_subject)) and len(found) > 0:
|
||||
_LOGGER.debug("Found tracking number in email subject: %s", found[0])
|
||||
return found[0]
|
||||
return None
|
||||
|
||||
|
||||
def _find_ups_tracking_in_raw(
|
||||
response_part: bytes | bytearray,
|
||||
pattern: re.Pattern,
|
||||
) -> str | None:
|
||||
"""UPS specific tracking search in raw email bytes."""
|
||||
try:
|
||||
email_content = str(response_part, "utf-8", errors="ignore")
|
||||
if (found := pattern.findall(email_content)) and len(found) > 0:
|
||||
_LOGGER.debug("Found tracking number in email: %s", found[0])
|
||||
return found[0]
|
||||
except (TypeError, UnicodeError) as err:
|
||||
_LOGGER.debug("Error processing email content: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
def _find_tracking_in_body(
|
||||
msg: email.message.Message,
|
||||
pattern: re.Pattern,
|
||||
the_format: str,
|
||||
) -> str | None:
|
||||
"""Search for tracking number in email body parts."""
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() not in ["text/html", "text/plain"]:
|
||||
continue
|
||||
email_msg = part.get_payload(decode=True)
|
||||
email_msg = email_msg.decode("utf-8", "ignore")
|
||||
if (found := pattern.findall(email_msg)) and len(found) > 0:
|
||||
tracking_num = found[0]
|
||||
# DHL is special
|
||||
if " " in the_format:
|
||||
tracking_num = tracking_num.split(" ")[1]
|
||||
|
||||
_LOGGER.debug("Found tracking number in email body: %s", tracking_num)
|
||||
return tracking_num
|
||||
return None
|
||||
|
||||
|
||||
def save_image_data_to_disk(shipper_name: str, path: str, image_data: bytes) -> bool:
|
||||
"""Write image bytes to disk and verify."""
|
||||
try:
|
||||
# Ensure directory exists
|
||||
directory = Path(path).parent
|
||||
if not directory.is_dir():
|
||||
_LOGGER.debug("%s - Creating directory: %s", shipper_name, directory)
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Error creating directory: %s", err)
|
||||
return False
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s - Writing %d bytes to file: %s",
|
||||
shipper_name,
|
||||
len(image_data),
|
||||
path,
|
||||
)
|
||||
with Path(path).open("wb") as the_file:
|
||||
the_file.write(image_data)
|
||||
|
||||
except OSError as err:
|
||||
_LOGGER.error(
|
||||
"Error saving %s delivery photo to %s: %s",
|
||||
shipper_name,
|
||||
path,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
if Path(path).exists():
|
||||
return True
|
||||
|
||||
_LOGGER.error(
|
||||
"%s - ERROR: File write reported success but file doesn't exist: %s",
|
||||
shipper_name,
|
||||
path,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def generic_delivery_image_extraction(
|
||||
sdata: Any,
|
||||
image_path: str,
|
||||
image_name: str,
|
||||
shipper_name: str,
|
||||
image_type: str,
|
||||
cid_name: str | None = None,
|
||||
attachment_filename_pattern: str | None = None,
|
||||
) -> bool:
|
||||
"""Extract delivery photos from email."""
|
||||
_LOGGER.debug("Attempting to extract %s delivery photo", shipper_name)
|
||||
|
||||
msg = (
|
||||
email.message_from_bytes(sdata)
|
||||
if isinstance(sdata, (bytes, bytearray))
|
||||
else email.message_from_string(sdata)
|
||||
)
|
||||
|
||||
normalized_image_path = image_path.rstrip("/") + "/"
|
||||
shipper_path = f"{normalized_image_path}{shipper_name}/"
|
||||
full_path = shipper_path + image_name
|
||||
|
||||
# Pass 1: CID search
|
||||
if cid_name and (
|
||||
found := _extract_from_cid(msg, cid_name, shipper_name, full_path, image_type)
|
||||
):
|
||||
return found
|
||||
|
||||
# Pass 2: HTML/Base64 search
|
||||
if found := _extract_from_html(msg, cid_name, shipper_name, full_path, image_type):
|
||||
return found
|
||||
|
||||
# Pass 3: Attachment search
|
||||
return _extract_from_attachments(
|
||||
msg,
|
||||
attachment_filename_pattern,
|
||||
shipper_name,
|
||||
full_path,
|
||||
image_type,
|
||||
)
|
||||
|
||||
|
||||
def _extract_from_cid(
|
||||
msg: email.message.Message,
|
||||
cid_name: str,
|
||||
shipper_name: str,
|
||||
full_path: str,
|
||||
image_type: str,
|
||||
) -> bool:
|
||||
"""Pass 1: Look for CID embedded images."""
|
||||
content_type = f"image/{image_type}"
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == content_type:
|
||||
content_id = part.get("Content-ID")
|
||||
if content_id and content_id.strip("<>") == cid_name:
|
||||
return save_image_data_to_disk(
|
||||
shipper_name,
|
||||
full_path,
|
||||
part.get_payload(decode=True),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _extract_from_html(
|
||||
msg: email.message.Message,
|
||||
cid_name: str | None,
|
||||
shipper_name: str,
|
||||
full_path: str,
|
||||
image_type: str,
|
||||
) -> bool:
|
||||
"""Pass 2: Look for HTML content with CID references or base64."""
|
||||
base64_pattern = rf"data:image/{image_type};base64,((?:[A-Za-z0-9+/]{{4}})*(?:[A-Za-z0-9+/]{{2}}==|[A-Za-z0-9+/]{{3}}=)?)"
|
||||
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() != "text/html":
|
||||
continue
|
||||
|
||||
payload = part.get_payload(decode=True)
|
||||
content = (
|
||||
payload.decode("utf-8", "ignore")
|
||||
if isinstance(payload, bytes)
|
||||
else str(payload)
|
||||
)
|
||||
|
||||
# Base64 check
|
||||
if matches := re.findall(base64_pattern, content):
|
||||
try:
|
||||
base64_data = matches[0].replace(" ", "").replace("=3D", "=")
|
||||
return save_image_data_to_disk(
|
||||
shipper_name,
|
||||
full_path,
|
||||
base64.b64decode(base64_data),
|
||||
)
|
||||
except (OSError, ValueError, TypeError) as err:
|
||||
_LOGGER.error(
|
||||
"Error saving %s delivery photo from base64: %s",
|
||||
shipper_name,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_from_attachments(
|
||||
msg: email.message.Message,
|
||||
pattern: str | None,
|
||||
shipper_name: str,
|
||||
full_path: str,
|
||||
image_type: str,
|
||||
) -> bool:
|
||||
"""Pass 3: Look for attachments."""
|
||||
content_type = f"image/{image_type}"
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == content_type:
|
||||
filename = part.get_filename()
|
||||
if not filename:
|
||||
continue
|
||||
if pattern and pattern.lower() not in filename.lower():
|
||||
continue
|
||||
|
||||
try:
|
||||
return save_image_data_to_disk(
|
||||
shipper_name,
|
||||
full_path,
|
||||
part.get_payload(decode=True),
|
||||
)
|
||||
except (OSError, ValueError, TypeError) as err:
|
||||
_LOGGER.error(
|
||||
"Error saving %s delivery photo to %s: %s",
|
||||
shipper_name,
|
||||
full_path,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
return False
|
||||
Reference in New Issue
Block a user