From 1956d7fd238bb89b00292aa3e062926204298e1a Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 9 Jun 2026 16:35:05 -0400 Subject: [PATCH] Digest polish: honest on-site wording, one-tap opt-in after sign-in, List-Unsubscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * On-site end-cap now says "You're caught up for now." — honest, since Highlights refreshes through the day (the email keeps the daily "see you tomorrow"). * Anonymous "Get tomorrow's brief by email" now honors the one-tap promise: sets a pending flag, opens sign-in, and auto-enables once auth resolves. * Email compliance (RFC 2369/8058): send_email takes optional headers; the digest sets List-Unsubscribe + List-Unsubscribe-Post=One-Click, and a POST /api/digest/unsubscribe handles native one-click (GET still serves the page). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/routes/+page.svelte | 12 ++++++++++-- goodnews/api.py | 19 ++++++++++++++----- goodnews/digest.py | 7 +++++-- goodnews/email_send.py | 5 ++++- tests/test_admin.py | 4 ++++ tests/test_digest.py | 7 +++++-- 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index e015b7a..4aff689 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -305,14 +305,22 @@ // The finite-ending's gentle nudge: one tap to get tomorrow's brief by email. let digestBusy = $state(false); + let pendingDigestOptIn = $state(false); async function subscribeDigest() { - if (!auth.user) { showSignIn = true; return; } // sign in, then enable below + if (!auth.user) { pendingDigestOptIn = true; showSignIn = true; return; } // sign in → auto-enable if (auth.user.digest_enabled || digestBusy) return; digestBusy = true; try { await postJSON('/api/account/digest', { enabled: true }); await refreshAuth(); } catch { /* leave as-is */ } finally { digestBusy = false; } } + // Keep the one-tap promise: if they tapped while signed out, enable right after sign-in. + $effect(() => { + if (pendingDigestOptIn && auth.user) { + pendingDigestOptIn = false; + if (!auth.user.digest_enabled) subscribeDigest(); + } + }); const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' }; function applyAction(kind, value) { @@ -443,7 +451,7 @@

✦ that's the good news for today ✦

-

You're caught up — see you tomorrow.

+

You're caught up for now.

