Model/infrastructure/email/ses_smtp_email_sender.py
Khalim Conn-Kowlessar 91db972d48 Bulk download: readable email, deal-name folders, visible worker logs
First successful live run surfaced three issues:

1. Email looked rubbish (a giant raw presigned URL). Now sends a proper HTML
   email with a 'Download documents' button plus a plain-text fallback, and a
   summary (N documents across M properties, expiry). Email delivery is now
   best-effort: a transport failure no longer loses an already-built package
   (the link is still on sub_task.outputs), and the SMTP connect has a 30s
   timeout so an unreachable SES endpoint fails fast instead of hanging to the
   900s Lambda timeout.

2. Every folder was 'address unavailable (...)': the resolver read property.address,
   but these are HubSpot deals with no property row. It now uses the deal's
   dealname from hubspot_deal_data.

3. No logs / no idea why a run took ~9 minutes: the worker's INFO logs were
   dropped (Lambda root logger defaults to WARNING). The handler now raises the
   level, and the orchestrator logs per-phase timing and volume (gather+plan,
   packaged N files / X MB, upload, email, total) so the slow phase is visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:02:38 +00:00

80 lines
2.6 KiB
Python

"""SES-over-SMTP implementation of the outbound-email port (ADR-0059).
Sends via `smtplib` against the SES SMTP endpoint using the credentials
provisioned in terraform (`modules/ses`, Secrets Manager
`${stage}/ses/smtp_credentials`). The SMTP transport is injected so the sender
is testable without a live server.
"""
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from types import TracebackType
from typing import Any, Callable, Optional, Protocol, Type, cast
class SmtpTransport(Protocol):
"""The minimal SMTP surface the sender uses — satisfied structurally by
`smtplib.SMTP` and by test fakes."""
def __enter__(self) -> "SmtpTransport": ...
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc: Optional[BaseException],
tb: Optional[TracebackType],
) -> Optional[bool]: ...
def starttls(self) -> Any: ...
def login(self, user: str, password: str) -> Any: ...
def send_message(self, message: EmailMessage) -> Any: ...
# A short connect/IO timeout so an unreachable SES endpoint fails fast and is
# logged, instead of blocking the Lambda until its 900s timeout (ADR-0059).
_SMTP_TIMEOUT_SECONDS = 30
def _default_smtp(host: str, port: int) -> SmtpTransport:
# `smtplib.SMTP` satisfies SmtpTransport structurally; its stdlib stubs
# declare wider signatures (extra optional args, an SMTP-typed context
# manager), so the cast bridges the real transport to the port's surface.
return cast(SmtpTransport, smtplib.SMTP(host, port, timeout=_SMTP_TIMEOUT_SECONDS))
class SesSmtpEmailSender:
def __init__(
self,
*,
host: str,
port: int,
username: str,
password: str,
from_address: str,
smtp_factory: Callable[[str, int], SmtpTransport] = _default_smtp,
) -> None:
self._host = host
self._port = port
self._username = username
self._password = password
self._from_address = from_address
self._smtp_factory = smtp_factory
def send(
self, *, to: str, subject: str, body: str, html_body: Optional[str] = None
) -> None:
message = EmailMessage()
message["From"] = self._from_address
message["To"] = to
message["Subject"] = subject
message.set_content(body)
if html_body is not None:
message.add_alternative(html_body, subtype="html")
with self._smtp_factory(self._host, self._port) as smtp:
smtp.starttls()
smtp.login(self._username, self._password)
smtp.send_message(message)