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:
@@ -128,6 +128,7 @@ def _user_out(user: sqlite3.Row) -> dict:
|
||||
"display_name": user["display_name"],
|
||||
"avatar_url": user["avatar_url"],
|
||||
"is_admin": _is_admin(user),
|
||||
"digest_enabled": bool(user["digest_enabled"]),
|
||||
}
|
||||
|
||||
|
||||
@@ -346,6 +347,7 @@ class UserOut(BaseModel):
|
||||
display_name: str | None = None
|
||||
avatar_url: str | None = None
|
||||
is_admin: bool = False
|
||||
digest_enabled: bool = False
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
@@ -409,6 +411,10 @@ class CandidatePromoteBody(BaseModel):
|
||||
poll_interval_minutes: int = 180
|
||||
|
||||
|
||||
class DigestBody(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class SourceReviewBody(BaseModel):
|
||||
flag: bool = False
|
||||
reason: str | None = None
|
||||
@@ -506,6 +512,49 @@ def create_app() -> FastAPI:
|
||||
user = _current_user(conn, request)
|
||||
return UserOut(**_user_out(user)) if user else None
|
||||
|
||||
@app.post("/api/account/digest")
|
||||
def account_digest(body: DigestBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
user = _current_user(conn, request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="sign in required")
|
||||
token = user["digest_unsub_token"]
|
||||
if body.enabled and not token:
|
||||
token = secrets.token_urlsafe(18)
|
||||
conn.execute(
|
||||
"UPDATE users SET digest_enabled = ?, digest_unsub_token = ? WHERE id = ?",
|
||||
(1 if body.enabled else 0, token, user["id"]),
|
||||
)
|
||||
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
|
||||
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
|
||||
msg = (
|
||||
"You’re unsubscribed from the daily digest. No hard feelings — "
|
||||
"Upbeat Bytes is always here when you want it."
|
||||
if ok else
|
||||
"That unsubscribe link looks invalid or expired. You can manage the "
|
||||
"digest from your account settings."
|
||||
)
|
||||
html = (
|
||||
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
|
||||
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
||||
'<h1 style="font-size:22px">Upbeat Bytes</h1>'
|
||||
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
|
||||
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to Upbeat Bytes</a></p></div>'
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def auth_logout(request: Request, response: Response) -> dict:
|
||||
with get_conn() as conn:
|
||||
|
||||
Reference in New Issue
Block a user