From a292289dc6f701e6f9f41ae56b86a235a147fd1a Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 23:07:38 +0200 Subject: [PATCH] email: one-click unsubscribe endpoint w/ signed token Co-Authored-By: Claude Sonnet 4.6 --- app/main.py | 2 + app/routers/email.py | 101 ++++++++++++++++++++++++++++++++ tests/test_email_unsubscribe.py | 83 ++++++++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 app/routers/email.py create mode 100644 tests/test_email_unsubscribe.py diff --git a/app/main.py b/app/main.py index a9dcb50..efca3ae 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/routers/email.py b/app/routers/email.py new file mode 100644 index 0000000..c6aa80f --- /dev/null +++ b/app/routers/email.py @@ -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 = """\ + + + + + Unsubscribed — {brand} + + + +
+
{brand}
+
email preferences
+

You're unsubscribed from email digests.

+

+ You can re-enable digests any time from + Settings. +

+
+ + +""" + + +@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)) diff --git a/tests/test_email_unsubscribe.py b/tests/test_email_unsubscribe.py new file mode 100644 index 0000000..22b5f90 --- /dev/null +++ b/tests/test_email_unsubscribe.py @@ -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