email: one-click unsubscribe endpoint w/ signed token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-25 23:07:38 +02:00
parent a4e585fbfb
commit a292289dc6
3 changed files with 186 additions and 0 deletions

View file

@ -19,6 +19,7 @@ from app.db import get_session_factory
from app.logging import configure_logging, get_logger
from app.routers import api as api_router
from app.routers import auth as auth_router
from app.routers import email as email_router
from app.routers import pages as pages_router
from app.routers import public as public_router
from app.routers import sync as sync_router
@ -83,6 +84,7 @@ app.mount(
)
app.include_router(auth_router.router, tags=["auth"])
app.include_router(email_router.router, tags=["email"])
app.include_router(api_router.router, prefix="/api", tags=["api"])
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
app.include_router(sync_router.router, tags=["portfolio-sync"])

101
app/routers/email.py Normal file
View file

@ -0,0 +1,101 @@
"""Email-related public routes.
Currently:
- GET /email/unsubscribe?token=...
The token is `itsdangerous.URLSafeSerializer` over a small payload,
signed with CASSANDRA_SESSION_SECRET. No auth dependency: the whole
point of one-click unsubscribe is that the user does not have to
sign in.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse
from itsdangerous import BadSignature, URLSafeSerializer
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import get_session
from app.logging import get_logger
from app.models import User
router = APIRouter()
log = get_logger("email_router")
_SALT = "digest-unsubscribe-v1"
def _serializer() -> URLSafeSerializer:
s = get_settings()
if not s.CASSANDRA_SESSION_SECRET:
# In tests with no secret configured, fall back to a constant —
# NEVER reach production; settings validation should catch this.
return URLSafeSerializer("dev-only-empty-secret", salt=_SALT)
return URLSafeSerializer(s.CASSANDRA_SESSION_SECRET, salt=_SALT)
def sign_unsubscribe_token(user_id: int) -> str:
return _serializer().dumps({"uid": int(user_id), "purpose": "digest_optout"})
def verify_unsubscribe_token(token: str) -> int | None:
try:
data = _serializer().loads(token)
except BadSignature:
return None
if not isinstance(data, dict):
return None
if data.get("purpose") != "digest_optout":
return None
try:
return int(data["uid"])
except (KeyError, TypeError, ValueError):
return None
_CONFIRM_PAGE = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Unsubscribed {brand}</title>
<link rel="stylesheet" href="/static/css/cassandra.css">
</head>
<body class="auth-shell">
<div class="auth-card" style="max-width:480px;">
<div class="auth-card__brand">{brand}</div>
<div class="auth-card__hint">email preferences</div>
<p class="auth-card__lede">You're unsubscribed from email digests.</p>
<p style="font-size:13px; color:var(--muted); line-height:1.6;">
You can re-enable digests any time from
<a href="/settings" style="color:var(--accent);">Settings</a>.
</p>
</div>
</body>
</html>
"""
@router.get("/email/unsubscribe", response_class=HTMLResponse)
async def unsubscribe(
request: Request,
token: str = Query(...),
session: AsyncSession = Depends(get_session),
):
from app import branding
uid = verify_unsubscribe_token(token)
if uid is not None:
user = await session.get(User, uid)
if user is not None and user.email_digest_opt_in:
user.email_digest_opt_in = False
await session.commit()
log.info("email.unsubscribe.ok", user_id=uid)
else:
log.info("email.unsubscribe.noop_or_unknown", user_id=uid)
else:
log.info("email.unsubscribe.bad_token")
# Same confirmation page regardless — don't leak token validity.
return HTMLResponse(_CONFIRM_PAGE.format(brand=branding.BRAND_NAME))

View file

@ -0,0 +1,83 @@
"""Unsubscribe token roundtrip + endpoint."""
from __future__ import annotations
import asyncio
def _build_app(tmp_path, secret="rt-secret-32-bytes-or-so-padding-here"):
import os
os.environ["CASSANDRA_SESSION_SECRET"] = secret
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
from app.config import get_settings
from app.models import User
from app.routers import email as email_router
get_settings.cache_clear() # pick up the new env var
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/u.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=42, email="u@x", tier="paid", email_digest_opt_in=True))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(email_router.router)
return TestClient(app)
def test_sign_and_verify_token_roundtrip(monkeypatch):
monkeypatch.setenv("CASSANDRA_SESSION_SECRET", "rt-secret-32-bytes-or-so-padding-here")
from app.config import get_settings
get_settings.cache_clear()
from app.routers.email import sign_unsubscribe_token, verify_unsubscribe_token
tok = sign_unsubscribe_token(42)
assert verify_unsubscribe_token(tok) == 42
assert verify_unsubscribe_token("garbage") is None
def test_get_unsubscribe_flips_flag(tmp_path):
client = _build_app(tmp_path)
from app.routers.email import sign_unsubscribe_token
tok = sign_unsubscribe_token(42)
r = client.get(f"/email/unsubscribe?token={tok}")
assert r.status_code == 200
assert "unsubscribed" in r.text.lower()
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 42)
assert u.email_digest_opt_in is False
asyncio.run(_check())
def test_get_unsubscribe_invalid_token_returns_generic_page(tmp_path):
client = _build_app(tmp_path)
r = client.get("/email/unsubscribe?token=garbage")
# We don't 4xx — that would leak token validity. Show the generic page.
assert r.status_code == 200
assert "unsubscribed" in r.text.lower() or "preferences" in r.text.lower()
def test_replay_is_idempotent(tmp_path):
client = _build_app(tmp_path)
from app.routers.email import sign_unsubscribe_token
tok = sign_unsubscribe_token(42)
r1 = client.get(f"/email/unsubscribe?token={tok}")
r2 = client.get(f"/email/unsubscribe?token={tok}")
assert r1.status_code == 200
assert r2.status_code == 200