Files
upbeatBytes/goodnews/digest.py
T
thejayman77 0c68c22221 Brand consistency: emails say "upbeatBytes" (From + digest body)
Per the brand-name standard (camelCase, one word). Updated the SMTP From default and
the digest email body/subject strings. Live env From values (auth.env + goodnews.env)
updated to match. (Web/OG brand strings in share.py + app.html are the remaining sweep.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:38:16 -04:00

258 lines
12 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, is_paywalled_for_source
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, s.paywall_override, 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_for_source(d["canonical_url"], d.get("paywall_override"))
items.append(d)
return items
def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, limit: int = 3) -> list[dict]:
"""Up to `limit` recent items from the user's followed sources/tags for the
"From what you follow" section — same accepted/non-dup/visible gate as the feed,
excluding what's already in the brief, capped to one per source so a single
follow can't dominate. Returns [] if they follow nothing (→ section omitted)."""
frows = conn.execute("SELECT kind, value FROM user_follows WHERE user_id = ?", (user_id,)).fetchall()
fsources = [int(r["value"]) for r in frows if r["kind"] == "source" and str(r["value"]).isdigit()]
ftags = [str(r["value"]).lower() for r in frows if r["kind"] == "tag"]
if not fsources and not ftags:
return []
ors, params = [], []
if fsources:
ors.append(f"a.source_id IN ({','.join('?' * len(fsources))})")
params += fsources
if ftags:
ors.append(
f"EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id "
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
)
params += ftags
rows = conn.execute(
f"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
FROM articles a
JOIN sources s ON s.id = a.source_id
JOIN article_scores sc ON sc.article_id = a.id
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1
AND ({' OR '.join(ors)})
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
LIMIT 30
""",
params,
).fetchall()
exclude, per_source, out = set(exclude_ids), {}, []
for r in rows:
d = dict(r)
if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1:
continue
per_source[d["source_id"]] = 1
d["paywalled"] = is_paywalled_for_source(d["canonical_url"], d.get("paywall_override"))
out.append(d)
if len(out) >= limit:
break
return out
def _weekday(brief_date: str) -> str:
try:
return datetime.strptime(brief_date, "%Y-%m-%d").strftime("%A")
except (ValueError, TypeError):
return "today"
def _item_text_lines(it: dict, base: str) -> list[str]:
lines = [f"{it['title']} ({it['source']})"]
if it.get("summary"):
lines.append(f" {it['summary']}")
if it.get("reason_text"):
lines.append(f" Why it's here: {it['reason_text']}")
lines.append(f" Read: {base}/a/{it['id']}")
lines.append(f" Source: {it['canonical_url']}\n")
return lines
def _item_html(it: dict, base: str) -> str:
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 ""
return (
'<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 upbeatBytes</a>'
f' &nbsp;·&nbsp; <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
'</div></div>'
)
def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str | None = None,
followed: list[dict] | None = None) -> tuple[str, str, str]:
"""Return (subject, text, html) for the digest — calm and dated, no urgency.
`followed` (optional) adds a small "From what you follow" section AFTER the
editorial brief — only when there are qualifying items; omitted otherwise.
The brief stays the star: subject and brief count are unchanged.
"""
base = base or _base_url()
followed = followed or []
n = len(items)
weekday = _weekday(brief_date)
subject = f"{weekday}'s upbeatBytes · {n} calm read{'' if n == 1 else 's'}"
if weekday == "today":
subject = f"Today's upbeatBytes · {n} calm reads"
text_lines = [
f"upbeatBytes — 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 += _item_text_lines(it, base)
if followed:
text_lines.append("\n— From what you follow —\n")
for it in followed:
text_lines += _item_text_lines(it, base)
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)
main_blocks = "".join(_item_html(it, base) for it in items)
followed_html = ""
if followed:
followed_html = (
# A darker, heavier rule marks the section change (the item separators
# above are a light #e8e3d8) — a clear shift from brief to personal picks.
'<div style="border-top:2px solid #9aa6b2;margin:2px 0 22px"></div>'
'<div style="font-size:20px;font-weight:700;letter-spacing:-0.01em;color:#0083ad;'
'margin:0 0 28px">From what you follow</div>'
+ "".join(_item_html(it, base) for it in followed)
)
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="upbeatBytes" 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>'
+ main_blocks
+ followed_html
+ '<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 upbeatBytes</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)
followed = followed_digest_items(conn, user["id"], [it["id"] for it in items])
subject, text, html = build_digest(items, brief_date, link, base, followed=followed)
# 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