* Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
100 lines
4 KiB
Python
100 lines
4 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, parseaddr
|
|
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
return False
|