thermograph/backend/notifications/mailer.py
Emi Griffith a4be7066e5 Subtree-merge thermograph-backend (origin/main) into backend/
git-subtree-dir: backend
git-subtree-mainline: 6723fc0326
git-subtree-split: 83c2e05b96
2026-07-22 22:01:11 -07:00

111 lines
4.7 KiB
Python

"""Outbound email, over stdlib smtplib only (no new dependency).
**The seam, not the policy.** The app always talks plain SMTP to whatever
``THERMOGRAPH_SMTP_HOST`` points at — in production, a Postfix null client
listening on 127.0.0.1:25 (see deploy/provision-mail.sh). Whether Postfix then
delivers straight to the recipient's MX or relays through a transactional
provider is a Postfix config decision that needs no change here. That is the
point of routing through a local MTA rather than talking to a provider's API:
delivery policy stays swappable, and a slow or failing upstream can't block a
request, because Postfix queues and retries on our behalf.
**Default-safe.** The backend defaults to ``console`` — it logs the message and
sends nothing — so LAN dev and the test suite can exercise the full signup path
without a mail server and without any risk of mailing a real person. Production
opts in with ``THERMOGRAPH_MAIL_BACKEND=smtp``, the same shape as
``THERMOGRAPH_COOKIE_SECURE``.
``send()`` never raises. Callers treat email like push notifications: a channel
that may fail without taking the request down with it.
"""
from __future__ import annotations
import logging
import os
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid, parseaddr
from core import audit
log = logging.getLogger("thermograph.mail")
# console -> log the message, send nothing (default; dev + tests)
# smtp -> hand it to the configured SMTP host
# disabled -> drop silently (a kill switch that needs no config removal)
BACKEND = os.environ.get("THERMOGRAPH_MAIL_BACKEND", "console").strip().lower()
SMTP_HOST = os.environ.get("THERMOGRAPH_SMTP_HOST", "127.0.0.1").strip()
SMTP_PORT = int(os.environ.get("THERMOGRAPH_SMTP_PORT", "25") or 25)
SMTP_USER = os.environ.get("THERMOGRAPH_SMTP_USER", "").strip()
SMTP_PASSWORD = os.environ.get("THERMOGRAPH_SMTP_PASSWORD", "")
SMTP_STARTTLS = os.environ.get("THERMOGRAPH_SMTP_STARTTLS", "").strip() in ("1", "true", "yes")
SMTP_TIMEOUT = float(os.environ.get("THERMOGRAPH_SMTP_TIMEOUT", "10") or 10)
MAIL_FROM = os.environ.get("THERMOGRAPH_MAIL_FROM", "Thermograph <no-reply@thermograph.org>").strip()
MAIL_REPLY_TO = os.environ.get("THERMOGRAPH_MAIL_REPLY_TO", "").strip()
def enabled() -> bool:
"""Whether a send would actually leave the process."""
return BACKEND == "smtp"
def _build(to: str, subject: str, text: str, html: "str | None") -> EmailMessage:
msg = EmailMessage()
name, addr = parseaddr(MAIL_FROM)
msg["From"] = formataddr((name, addr)) if addr else MAIL_FROM
msg["To"] = to
msg["Subject"] = subject
# RFC 5322 requires both; without them Gmail rejects the message outright
# ("Messages missing a valid Message-ID header are not accepted") rather than
# just scoring it as spam. domain= keeps the Message-ID on our own sending
# domain rather than leaking the sending host's local/internal hostname.
msg["Message-ID"] = make_msgid(domain=addr.split("@", 1)[-1] if addr else None)
msg["Date"] = formatdate(localtime=True)
if MAIL_REPLY_TO:
msg["Reply-To"] = MAIL_REPLY_TO
# Plain text is the body; HTML is the alternative, so text-only clients and
# spam filters both see real content.
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
return msg
def send(to: str, subject: str, text: str, html: "str | None" = None) -> bool:
"""Send one message. Returns whether it was handed off successfully.
Never raises: a mail failure must not break the request that triggered it.
"""
if not to or "@" not in to:
return False
if BACKEND == "disabled":
return False
try:
msg = _build(to, subject, text, html)
except Exception: # noqa: BLE001
log.exception("mail: could not build message")
return False
if BACKEND != "smtp":
# console (the default): prove the flow end-to-end without sending.
log.info("mail[console] to=%s subject=%s\n%s", to, subject, text)
audit.log_activity("mail.send", {"ok": True, "backend": "console"})
return True
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=SMTP_TIMEOUT) as smtp:
if SMTP_STARTTLS:
smtp.starttls(context=ssl.create_default_context())
if SMTP_USER:
smtp.login(SMTP_USER, SMTP_PASSWORD)
smtp.send_message(msg)
audit.log_activity("mail.send", {"ok": True, "backend": "smtp"})
return True
except Exception: # noqa: BLE001 - deliverability is never worth a 500
log.exception("mail: send failed to=%s host=%s:%s", to, SMTP_HOST, SMTP_PORT)
audit.log_activity("mail.send", {"ok": False, "backend": "smtp"})
return False