feat(slice-4): SmtpMailer — real mail over a configured relay, log-hygienic (INV-8, §6.6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,20 +2,30 @@
|
||||
|
||||
A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests —
|
||||
the message lands in the app log and an in-memory outbox tests read back, §6.8) and the
|
||||
deployed `SmtpMailer` (SLICE-4). The adapter is chosen by configuration at startup
|
||||
(INV-8), so no deployment shape lives in the domain. LogMailer logs the full body on
|
||||
purpose: that is PUC-10's "local dev channel" — the one-time code reaches the developer in
|
||||
the terminal. The deployed mailer (SLICE-4) will NOT log the body (INV-3 / §6.6 log hygiene).
|
||||
deployed `SmtpMailer` (relay coordinates from configuration, INV-8). The adapter is chosen
|
||||
by configuration at startup, so no deployment shape lives in the domain. LogMailer logs the
|
||||
full body on purpose: that is PUC-10's "local dev channel" — the one-time code reaches the
|
||||
developer in the terminal. SmtpMailer never logs the body or the recipient (INV-3 / §6.6
|
||||
log hygiene) and raises MailerError on delivery failure (INV-9 → the §6.4 502).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from typing import Protocol
|
||||
|
||||
from app.platform import config
|
||||
|
||||
logger = logging.getLogger("ecomm.mailer")
|
||||
|
||||
|
||||
class MailerError(Exception):
|
||||
"""Delivery failed — the relay refused or was unreachable (INV-9)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SentMessage:
|
||||
to: str
|
||||
@@ -41,10 +51,48 @@ class LogMailer:
|
||||
logger.info("LogMailer -> %s | %s\n%s", to, subject, body)
|
||||
|
||||
|
||||
class SmtpMailer:
|
||||
"""Deployed adapter: real mail over an SMTP relay (STARTTLS + login by default).
|
||||
|
||||
Logs only a hashed recipient — never the address, subject, or body (§6.6).
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int, user: str, password: str, sender: str, starttls: bool) -> None:
|
||||
self._host, self._port = host, port
|
||||
self._user, self._password = user, password
|
||||
self._sender, self._starttls = sender, starttls
|
||||
|
||||
def send(self, to: str, subject: str, body: str) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["To"] = to
|
||||
msg["From"] = self._sender
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
to_hash = hashlib.sha256(to.encode("utf-8")).hexdigest()[:8]
|
||||
try:
|
||||
with smtplib.SMTP(self._host, self._port, timeout=10) as smtp:
|
||||
if self._starttls:
|
||||
smtp.starttls()
|
||||
if self._user:
|
||||
smtp.login(self._user, self._password)
|
||||
smtp.send_message(msg)
|
||||
except Exception as exc:
|
||||
logger.warning("smtp send FAILED to=%s: %s", to_hash, type(exc).__name__)
|
||||
raise MailerError(str(exc)) from exc
|
||||
logger.info("smtp sent to=%s", to_hash)
|
||||
|
||||
|
||||
def build_mailer(kind: str) -> Mailer:
|
||||
"""Select the mailer adapter by configured kind (config.mailer_kind(), INV-8)."""
|
||||
if kind == "log":
|
||||
return LogMailer()
|
||||
if kind == "smtp":
|
||||
raise NotImplementedError("SmtpMailer arrives in SLICE-4 (deployed environments)")
|
||||
return SmtpMailer(
|
||||
host=config.smtp_host(),
|
||||
port=config.smtp_port(),
|
||||
user=config.smtp_user(),
|
||||
password=config.smtp_password(),
|
||||
sender=config.smtp_from(),
|
||||
starttls=config.smtp_starttls(),
|
||||
)
|
||||
raise ValueError(f"unknown mailer kind: {kind!r}")
|
||||
|
||||
Reference in New Issue
Block a user