"""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 ").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