121 lines
4.8 KiB
Python
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,
|
|
)
|