thermograph/notifications/digest.py
Emi Griffith d17ac794fd Split the backend into domain packages (#217)
* 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
2026-07-20 05:31:03 +00:00

117 lines
4.6 KiB
Python

"""Monthly-digest signup.
The form ships ahead of email delivery on purpose (see models.PendingDigest):
collecting the list is the slow part, so signups are stored now and confirmed
once SMTP is live. Nothing is mailed while mailer.enabled() is False.
Privacy: the address is used for the digest and nothing else, and the only other
thing recorded is the bare referrer domain for attribution.
"""
from __future__ import annotations
import re
import time
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy import select
from notifications import mailer
from core import metrics
from accounts.db import async_session_maker
from accounts.models import PendingDigest
# Deliberately permissive: real-world addresses defeat clever patterns, and the
# confirmation email is the actual validator. This only rejects obvious junk.
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$")
# The same answer for every outcome — new row, duplicate, or throttled. Anything
# else would turn the endpoint into an oracle for "is this address subscribed?".
GENERIC_OK = "Check your inbox to confirm your address."
# Don't re-send a confirmation to an unconfirmed address more than this often.
RESEND_INTERVAL_S = 600
def _valid(email: str) -> bool:
return bool(email) and len(email) <= 320 and bool(_EMAIL_RE.match(email))
async def _store(email: str, place: str | None, source: str | None) -> None:
"""Upsert the signup. A repeat submit updates the existing row rather than
creating a second one — the unique constraint on email is the idempotency."""
async with async_session_maker() as session:
existing = (
await session.execute(
select(PendingDigest).where(PendingDigest.email == email)
)
).scalar_one_or_none()
now = time.time()
if existing is None:
session.add(PendingDigest(
email=email, place_label=place, source=source, created_at=now,
))
else:
if place and not existing.place_label:
existing.place_label = place
# Re-subscribing after an unsubscribe re-opens the row.
existing.unsubscribed_at = None
if existing.confirmed_at is None:
if existing.last_sent_at and now - existing.last_sent_at < RESEND_INTERVAL_S:
await session.commit()
return
existing.last_sent_at = now
await session.commit()
# Nothing is sent until a real mail backend is configured; the console
# backend logs it so the flow can be verified end-to-end without a server.
if mailer.enabled():
mailer.send(
email,
"Confirm your Thermograph digest",
"Thanks for signing up for the Thermograph monthly digest.\n\n"
"Confirmation links are not active yet, but you're on the list, and "
"you'll get a confirmation as soon as they are.\n",
)
async def signup(request: Request) -> Response:
"""POST {BASE}/digest — accepts a JSON body (the fetch path) or a plain form
post (the no-JS path). Always answers 200 with the same message."""
email = ""
place = None
try:
ctype = request.headers.get("content-type", "")
if ctype.startswith("application/json"):
body = await request.json()
if isinstance(body, dict):
email = str(body.get("email") or "").strip().lower()
place = (str(body.get("place")).strip() or None) if body.get("place") else None
else:
form = await request.form()
email = str(form.get("email") or "").strip().lower()
place = (str(form.get("place")).strip() or None) if form.get("place") else None
except Exception: # noqa: BLE001 - a malformed body is just an invalid signup
pass
if _valid(email):
source = metrics.normalize_referrer(
request.headers.get("referer"), request.headers.get("host")
)
try:
await _store(email, place, source)
except Exception: # noqa: BLE001 - never surface storage detail to the form
pass
metrics.record_event(
"home.digest_signup",
referer=request.headers.get("referer"),
host=request.headers.get("host"),
ip=request.client.host if request.client else None,
)
return JSONResponse({"ok": True, "message": GENERIC_OK})
return JSONResponse(
{"ok": False, "message": "That doesn't look like an email address."},
status_code=400,
)