queries.feed was the main chokepoint, but several discovery paths have their own
SQL. Apply the shared source exclusion to all of them so "no paywalls" is truly
site-wide:
- briefs.build_daily_brief: EXCLUDE paywalled candidates (was: demote) — never
stored in a new brief.
- queries.brief: stored-brief retrieval (covers /today + /api/brief) filters the
paywalled source.
- digest.digest_items + followed_digest_items: the morning email + "from what you
follow" omit paywalled sources.
- sitemap(): paywalled article pages excluded from the sitemap.
All reuse queries.paywalled_source_ids (admin override still wins).
Regression tests (test_paywall_exclusion.py): never stored in a new brief; /today
+ digest omit it; followed-source email omits it; Saved retains it; 'free'
override restores eligibility. 423 backend tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The Articles inspector revealed paywall is domain-coarse: nytimes.com is flagged,
so NY Times Learning's free Word-of-the-Day inherits 🔒 — and that flag isn't
cosmetic, it deprioritizes the content in feed sort + lead selection. Add a
per-source override so admins can correct it after inspecting.
- sources.paywall_override: NULL (domain rule) | 'free' | 'paywalled'.
- paywall.py: keep low-level is_paywalled(url) (domain); add is_paywalled_for_source
(url, override) for the EFFECTIVE decision — never patched the domain helper
globally (per Codex), so "domain says X" stays distinguishable from "overridden".
- Threaded everywhere ranking/UI touches paywall, via src.paywall_override on the
shared _ARTICLE_COLUMNS + the source-aware helper: feed sort, /api/since, replace,
lead selection, Article badge, brief composition (briefs.py), digest, source_health
(table 🔒), the Articles inspector, and the review/attention check — so ranking and
UI always agree.
- Endpoint POST /api/admin/sources/{id}/paywall {override}; admin UI: a select in the
inspector header (Use domain rule / Treat as free / Treat as paywalled) + the basis
("ON (domain)" / "OFF (override)"), optimistic so the panel stays open.
Test: domain rule → paywalled in table+inspector+feed badge; 'free' → off in all
three; validation 422 + 404. 242 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per user — a heavier, darker divider (2px #9aa6b2) marks the brief→personal
section change, distinct from the light #e8e3d8 item separators above.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per user — separate the heading a touch more from the first followed entry
(bottom margin 18px → 28px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It was 11px uppercase — smaller than the 14px links, backwards for a heading.
Now 20px bold ink, clearly a section header above its items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — give follows an immediate payoff without making the email feel
algorithmic. The editorial brief stays the star: subject + brief count unchanged.
* followed_digest_items(conn, user_id, exclude_ids, limit=3): recent items from
the user's followed sources/tags, same accepted/non-dup/content-visible gate,
excludes anything in the brief, capped to one per source so a single follow
can't dominate. Empty → section omitted (no empty state in email).
* build_digest gains an optional `followed` list → a small "From what you follow"
section AFTER the brief, only when there are items. Item rendering factored into
shared _item_html / _item_text_lines helpers.
* send_due_digests computes the followed items per user (excluding the brief).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Per user: lowercase masthead to echo the logo's character (kept as text, not an
image — email clients strip SVG and block remote images, so text always renders
and degrades gracefully). More breathing room before the 'Good morning' intro,
and a horizontal divider after it to match the item separators. (Real logo PNG
remains an option if wanted.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per user feedback: rename from 'Today's good news' (which implied the site was
done for the day) to a publication-style 'Upbeat Bytes — Daily Highlights'
masthead with a warm morning intro and a sign-off that links back to the site
('more good news is always waiting'). Adds 'why it's here' to the plain-text
part too. No images by design (lightweight, mail-client-safe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* On-site end-cap now says "You're caught up for now." — honest, since Highlights
refreshes through the day (the email keeps the daily "see you tomorrow").
* Anonymous "Get tomorrow's brief by email" now honors the one-tap promise:
sets a pending flag, opens sign-in, and auto-enables once auth resolves.
* Email compliance (RFC 2369/8058): send_email takes optional headers; the digest
sets List-Unsubscribe + List-Unsubscribe-Post=One-Click, and a POST
/api/digest/unsubscribe handles native one-click (GET still serves the page).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>