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