From e338650dfa118cf9ff75fe4eb120b6380009e40e Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Mon, 25 May 2026 23:33:53 +0200 Subject: [PATCH] beta-launch: respect returning-user opt-out + show digest job in ops LEDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify_submit now applies the subscribe checkbox only at first sign-up. Returning users keep whatever they set via Settings or the one-click unsubscribe link — previously, every login silently re-enrolled them. - JOB_NAMES gains email_digest_job so the ops footer reflects its health. Adds tests/test_verify_subscribe.py::test_returning_user_login_preserves_unsubscribe. --- app/routers/api.py | 3 ++- app/routers/auth.py | 9 +++++-- tests/test_verify_subscribe.py | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app/routers/api.py b/app/routers/api.py index e76a625..df8ce4b 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -50,7 +50,8 @@ from app.schemas import ( router = APIRouter(dependencies=[Depends(require_token)]) JOB_NAMES = ("market_job", "news_job", "ai_log_job", "rollup_job", - "indicator_summary_job", "universe_flush_job") + "indicator_summary_job", "universe_flush_job", + "email_digest_job") JOB_STALE_HOURS = 2.0 # job is "warn" if its last success was >2h ago # Per-group expected freshness — bonds and intraday tape want daily data, diff --git a/app/routers/auth.py b/app/routers/auth.py index 6d3bfbc..05e3de1 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -240,9 +240,14 @@ async def verify_submit( if user is None: # User row vanished between cookie issue and verify. Restart flow. return RedirectResponse(url="/login", status_code=303) + is_first_login = user.last_login_at is None 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 + # Apply the verify-page subscribe checkbox ONLY at first sign-up. After + # that, Settings (and the one-click unsubscribe link) own the preference + # — re-applying on every login would silently re-subscribe users who + # explicitly opted out. + if is_first_login: + 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/tests/test_verify_subscribe.py b/tests/test_verify_subscribe.py index 51477c0..1ce1b76 100644 --- a/tests/test_verify_subscribe.py +++ b/tests/test_verify_subscribe.py @@ -83,3 +83,48 @@ def test_verify_with_checked_subscribe_keeps_opt_in(tmp_path, monkeypatch): u = await s.get(User, 10) assert u.email_digest_opt_in is True asyncio.run(_check()) + + +def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch): + """A user who unsubscribed (via Settings or the one-click link) must + not be silently re-enrolled when they log in again, even if the verify + page's default-checked checkbox gets submitted.""" + from datetime import datetime, timezone + + from app.services import otp_service + + async def _ok(*args, **kwargs): + return None + monkeypatch.setattr(otp_service, "verify", _ok) + + client, pending = _build(tmp_path) + + # Simulate a returning user: previously logged in, then opted out. + async def _make_returning(): + 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) + u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc) + u.email_digest_opt_in = False + await s.commit() + asyncio.run(_make_returning()) + + # They log in again. The verify checkbox is default-checked in the + # template, so the form will submit "on" — but the handler must NOT + # apply that to a returning user. + 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 False, "returning user re-enrolled" + asyncio.run(_check())