Files
upbeatBytes/goodnews/digest.py
T
thejayman77 cf5cbb33c0 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>
2026-06-09 16:17:46 -04:00

163 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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