diff --git a/app/routers/auth.py b/app/routers/auth.py
index 59733a9..6d3bfbc 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -216,6 +216,7 @@ async def verify_page(request: Request, error: str | None = None, sent: str | No
async def verify_submit(
request: Request,
code: str = Form(...),
+ subscribe_to_digests: str | None = Form(default=None),
session: AsyncSession = Depends(get_session),
):
cookie = request.cookies.get(PENDING_COOKIE_NAME)
@@ -240,6 +241,8 @@ async def verify_submit(
# User row vanished between cookie issue and verify. Restart flow.
return RedirectResponse(url="/login", status_code=303)
user.last_login_at = utcnow()
+ # An unchecked HTML checkbox sends NO field; that means "opt out".
+ user.email_digest_opt_in = subscribe_to_digests is not None
await session.commit()
log.info("user.login", user_id=user.id, email=email)
diff --git a/app/templates/verify.html b/app/templates/verify.html
index ae62056..8596939 100644
--- a/app/templates/verify.html
+++ b/app/templates/verify.html
@@ -32,6 +32,12 @@
minlength="6" maxlength="6" required autofocus
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
+
diff --git a/tests/test_verify_subscribe.py b/tests/test_verify_subscribe.py
new file mode 100644
index 0000000..51477c0
--- /dev/null
+++ b/tests/test_verify_subscribe.py
@@ -0,0 +1,85 @@
+"""Verify-POST persists the subscribe_to_digests form field."""
+from __future__ import annotations
+
+import asyncio
+
+
+def _build(tmp_path):
+ 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.models import User
+ from app.routers import auth as auth_router
+ from app.auth import _pending_serializer
+
+ engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/v.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=10, email="newbie@x", tier="free",
+ email_digest_opt_in=True))
+ await s.commit()
+
+ asyncio.run(_seed())
+ app = FastAPI()
+ app.include_router(auth_router.router)
+ pending = _pending_serializer().dumps({"email": "newbie@x", "uid": 10, "ref": None})
+ return TestClient(app), pending
+
+
+def test_verify_with_unchecked_subscribe_disables_opt_in(tmp_path, monkeypatch):
+ from app.services import otp_service
+
+ async def _ok(*args, **kwargs):
+ return None
+ monkeypatch.setattr(otp_service, "verify", _ok)
+
+ client, pending = _build(tmp_path)
+ r = client.post(
+ "/verify",
+ data={"code": "000000"}, # subscribe_to_digests omitted = unchecked
+ cookies={"cassandra_pending": pending},
+ follow_redirects=False,
+ )
+ assert r.status_code == 303, r.text
+
+ 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, 10)
+ assert u.email_digest_opt_in is False
+ asyncio.run(_check())
+
+
+def test_verify_with_checked_subscribe_keeps_opt_in(tmp_path, monkeypatch):
+ from app.services import otp_service
+
+ async def _ok(*args, **kwargs):
+ return None
+ monkeypatch.setattr(otp_service, "verify", _ok)
+
+ client, pending = _build(tmp_path)
+ r = client.post(
+ "/verify",
+ data={"code": "000000", "subscribe_to_digests": "on"},
+ cookies={"cassandra_pending": pending},
+ follow_redirects=False,
+ )
+ assert r.status_code == 303
+
+ 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, 10)
+ assert u.email_digest_opt_in is True
+ asyncio.run(_check())