311 lines
9.3 KiB
Python
311 lines
9.3 KiB
Python
"""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 | None
|
|
) -> bool:
|
|
"""Write image bytes to disk and verify."""
|
|
if not image_data:
|
|
# Extraction can hand us zero bytes (e.g. a bare/empty base64 data URI
|
|
# in the email HTML). Writing that produces a 0-byte "photo" the
|
|
# camera then serves as a broken image — report failure instead so
|
|
# the caller falls through to the next extraction pass.
|
|
_LOGGER.debug(
|
|
"%s - No image data extracted; not writing %s",
|
|
shipper_name,
|
|
path,
|
|
)
|
|
return False
|
|
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. Skip empty matches: a bare "data:image/...;base64,"
|
|
# URI (seen in real FedEx delivered emails) matches zero characters,
|
|
# and taking it would discard a real photo later in the same part.
|
|
matches = [m for m in re.findall(base64_pattern, content) if m]
|
|
if matches:
|
|
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
|