thermograph/backend/notifications/digest.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

121 lines
4.8 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.concurrency import run_in_threadpool
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():
# Blocking SMTP connect+send — keep it off the event loop.
await run_in_threadpool(
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
await run_in_threadpool(
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,
)