Updated apps

This commit is contained in:
2026-07-09 13:30:12 -04:00
parent 0f7585754d
commit 6cc7212cef
262 changed files with 16124 additions and 107 deletions
@@ -0,0 +1 @@
"""Utility functions for Mail and Packages."""
@@ -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("&amp;", "&")) 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