Files
HomeAssistantVS/custom_components/mail_and_packages/utils/email.py
T
2026-07-09 13:30:12 -04:00

202 lines
5.6 KiB
Python

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