mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 17:03:02 +00:00
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>
80 lines
2.6 KiB
Python
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)
|