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:
jay
2026-06-09 16:17:46 -04:00
parent 5d36e6b639
commit cf5cbb33c0
9 changed files with 386 additions and 3 deletions
+49
View File
@@ -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 = (
"Youre 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:
+2 -1
View File
@@ -46,7 +46,8 @@ def normalize_email(email: str) -> str:
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
return conn.execute(
"SELECT id, email, display_name, avatar_url, is_admin, created_at FROM users WHERE id = ?",
"SELECT id, email, display_name, avatar_url, is_admin, digest_enabled, digest_unsub_token, created_at "
"FROM users WHERE id = ?",
(user_id,),
).fetchone()
+17
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from .briefs import build_daily_brief, show_brief
from .db import connect, init_db
from .digest import send_due_digests
from .localtime import local_today
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
@@ -123,10 +124,14 @@ def main() -> None:
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
cycle_parser.add_argument("--force", action="store_true", help="Poll all active sources, ignoring intervals")
cycle_parser.add_argument("--base-url", help="OpenAI-compatible base URL for classify")
cycle_parser.add_argument("--model", help="Local model name for classify")
digest_parser = subparsers.add_parser("send-digests", help="Send today's digest to opted-in users (morning-gated)")
digest_parser.add_argument("--force", action="store_true", help="Ignore the morning send window")
enrich_images_parser = subparsers.add_parser(
"enrich-images", help="Backfill og:images for already-summarized articles that lack one"
)
@@ -271,6 +276,10 @@ def main() -> None:
print("Dry run only; database was not updated.")
elif args.command == "cycle":
run_cycle(conn, args)
elif args.command == "send-digests":
init_db(conn)
sent = send_due_digests(conn, force=args.force)
print(f"send-digests: sent {sent}")
elif args.command == "enrich-images":
found = enrich_summarized_images(conn, limit=args.limit)
print(f"enrich-images: {found} new image(s) for summarized articles")
@@ -495,6 +504,14 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc:
print(f"review: skipped ({exc})")
if not args.no_digest:
try:
sent = send_due_digests(conn) # morning-gated + deduped internally
if sent:
print(f"digest: sent {sent}")
except Exception as exc:
print(f"digest: skipped ({exc})")
def serve(args: argparse.Namespace) -> None:
try:
+15
View File
@@ -141,6 +141,8 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT,
avatar_url TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
digest_enabled INTEGER NOT NULL DEFAULT 0,
digest_unsub_token TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -254,6 +256,15 @@ CREATE TABLE IF NOT EXISTS feedback_replies (
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
CREATE TABLE IF NOT EXISTS digest_sends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
brief_date TEXT NOT NULL,
item_count INTEGER NOT NULL DEFAULT 0,
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, brief_date)
);
"""
@@ -296,6 +307,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT")
if user_tbl and "is_admin" not in user_tbl:
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
if user_tbl and "digest_enabled" not in user_tbl:
conn.execute("ALTER TABLE users ADD COLUMN digest_enabled INTEGER NOT NULL DEFAULT 0")
if user_tbl and "digest_unsub_token" not in user_tbl:
conn.execute("ALTER TABLE users ADD COLUMN digest_unsub_token TEXT")
article_cols = {row["name"] for row in conn.execute("PRAGMA table_info(articles)")}
if "duplicate_of" not in article_cols:
+162
View File
@@ -0,0 +1,162 @@
"""Opt-in daily digest — a finite, calm morning email of today's brief.
Ritual, not capture: no streaks, no "you missed", no urgency, no unread counts.
One send per opted-in user per day, gated to a morning window in the site
timezone and deduped via digest_sends. On a thin day it skips quietly rather
than padding. Reuses the existing SMTP/email pipeline.
"""
from __future__ import annotations
import os
import secrets
import sqlite3
from datetime import datetime
from html import escape
from . import email_send
from .localtime import local_now, local_today
from .paywall import is_paywalled
DIGEST_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
MIN_ITEMS = 4 # below this, skip the day rather than pad
def _base_url() -> str:
return os.environ.get("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com").rstrip("/")
def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> list[dict]:
"""The brief's items with the bits a calm email needs (visible sources only)."""
rows = conn.execute(
"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, sc.reason_text,
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
FROM daily_briefs b
JOIN daily_brief_items bi ON bi.brief_id = b.id
JOIN articles a ON a.id = bi.article_id
JOIN sources s ON s.id = a.source_id
LEFT JOIN article_scores sc ON sc.article_id = a.id
WHERE b.brief_date = ? AND s.content_visible = 1
ORDER BY bi.rank
LIMIT ?
""",
(brief_date, limit),
).fetchall()
items = []
for r in rows:
d = dict(r)
d["paywalled"] = is_paywalled(d["canonical_url"])
items.append(d)
return items
def _weekday(brief_date: str) -> str:
try:
return datetime.strptime(brief_date, "%Y-%m-%d").strftime("%A")
except (ValueError, TypeError):
return "today"
def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | None = None) -> tuple[str, str, str]:
"""Return (subject, text, html) for the digest — calm and dated, no urgency."""
base = base or _base_url()
n = len(items)
weekday = _weekday(brief_date)
subject = f"{weekday}'s Upbeat Bytes · {n} calm read{'' if n == 1 else 's'}"
if weekday == "today":
subject = f"Today's Upbeat Bytes · {n} calm reads"
text_lines = [f"Today's good news — {n} calm reads.\n"]
for it in items:
text_lines.append(f"{it['title']} ({it['source']})")
if it.get("summary"):
text_lines.append(f" {it['summary']}")
text_lines.append(f" Read: {base}/a/{it['id']}")
text_lines.append(f" Source: {it['canonical_url']}\n")
text_lines.append("That's today's good news. You're caught up — see you tomorrow.")
text_lines.append(f"\nTo stop these emails: {unsub_url}")
text = "\n".join(text_lines)
blocks = []
for it in items:
summary = f'<div style="font-size:15px;line-height:1.5;color:#16263a">{escape(it["summary"])}</div>' if it.get("summary") else ""
why = f'<div style="font-size:13px;color:#5d6b78;margin-top:6px"><em>Why its here:</em> {escape(it["reason_text"])}</div>' if it.get("reason_text") else ""
lock = " \U0001f512" if it.get("paywalled") else ""
blocks.append(
'<div style="margin:0 0 22px;padding:0 0 18px;border-bottom:1px solid #e8e3d8">'
f'<a href="{base}/a/{it["id"]}" style="font-size:18px;font-weight:600;color:#16263a;text-decoration:none">{escape(it["title"])}</a>'
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
f'{summary}{why}'
'<div style="margin-top:10px;font-size:14px">'
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on Upbeat Bytes</a>'
f' &nbsp;·&nbsp; <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
'</div></div>'
)
html = (
'<div style="max-width:600px;margin:0 auto;padding:8px 4px;'
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
'<h1 style="font-size:22px;margin:0 0 4px">Todays good news</h1>'
f'<p style="color:#5d6b78;font-size:14px;margin:0 0 22px">{n} calm reads for {escape(weekday)}</p>'
+ "".join(blocks)
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">Thats todays good news. '
'Youre caught up — see you tomorrow.</p>'
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">Youre getting this because you turned on '
f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>'
'</div>'
)
return subject, text, html
def unsub_url(user: dict, base: str | None = None) -> str:
base = base or _base_url()
return f"{base}/api/digest/unsubscribe?u={user['id']}&t={user['digest_unsub_token']}"
def send_due_digests(conn: sqlite3.Connection, force: bool = False, base: str | None = None) -> int:
"""Send today's digest to opted-in users who haven't received it yet.
Gated to the morning window unless force=True (manual CLI). Skips quietly on
a thin day. Deduped via digest_sends. Returns the number sent.
"""
if not force:
hour = local_now().hour
if hour < DIGEST_HOUR or hour >= DIGEST_HOUR + DIGEST_WINDOW_HOURS:
return 0
brief_date = local_today()
items = digest_items(conn, brief_date)
if len(items) < MIN_ITEMS:
return 0
base = base or _base_url()
users = conn.execute(
"""
SELECT u.id, u.email, u.digest_unsub_token
FROM users u
WHERE u.digest_enabled = 1
AND NOT EXISTS (SELECT 1 FROM digest_sends d WHERE d.user_id = u.id AND d.brief_date = ?)
""",
(brief_date,),
).fetchall()
sent = 0
for row in users:
user = dict(row)
if not user.get("digest_unsub_token"):
token = secrets.token_urlsafe(18)
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)
try:
email_send.send_email(user["email"], subject, text, html=html)
except Exception: # noqa: BLE001 — one bad send shouldn't stop the rest; retry next window
continue
conn.execute(
"INSERT OR IGNORE INTO digest_sends (user_id, brief_date, item_count) VALUES (?, ?, ?)",
(user["id"], brief_date, len(items)),
)
conn.commit()
sent += 1
return sent