Daily digest (opt-in) + finite "you're caught up" ending
Reader-retention as ritual, not capture (Codex's framing). Opt-in calm morning email of today's brief; the on-site twin is the finite end-of-feed nudge. * Schema: users.digest_enabled + digest_unsub_token; digest_sends (dedupe + visibility). auth.get_user now returns the digest fields. * goodnews/digest.py: build (dated calm subject, items w/ summary + "why it's here" + UB/source links + one-click unsubscribe, "you're caught up" sign-off) and send_due_digests (morning-window gated, >=4-item floor or skip quietly, deduped, reuses SMTP). No streaks/urgency/"you missed". * API: /auth/me exposes digest_enabled; POST /api/account/digest toggle; GET /api/digest/unsubscribe (token, no login, calm confirmation page). * CLI: cycle gains a morning-gated digest step (--no-digest) + a send-digests command (--force). * Frontend: digest toggle on the Account profile; the Highlights end-cap now says "you're caught up — see you tomorrow" with a one-tap "Get tomorrow's brief by email" (signed-in → enable; anon → sign in). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -213,3 +213,21 @@ def test_source_check_preview_is_readonly(tmp_path, monkeypatch):
|
||||
assert after["last_success_at"] == before["last_success_at"] and after["next_due_at"] == before["next_due_at"]
|
||||
assert TestClient(app).post("/api/admin/sources/1/preview").status_code == 401 # gated
|
||||
assert tc.post("/api/admin/sources/999/preview").status_code == 404
|
||||
|
||||
|
||||
def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch):
|
||||
import os, sqlite3
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
tc = _signin(app, api, "reader@x.com")
|
||||
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
||||
assert tc.post("/api/account/digest", json={"enabled": True}).json()["digest_enabled"] is True
|
||||
assert tc.get("/api/auth/me").json()["digest_enabled"] is True
|
||||
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||
uid, tok = c.execute("SELECT id, digest_unsub_token FROM users WHERE email='reader@x.com'").fetchone()
|
||||
c.close()
|
||||
assert tok # token generated on opt-in
|
||||
# one-click unsubscribe: wrong token is rejected, right token disables
|
||||
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
|
||||
assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated
|
||||
|
||||
Reference in New Issue
Block a user