mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Send a document-download email to the requesting user over SES SMTP 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
135dd9abda
commit
7466b78e54
6 changed files with 152 additions and 0 deletions
0
infrastructure/email/__init__.py
Normal file
0
infrastructure/email/__init__.py
Normal file
60
infrastructure/email/ses_smtp_email_sender.py
Normal file
60
infrastructure/email/ses_smtp_email_sender.py
Normal file
|
|
@ -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
|
||||
0
repositories/email/__init__.py
Normal file
0
repositories/email/__init__.py
Normal file
15
repositories/email/email_sender.py
Normal file
15
repositories/email/email_sender.py
Normal file
|
|
@ -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``."""
|
||||
...
|
||||
0
tests/infrastructure/email/__init__.py
Normal file
0
tests/infrastructure/email/__init__.py
Normal file
77
tests/infrastructure/email/test_ses_smtp_email_sender.py
Normal file
77
tests/infrastructure/email/test_ses_smtp_email_sender.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue