Files
upbeatBytes/goodnews/digest.py
T
thejayman77 69ed202c4e Digest masthead: use the real logo as a hosted PNG
Per Codex — cleanest, most brand-consistent option. Rasterized logo.svg to a
small transparent logo-email.png (360px wide, shown at 180px for retina), served
at /logo-email.png. alt="Upbeat Bytes" keeps the brand if a client blocks
images. SVG isn't email-safe; a hosted PNG is the standard approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:24:43 -04:00

181 lines
8.1 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"Upbeat Bytes — Daily Highlights",
f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n",
"Good morning. A small, hopeful handful of what's going right — and there's",
"always more waiting on the site whenever you want it.\n",
]
for it in items:
text_lines.append(f"{it['title']} ({it['source']})")
if it.get("summary"):
text_lines.append(f" {it['summary']}")
if it.get("reason_text"):
text_lines.append(f" Why it's here: {it['reason_text']}")
text_lines.append(f" Read: {base}/a/{it['id']}")
text_lines.append(f" Source: {it['canonical_url']}\n")
text_lines.append(f"That's today's highlights — more good news is always waiting at {base}. 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">'
# The real logo as a small hosted PNG (SVG isn't email-safe); alt text
# keeps the brand when a client blocks remote images.
f'<img src="{base}/logo-email.png" alt="Upbeat Bytes" width="180" '
'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">'
'<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;'
f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>'
'<p style="font-size:15px;line-height:1.5;color:#3b4754;margin:28px 0 18px">'
'Good morning. A small, hopeful handful of whats going right — and theres always more '
f'<a href="{base}" style="color:#0083ad;text-decoration:none">waiting on the site</a> when you want it.</p>'
'<div style="border-top:1px solid #e8e3d8;margin:0 0 24px"></div>'
+ "".join(blocks)
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">Thats todays highlights — more good news is '
f'always <a href="{base}" style="color:#3f7048">waiting on Upbeat Bytes</a>. 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
link = unsub_url(user, base)
subject, text, html = build_digest(items, brief_date, link, base)
# RFC 2369 / 8058: let inboxes offer native one-click unsubscribe.
headers = {"List-Unsubscribe": f"<{link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"}
try:
email_send.send_email(user["email"], subject, text, html=html, headers=headers)
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