{#if auth.user?.digest_enabled}

Tomorrow's brief is headed to your inbox ☕

{:else} diff --git a/goodnews/api.py b/goodnews/api.py index 7f5fe7e..63b7bb3 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -528,16 +528,25 @@ def create_app() -> FastAPI: conn.commit() return {"ok": True, "digest_enabled": body.enabled} - @app.get("/api/digest/unsubscribe", response_class=HTMLResponse) - def digest_unsubscribe(u: int = Query(...), t: str = Query(...)) -> HTMLResponse: - # One-click, no login: match the per-user token, then turn the digest off. - ok = False + def _do_unsubscribe(u: int, t: str) -> bool: with get_conn() as conn: row = conn.execute("SELECT digest_unsub_token FROM users WHERE id = ?", (u,)).fetchone() if row and row["digest_unsub_token"] and hmac.compare_digest(row["digest_unsub_token"], t): conn.execute("UPDATE users SET digest_enabled = 0 WHERE id = ?", (u,)) conn.commit() - ok = True + return True + return False + + @app.post("/api/digest/unsubscribe") + def digest_unsubscribe_oneclick(u: int = Query(...), t: str = Query(...)) -> dict: + # RFC 8058 one-click: the mailbox provider POSTs here; just do it + 200. + _do_unsubscribe(u, t) + return {"ok": True} + + @app.get("/api/digest/unsubscribe", response_class=HTMLResponse) + def digest_unsubscribe(u: int = Query(...), t: str = Query(...)) -> HTMLResponse: + # One-click, no login: match the per-user token, then turn the digest off. + ok = _do_unsubscribe(u, t) msg = ( "You’re unsubscribed from the daily digest. No hard feelings — " "Upbeat Bytes is always here when you want it." diff --git a/goodnews/digest.py b/goodnews/digest.py index 1bc681c..b2a01bd 100644 --- a/goodnews/digest.py +++ b/goodnews/digest.py @@ -148,9 +148,12 @@ def send_due_digests(conn: sqlite3.Connection, force: bool = False, base: str | conn.execute("UPDATE users SET digest_unsub_token = ? WHERE id = ?", (token, user["id"])) conn.commit() user["digest_unsub_token"] = token - subject, text, html = build_digest(items, brief_date, unsub_url(user, base), base) + link = unsub_url(user, base) + subject, text, html = build_digest(items, brief_date, link, base) + # RFC 2369 / 8058: let inboxes offer native one-click unsubscribe. + headers = {"List-Unsubscribe": f"<{link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"} try: - email_send.send_email(user["email"], subject, text, html=html) + email_send.send_email(user["email"], subject, text, html=html, headers=headers) except Exception: # noqa: BLE001 — one bad send shouldn't stop the rest; retry next window continue conn.execute( diff --git a/goodnews/email_send.py b/goodnews/email_send.py index 5e6a68a..6ceb536 100644 --- a/goodnews/email_send.py +++ b/goodnews/email_send.py @@ -30,7 +30,8 @@ def _cfg() -> dict: } -def send_email(to: str, subject: str, text: str, html: str | None = None, reply_to: str | None = None) -> None: +def send_email(to: str, subject: str, text: str, html: str | None = None, reply_to: str | None = None, + headers: dict | None = None) -> None: """Send one message. Raises on failure (caller decides how loud to be).""" cfg = _cfg() if not cfg["host"]: @@ -40,6 +41,8 @@ def send_email(to: str, subject: str, text: str, html: str | None = None, reply_ msg["To"] = to if reply_to: msg["Reply-To"] = reply_to + for key, value in (headers or {}).items(): + msg[key] = value msg["Subject"] = subject msg.set_content(text) if html: diff --git a/tests/test_admin.py b/tests/test_admin.py index b1a8636..9d76162 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -230,4 +230,8 @@ def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch): assert "invalid" in TestClient(app).get(f"/api/digest/unsubscribe?u={uid}&t=nope").text.lower() assert "unsubscribed" in TestClient(app).get(f"/api/digest/unsubscribe?u={uid}&t={tok}").text.lower() assert tc.get("/api/auth/me").json()["digest_enabled"] is False + # RFC 8058 one-click POST also disables + tc.post("/api/account/digest", json={"enabled": True}) + assert TestClient(app).post(f"/api/digest/unsubscribe?u={uid}&t={tok}").json() == {"ok": True} + assert tc.get("/api/auth/me").json()["digest_enabled"] is False assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated diff --git a/tests/test_digest.py b/tests/test_digest.py index dc560b1..b2a7dcb 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -28,13 +28,16 @@ def test_build_digest_is_calm_and_dated(): def test_send_due_digests_sends_dedupes_and_skips(monkeypatch, tmp_path): sent = [] - monkeypatch.setattr(email_send, "send_email", lambda to, subj, text, html=None: sent.append(to)) + monkeypatch.setattr(email_send, "send_email", + lambda to, subj, text, html=None, headers=None, **k: sent.append((to, headers))) c = connect(str(tmp_path / "d.db")); init_db(c) date = _seed(c, n=5) monkeypatch.setattr(digest, "local_today", lambda: date) # force=True bypasses the morning window assert digest.send_due_digests(c, force=True) == 1 - assert sent == ["reader@x.com"] + assert sent[0][0] == "reader@x.com" + hdrs = sent[0][1] # RFC 8058 one-click unsubscribe headers present + assert "List-Unsubscribe" in hdrs and hdrs["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click" assert c.execute("SELECT item_count FROM digest_sends WHERE user_id=1").fetchone()[0] == 5 # dedupe: a second run sends nothing assert digest.send_due_digests(c, force=True) == 0