From 7466b78e54ac79ec569f863d1911422f676f032d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 8 Jul 2026 09:25:56 +0000 Subject: [PATCH] =?UTF-8?q?Send=20a=20document-download=20email=20to=20the?= =?UTF-8?q?=20requesting=20user=20over=20SES=20SMTP=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- infrastructure/email/__init__.py | 0 infrastructure/email/ses_smtp_email_sender.py | 60 +++++++++++++++ repositories/email/__init__.py | 0 repositories/email/email_sender.py | 15 ++++ tests/infrastructure/email/__init__.py | 0 .../email/test_ses_smtp_email_sender.py | 77 +++++++++++++++++++ 6 files changed, 152 insertions(+) create mode 100644 infrastructure/email/__init__.py create mode 100644 infrastructure/email/ses_smtp_email_sender.py create mode 100644 repositories/email/__init__.py create mode 100644 repositories/email/email_sender.py create mode 100644 tests/infrastructure/email/__init__.py create mode 100644 tests/infrastructure/email/test_ses_smtp_email_sender.py diff --git a/infrastructure/email/__init__.py b/infrastructure/email/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/infrastructure/email/ses_smtp_email_sender.py b/infrastructure/email/ses_smtp_email_sender.py new file mode 100644 index 000000000..9318f100d --- /dev/null +++ b/infrastructure/email/ses_smtp_email_sender.py @@ -0,0 +1,60 @@ +"""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 + + +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: ... + + +def _default_smtp(host: str, port: int) -> SmtpTransport: + return smtplib.SMTP(host, port) + + +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) -> None: + raise NotImplementedError diff --git a/repositories/email/__init__.py b/repositories/email/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/repositories/email/email_sender.py b/repositories/email/email_sender.py new file mode 100644 index 000000000..2d6ac8725 --- /dev/null +++ b/repositories/email/email_sender.py @@ -0,0 +1,15 @@ +"""The outbound-email port (ADR-0059). + +A narrow interface the orchestrator depends on to notify a user, so the +transport (SES over SMTP today) stays swappable and testable behind it. +""" + +from __future__ import annotations + +from typing import Protocol + + +class EmailSender(Protocol): + def send(self, *, to: str, subject: str, body: str) -> None: + """Send a plain-text email to ``to``.""" + ... diff --git a/tests/infrastructure/email/__init__.py b/tests/infrastructure/email/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/infrastructure/email/test_ses_smtp_email_sender.py b/tests/infrastructure/email/test_ses_smtp_email_sender.py new file mode 100644 index 000000000..c5e5ca2f3 --- /dev/null +++ b/tests/infrastructure/email/test_ses_smtp_email_sender.py @@ -0,0 +1,77 @@ +"""The SES-over-SMTP email sender (ADR-0059) — delivers a plain-text message to +the requesting user via an injected SMTP transport.""" + +from __future__ import annotations + +from email.message import EmailMessage +from types import TracebackType +from typing import Any, Optional, Type + +from infrastructure.email.ses_smtp_email_sender import SesSmtpEmailSender + + +class _FakeSmtp: + """Records what the sender hands the transport, standing in for a live SES + SMTP connection.""" + + def __init__(self, host: str, port: int) -> None: + self.host = host + self.port = port + self.messages: list[EmailMessage] = [] + self.started_tls = False + self.login_args: Optional[tuple[str, str]] = None + + def __enter__(self) -> "_FakeSmtp": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + tb: Optional[TracebackType], + ) -> Optional[bool]: + return None + + def starttls(self) -> Any: + self.started_tls = True + + def login(self, user: str, password: str) -> Any: + self.login_args = (user, password) + + def send_message(self, message: EmailMessage) -> Any: + self.messages.append(message) + + +def test_send_delivers_a_plaintext_message_with_the_right_envelope() -> None: + # Arrange + created: list[_FakeSmtp] = [] + + def factory(host: str, port: int) -> _FakeSmtp: + smtp = _FakeSmtp(host, port) + created.append(smtp) + return smtp + + sender = SesSmtpEmailSender( + host="email-smtp.eu-west-2.amazonaws.com", + port=587, + username="AKIA_SMTP_USER", + password="smtp-password", + from_address="noreply@domna.homes", + smtp_factory=factory, + ) + + # Act + sender.send( + to="user@example.com", + subject="Your document download is ready", + body="Download it here: https://signed-url", + ) + + # Assert — one message, correctly addressed, body carried through. + assert len(created) == 1 + assert len(created[0].messages) == 1 + message = created[0].messages[0] + assert message["To"] == "user@example.com" + assert message["From"] == "noreply@domna.homes" + assert message["Subject"] == "Your document download is ready" + assert "https://signed-url" in message.get_content()