import hashlib import json import logging import time from email.utils import parseaddr from functools import cached_property from typing import Any, Literal, Self from fansifter_common.email import ( EmailInfo, EmailType, decode_local_part_to_email, decode_reply_to_local_part, ) from pydantic import ( Base64Bytes, BaseModel, ConfigDict, EmailStr, Field, ValidationInfo, computed_field, model_validator, ) from streaming_form_data import ( # type: ignore[reportMissingTypeStubs] StreamingFormDataParser, ) from streaming_form_data.targets import ( # type: ignore[reportMissingTypeStubs] ValueTarget, ) from app.exceptions import RequestValidationError logger = logging.getLogger(__name__) class RequestHeaders(BaseModel): content_type: str = Field(alias="content-type") class BaseRequest(BaseModel): model_config = ConfigDict( arbitrary_types_allowed=True, ) headers: RequestHeaders body: Base64Bytes recipient: ValueTarget = Field(default_factory=ValueTarget) envelope: ValueTarget = Field(default_factory=ValueTarget) sender: ValueTarget = Field(default_factory=ValueTarget) email_subject: ValueTarget = Field(default_factory=ValueTarget) email_text: ValueTarget = Field(default_factory=ValueTarget) email_html: ValueTarget = Field(default_factory=ValueTarget) @staticmethod def _extract_email_address(value: str) -> str: # Handle both plain email and display-name format: "Name ". _, email_address = parseaddr(value) return email_address or value @staticmethod def _decode_bytes(data: bytes) -> str: if not data: return "" try: return data.decode("utf-8") except UnicodeDecodeError: return data.decode("latin-1") @cached_property def _envelope_recipient(self) -> str: envelope = json.loads(self._decode_bytes(self.envelope.value)) return envelope["to"][0] @cached_property def _email_info(self) -> EmailInfo: recipient_email = self._extract_email_address(self._envelope_recipient) local_part = recipient_email.split("@")[0] return decode_local_part_to_email(local_part) @computed_field @property def version(self) -> str: return self._email_info.version @computed_field @property def email_type(self) -> EmailType: return self._email_info.email_type @computed_field @property def email_id(self) -> str: return self._email_info.email_id @computed_field @property def automated_email_trigger_id(self) -> str | None: return self._email_info.automated_email_trigger_id @computed_field @property def email_campaign_id(self) -> str: return self._email_info.email_id @computed_field @property def email(self) -> str: _, addr = parseaddr(self.sender.value.decode()) email = str(EmailStr._validate(addr)) # type: ignore return email @computed_field @property def email_hash(self) -> str: return hashlib.sha256(self.email.encode()).hexdigest() @computed_field @property def recipient_email(self) -> str: _, addr = parseaddr(self._envelope_recipient) email = str(EmailStr._validate(addr)) # type: ignore return email @computed_field @property def recipient_email_local_part(self) -> str: return self.recipient_email.split("@")[0] @computed_field @property def subject(self) -> str: return self._decode_bytes(self.email_subject.value) @computed_field @property def text(self) -> str: return self._decode_bytes(self.email_text.value) @computed_field @property def html(self) -> str: return self._decode_bytes(self.email_html.value) @computed_field @cached_property def timestamp(self) -> int: return int(time.time()) @model_validator(mode="after") def body_fields_parser(self) -> Self: parser = StreamingFormDataParser(headers=self.headers.model_dump(by_alias=True)) parser.register("to", self.recipient) parser.register("envelope", self.envelope) parser.register("from", self.sender) parser.register("subject", self.email_subject) parser.register("text", self.email_text) parser.register("html", self.email_html) parser.data_received(self.body) return self class UnsubRequest(BaseRequest): path: Literal["/inbound", "/inbound-unsub"] class ReplyRequest(BaseRequest): path: Literal["/inbound-reply"] _secret_key: str @model_validator(mode="after") def store_secret_key(self, info: ValidationInfo) -> Self: context: dict[str, Any] = info.context # type: ignore self._secret_key = context["secret_key"] return self @cached_property def _email_info(self) -> EmailInfo: recipient_email = self._extract_email_address(self._envelope_recipient) local_part = recipient_email.split("@")[0] try: decoded = decode_reply_to_local_part(local_part, key=self._secret_key) except Exception as exc: logger.error( "Failed to decode reply-to local part for email address: %s", recipient_email, ) raise RequestValidationError( "Failed to decode reply-to local part" ) from exc return decoded