Daily digest (opt-in) + finite "you're caught up" ending
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>
This commit is contained in:
@@ -303,6 +303,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The finite-ending's gentle nudge: one tap to get tomorrow's brief by email.
|
||||||
|
let digestBusy = $state(false);
|
||||||
|
async function subscribeDigest() {
|
||||||
|
if (!auth.user) { showSignIn = true; return; } // sign in, then enable below
|
||||||
|
if (auth.user.digest_enabled || digestBusy) return;
|
||||||
|
digestBusy = true;
|
||||||
|
try { await postJSON('/api/account/digest', { enabled: true }); await refreshAuth(); }
|
||||||
|
catch { /* leave as-is */ }
|
||||||
|
finally { digestBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
||||||
function applyAction(kind, value) {
|
function applyAction(kind, value) {
|
||||||
applyPrefAction(kind, value); // updates + persists + syncs to account
|
applyPrefAction(kind, value); // updates + persists + syncs to account
|
||||||
@@ -430,7 +441,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
<div class="endcap rise">
|
||||||
|
<p class="endmark">✦ that's the good news for today ✦</p>
|
||||||
|
<p class="endsub">You're caught up — see you tomorrow.</p>
|
||||||
|
{#if auth.user?.digest_enabled}
|
||||||
|
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
|
||||||
|
{:else}
|
||||||
|
<button class="digestcta" onclick={subscribeDigest} disabled={digestBusy}>
|
||||||
|
{digestBusy ? '…' : 'Get tomorrow’s brief by email'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -532,6 +553,15 @@
|
|||||||
text-align: center; color: var(--muted); font-family: var(--serif);
|
text-align: center; color: var(--muted); font-family: var(--serif);
|
||||||
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
.endcap .endmark { margin: 0; }
|
||||||
|
.endcap .endsub { margin: 4px 0 0; font-size: 0.92rem; }
|
||||||
|
.endcap .digestnote { margin: 14px 0 0; font-style: normal; font-family: var(--label); font-size: 0.86rem; color: var(--accent-deep); }
|
||||||
|
.endcap .digestcta {
|
||||||
|
margin-top: 16px; font-family: var(--label); font-style: normal; font-size: 0.9rem; cursor: pointer;
|
||||||
|
background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 10px 22px;
|
||||||
|
}
|
||||||
|
.endcap .digestcta:hover { background: var(--accent-deep); }
|
||||||
|
.endcap .digestcta:disabled { opacity: 0.6; cursor: default; }
|
||||||
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
|
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
|
||||||
.loadmore button {
|
.loadmore button {
|
||||||
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
|
import { auth, savedIds, refresh } from '$lib/auth.svelte.js';
|
||||||
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
||||||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||||
@@ -43,6 +43,20 @@
|
|||||||
persistPrefs();
|
persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let digestBusy = $state(false);
|
||||||
|
async function toggleDigest() {
|
||||||
|
if (!auth.user || digestBusy) return;
|
||||||
|
digestBusy = true;
|
||||||
|
try {
|
||||||
|
await postJSON('/api/account/digest', { enabled: !auth.user.digest_enabled });
|
||||||
|
await refresh(); // re-pull auth.user with the new digest_enabled
|
||||||
|
} catch {
|
||||||
|
/* leave as-is */
|
||||||
|
} finally {
|
||||||
|
digestBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load the saved grid when entering that section while signed in.
|
// Load the saved grid when entering that section while signed in.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (section === 'saved' && auth.user && !savedReady) {
|
if (section === 'saved' && auth.user && !savedReady) {
|
||||||
@@ -128,6 +142,13 @@
|
|||||||
<!-- profile -->
|
<!-- profile -->
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
<AccountPanel onclose={() => {}} />
|
<AccountPanel onclose={() => {}} />
|
||||||
|
<section class="panel digest">
|
||||||
|
<h2>Daily digest</h2>
|
||||||
|
<p class="dnote">A calm morning email — today's handful of good things, with a one-tap unsubscribe. No streaks, no noise; just the brief in your inbox.</p>
|
||||||
|
<button class="dtoggle" class:on={auth.user.digest_enabled} onclick={toggleDigest} disabled={digestBusy} aria-pressed={auth.user.digest_enabled}>
|
||||||
|
{#if digestBusy}…{:else if auth.user.digest_enabled}✓ On — you'll get tomorrow's brief{:else}Get the daily digest{/if}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="gate">Sign in from the home page to manage your profile, saved articles, and devices.</p>
|
<p class="gate">Sign in from the home page to manage your profile, saved articles, and devices.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -165,6 +186,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px; }
|
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px; }
|
||||||
|
.digest { margin-top: 16px; }
|
||||||
|
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||||
|
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
|
||||||
|
.dtoggle {
|
||||||
|
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
|
||||||
|
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
|
||||||
|
}
|
||||||
|
.dtoggle:hover { border-color: var(--accent-deep); }
|
||||||
|
.dtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.dtoggle:disabled { opacity: 0.6; cursor: default; }
|
||||||
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
.phead h2 { font-size: 1.3rem; }
|
.phead h2 { font-size: 1.3rem; }
|
||||||
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
|
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ def _user_out(user: sqlite3.Row) -> dict:
|
|||||||
"display_name": user["display_name"],
|
"display_name": user["display_name"],
|
||||||
"avatar_url": user["avatar_url"],
|
"avatar_url": user["avatar_url"],
|
||||||
"is_admin": _is_admin(user),
|
"is_admin": _is_admin(user),
|
||||||
|
"digest_enabled": bool(user["digest_enabled"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -346,6 +347,7 @@ class UserOut(BaseModel):
|
|||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
is_admin: bool = False
|
is_admin: bool = False
|
||||||
|
digest_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SessionOut(BaseModel):
|
class SessionOut(BaseModel):
|
||||||
@@ -409,6 +411,10 @@ class CandidatePromoteBody(BaseModel):
|
|||||||
poll_interval_minutes: int = 180
|
poll_interval_minutes: int = 180
|
||||||
|
|
||||||
|
|
||||||
|
class DigestBody(BaseModel):
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class SourceReviewBody(BaseModel):
|
class SourceReviewBody(BaseModel):
|
||||||
flag: bool = False
|
flag: bool = False
|
||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
@@ -506,6 +512,49 @@ def create_app() -> FastAPI:
|
|||||||
user = _current_user(conn, request)
|
user = _current_user(conn, request)
|
||||||
return UserOut(**_user_out(user)) if user else None
|
return UserOut(**_user_out(user)) if user else None
|
||||||
|
|
||||||
|
@app.post("/api/account/digest")
|
||||||
|
def account_digest(body: DigestBody, request: Request) -> dict:
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = _current_user(conn, request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="sign in required")
|
||||||
|
token = user["digest_unsub_token"]
|
||||||
|
if body.enabled and not token:
|
||||||
|
token = secrets.token_urlsafe(18)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET digest_enabled = ?, digest_unsub_token = ? WHERE id = ?",
|
||||||
|
(1 if body.enabled else 0, token, user["id"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "digest_enabled": body.enabled}
|
||||||
|
|
||||||
|
@app.get("/api/digest/unsubscribe", response_class=HTMLResponse)
|
||||||
|
def digest_unsubscribe(u: int = Query(...), t: str = Query(...)) -> HTMLResponse:
|
||||||
|
# One-click, no login: match the per-user token, then turn the digest off.
|
||||||
|
ok = False
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT digest_unsub_token FROM users WHERE id = ?", (u,)).fetchone()
|
||||||
|
if row and row["digest_unsub_token"] and hmac.compare_digest(row["digest_unsub_token"], t):
|
||||||
|
conn.execute("UPDATE users SET digest_enabled = 0 WHERE id = ?", (u,))
|
||||||
|
conn.commit()
|
||||||
|
ok = True
|
||||||
|
msg = (
|
||||||
|
"You’re unsubscribed from the daily digest. No hard feelings — "
|
||||||
|
"Upbeat Bytes is always here when you want it."
|
||||||
|
if ok else
|
||||||
|
"That unsubscribe link looks invalid or expired. You can manage the "
|
||||||
|
"digest from your account settings."
|
||||||
|
)
|
||||||
|
html = (
|
||||||
|
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||||
|
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
|
||||||
|
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
||||||
|
'<h1 style="font-size:22px">Upbeat Bytes</h1>'
|
||||||
|
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
|
||||||
|
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to Upbeat Bytes</a></p></div>'
|
||||||
|
)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout")
|
||||||
def auth_logout(request: Request, response: Response) -> dict:
|
def auth_logout(request: Request, response: Response) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
+2
-1
@@ -46,7 +46,8 @@ def normalize_email(email: str) -> str:
|
|||||||
|
|
||||||
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
|
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"SELECT id, email, display_name, avatar_url, is_admin, created_at FROM users WHERE id = ?",
|
"SELECT id, email, display_name, avatar_url, is_admin, digest_enabled, digest_unsub_token, created_at "
|
||||||
|
"FROM users WHERE id = ?",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .briefs import build_daily_brief, show_brief
|
from .briefs import build_daily_brief, show_brief
|
||||||
from .db import connect, init_db
|
from .db import connect, init_db
|
||||||
|
from .digest import send_due_digests
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
|
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
|
||||||
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
||||||
@@ -123,10 +124,14 @@ def main() -> None:
|
|||||||
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
|
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
|
||||||
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
|
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
|
||||||
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
|
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
|
||||||
|
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
|
||||||
cycle_parser.add_argument("--force", action="store_true", help="Poll all active sources, ignoring intervals")
|
cycle_parser.add_argument("--force", action="store_true", help="Poll all active sources, ignoring intervals")
|
||||||
cycle_parser.add_argument("--base-url", help="OpenAI-compatible base URL for classify")
|
cycle_parser.add_argument("--base-url", help="OpenAI-compatible base URL for classify")
|
||||||
cycle_parser.add_argument("--model", help="Local model name for classify")
|
cycle_parser.add_argument("--model", help="Local model name for classify")
|
||||||
|
|
||||||
|
digest_parser = subparsers.add_parser("send-digests", help="Send today's digest to opted-in users (morning-gated)")
|
||||||
|
digest_parser.add_argument("--force", action="store_true", help="Ignore the morning send window")
|
||||||
|
|
||||||
enrich_images_parser = subparsers.add_parser(
|
enrich_images_parser = subparsers.add_parser(
|
||||||
"enrich-images", help="Backfill og:images for already-summarized articles that lack one"
|
"enrich-images", help="Backfill og:images for already-summarized articles that lack one"
|
||||||
)
|
)
|
||||||
@@ -271,6 +276,10 @@ def main() -> None:
|
|||||||
print("Dry run only; database was not updated.")
|
print("Dry run only; database was not updated.")
|
||||||
elif args.command == "cycle":
|
elif args.command == "cycle":
|
||||||
run_cycle(conn, args)
|
run_cycle(conn, args)
|
||||||
|
elif args.command == "send-digests":
|
||||||
|
init_db(conn)
|
||||||
|
sent = send_due_digests(conn, force=args.force)
|
||||||
|
print(f"send-digests: sent {sent}")
|
||||||
elif args.command == "enrich-images":
|
elif args.command == "enrich-images":
|
||||||
found = enrich_summarized_images(conn, limit=args.limit)
|
found = enrich_summarized_images(conn, limit=args.limit)
|
||||||
print(f"enrich-images: {found} new image(s) for summarized articles")
|
print(f"enrich-images: {found} new image(s) for summarized articles")
|
||||||
@@ -495,6 +504,14 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"review: skipped ({exc})")
|
print(f"review: skipped ({exc})")
|
||||||
|
|
||||||
|
if not args.no_digest:
|
||||||
|
try:
|
||||||
|
sent = send_due_digests(conn) # morning-gated + deduped internally
|
||||||
|
if sent:
|
||||||
|
print(f"digest: sent {sent}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"digest: skipped ({exc})")
|
||||||
|
|
||||||
|
|
||||||
def serve(args: argparse.Namespace) -> None:
|
def serve(args: argparse.Namespace) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
digest_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
digest_unsub_token TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -254,6 +256,15 @@ CREATE TABLE IF NOT EXISTS feedback_replies (
|
|||||||
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
|
CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS digest_sends (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
brief_date TEXT NOT NULL,
|
||||||
|
item_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, brief_date)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +307,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT")
|
conn.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT")
|
||||||
if user_tbl and "is_admin" not in user_tbl:
|
if user_tbl and "is_admin" not in user_tbl:
|
||||||
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if user_tbl and "digest_enabled" not in user_tbl:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN digest_enabled INTEGER NOT NULL DEFAULT 0")
|
||||||
|
if user_tbl and "digest_unsub_token" not in user_tbl:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN digest_unsub_token TEXT")
|
||||||
|
|
||||||
article_cols = {row["name"] for row in conn.execute("PRAGMA table_info(articles)")}
|
article_cols = {row["name"] for row in conn.execute("PRAGMA table_info(articles)")}
|
||||||
if "duplicate_of" not in article_cols:
|
if "duplicate_of" not in article_cols:
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""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
|
||||||
@@ -213,3 +213,21 @@ def test_source_check_preview_is_readonly(tmp_path, monkeypatch):
|
|||||||
assert after["last_success_at"] == before["last_success_at"] and after["next_due_at"] == before["next_due_at"]
|
assert after["last_success_at"] == before["last_success_at"] and after["next_due_at"] == before["next_due_at"]
|
||||||
assert TestClient(app).post("/api/admin/sources/1/preview").status_code == 401 # gated
|
assert TestClient(app).post("/api/admin/sources/1/preview").status_code == 401 # gated
|
||||||
assert tc.post("/api/admin/sources/999/preview").status_code == 404
|
assert tc.post("/api/admin/sources/999/preview").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch):
|
||||||
|
import os, sqlite3
|
||||||
|
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||||
|
tc = _signin(app, api, "reader@x.com")
|
||||||
|
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
||||||
|
assert tc.post("/api/account/digest", json={"enabled": True}).json()["digest_enabled"] is True
|
||||||
|
assert tc.get("/api/auth/me").json()["digest_enabled"] is True
|
||||||
|
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||||
|
uid, tok = c.execute("SELECT id, digest_unsub_token FROM users WHERE email='reader@x.com'").fetchone()
|
||||||
|
c.close()
|
||||||
|
assert tok # token generated on opt-in
|
||||||
|
# one-click unsubscribe: wrong token is rejected, right token disables
|
||||||
|
assert "invalid" in TestClient(app).get(f"/api/digest/unsubscribe?u={uid}&t=nope").text.lower()
|
||||||
|
assert "unsubscribed" in TestClient(app).get(f"/api/digest/unsubscribe?u={uid}&t={tok}").text.lower()
|
||||||
|
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
||||||
|
assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from goodnews.db import connect, init_db
|
||||||
|
from goodnews import digest, email_send
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(c, n=5, date="2026-06-09"):
|
||||||
|
bid = c.execute("INSERT INTO daily_briefs (brief_date, title) VALUES (?, 't')", (date,)).lastrowid
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,active,content_visible) VALUES (?,?,?,1,1)",
|
||||||
|
(i, f"Src{i}", f"http://s{i}/f"))
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (?,?,?,?,?)",
|
||||||
|
(i, i, f"http://a/{i}", f"Title {i}", f"h{i}"))
|
||||||
|
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (?,1,?)", (i, f"reason {i}"))
|
||||||
|
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (i, f"summary {i}"))
|
||||||
|
c.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (?,?,?)", (bid, i, i))
|
||||||
|
c.execute("INSERT INTO users (id,email,digest_enabled) VALUES (1,'reader@x.com',1)")
|
||||||
|
c.commit()
|
||||||
|
return date
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_digest_is_calm_and_dated():
|
||||||
|
items = [{"id": 1, "title": "Good thing", "canonical_url": "http://a/1", "source": "Src", "summary": "nice", "reason_text": "wonder", "paywalled": False}]
|
||||||
|
subject, text, html = digest.build_digest(items, "2026-06-09", "http://ub/unsub")
|
||||||
|
assert "Tuesday's Upbeat Bytes" in subject and "1 calm read" in subject
|
||||||
|
assert "You're caught up" in text and "http://ub/unsub" in text
|
||||||
|
assert "Good thing" in html and "Read on Upbeat Bytes" in html and "Unsubscribe" in html
|
||||||
|
assert "you missed" not in (text + html).lower() # no guilt language
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_due_digests_sends_dedupes_and_skips(monkeypatch, tmp_path):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(email_send, "send_email", lambda to, subj, text, html=None: sent.append(to))
|
||||||
|
c = connect(str(tmp_path / "d.db")); init_db(c)
|
||||||
|
date = _seed(c, n=5)
|
||||||
|
monkeypatch.setattr(digest, "local_today", lambda: date)
|
||||||
|
# force=True bypasses the morning window
|
||||||
|
assert digest.send_due_digests(c, force=True) == 1
|
||||||
|
assert sent == ["reader@x.com"]
|
||||||
|
assert c.execute("SELECT item_count FROM digest_sends WHERE user_id=1").fetchone()[0] == 5
|
||||||
|
# dedupe: a second run sends nothing
|
||||||
|
assert digest.send_due_digests(c, force=True) == 0
|
||||||
|
# unsub token was generated on send
|
||||||
|
assert c.execute("SELECT digest_unsub_token FROM users WHERE id=1").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_skips_thin_day(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(email_send, "send_email", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send")))
|
||||||
|
c = connect(str(tmp_path / "d.db")); init_db(c)
|
||||||
|
date = _seed(c, n=3) # below MIN_ITEMS (4)
|
||||||
|
monkeypatch.setattr(digest, "local_today", lambda: date)
|
||||||
|
assert digest.send_due_digests(c, force=True) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_respects_opt_out(monkeypatch, tmp_path):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(email_send, "send_email", lambda to, *a, **k: sent.append(to))
|
||||||
|
c = connect(str(tmp_path / "d.db")); init_db(c)
|
||||||
|
date = _seed(c, n=5)
|
||||||
|
c.execute("UPDATE users SET digest_enabled=0 WHERE id=1"); c.commit()
|
||||||
|
monkeypatch.setattr(digest, "local_today", lambda: date)
|
||||||
|
assert digest.send_due_digests(c, force=True) == 0 and sent == []
|
||||||
Reference in New Issue
Block a user