Files
upbeatBytes/goodnews/digest.py
T
thejayman77 798f08b256 Digest masthead: two-tone tight-tracked 'upbeat bytes' wordmark
Per user — make the text masthead read as the brand: lowercase 'upbeat bytes'
with a space but tight letter-spacing (-0.045em, not standard), in the logo's
two colours (azure #0083ad + navy #002772). Closest email-safe echo of the logo
short of a hosted PNG.

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

181 lines
8.2 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">'
# Two-tone lowercase wordmark echoing the logo (azure + navy), tight-tracked.
'<div style="font-family:\'Helvetica Neue\',Arial,sans-serif;font-size:32px;font-weight:700;'
'letter-spacing:-0.045em;margin:0;line-height:1">'
'<span style="color:#0083ad">upbeat</span> <span style="color:#002772">bytes</span></div>'
'<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