cf5cbb33c0
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>
163 lines
6.8 KiB
Python
163 lines
6.8 KiB
Python
"""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 it’s 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' · <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">Today’s 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">That’s today’s good news. '
|
||
'You’re caught up — see you tomorrow.</p>'
|
||
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">You’re 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
|