email: one-click unsubscribe endpoint w/ signed token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a4e585fbfb
commit
a292289dc6
3 changed files with 186 additions and 0 deletions
|
|
@ -19,6 +19,7 @@ from app.db import get_session_factory
|
||||||
from app.logging import configure_logging, get_logger
|
from app.logging import configure_logging, get_logger
|
||||||
from app.routers import api as api_router
|
from app.routers import api as api_router
|
||||||
from app.routers import auth as auth_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 pages as pages_router
|
||||||
from app.routers import public as public_router
|
from app.routers import public as public_router
|
||||||
from app.routers import sync as sync_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(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(api_router.router, prefix="/api", tags=["api"])
|
||||||
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
|
app.include_router(universe_router.router, prefix="/api", tags=["universe"])
|
||||||
app.include_router(sync_router.router, tags=["portfolio-sync"])
|
app.include_router(sync_router.router, tags=["portfolio-sync"])
|
||||||
|
|
|
||||||
101
app/routers/email.py
Normal file
101
app/routers/email.py
Normal 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))
|
||||||
83
tests/test_email_unsubscribe.py
Normal file
83
tests/test_email_unsubscribe.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue