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