"""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'
'
'' 'Good morning. A small, hopeful handful of what’s going right — and there’s always more ' f'waiting on the site when you want it.
' '' + main_blocks + followed_html + 'That’s today’s highlights — more good news is ' f'always waiting on upbeatBytes. See you tomorrow.
' f'You’re getting this because you turned on ' f'the daily digest. Unsubscribe.
' '