118 lines
4.5 KiB
Python
118 lines
4.5 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
|
||
|
|
|
||
|
|
import mailer
|
||
|
|
import metrics
|
||
|
|
from db import async_session_maker
|
||
|
|
from 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 — 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,
|
||
|
|
)
|