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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user