diff --git a/AGENTS.md b/AGENTS.md
index bca15ef..6cd20c2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -127,13 +127,13 @@ unsure whether a change is contract-affecting, assume it is and check.
## Current state
-**Live on the operator's StartOS box** — app **0.2.157** + relay **0.2.124**. Tests: `cd server && npm test` → **119 pass**.
+**Live on the operator's StartOS box** — app **0.2.157** + relay **0.2.124** (0.2.158 built + committed, install in progress). Tests: `cd server && npm test` → **142 pass**.
**Done & live:** self-serve Pro/Max purchase (Bitcoin inline-Lightning + Zaprite card, prepaid, relay owns tier/expiry), core-decoupling, per-tenant subscriptions, and expiry-reminder emails (`POST /api/admin/reminders/run {test_email}`). Plans in `docs/*-plan.md`.
**Shipped this session (committed + pushed + verified live):** **0.2.156** — iOS sign-in "network error" flake; both sign-in paths now retry 3× with growing backoff (`91af0b7`). **0.2.157** — mobile/UX cluster: YT-minimize black-frame, podcast-audio + scroll loss on background re-render, redundant loading box, and a best-effort iOS scroll tweak (`693bb98`). Mechanics now captured as conventions above.
-**In progress — Daily Digest** (`docs/daily-digest-plan.md`, **proposed, awaiting go-ahead**): opt-in (off by default) daily email of the last 24h of library recaps, each a 1–2 paragraph overview synthesized from the recap's stored topic summaries; clones the `subscription-reminders.js` scan pattern. Next: build **phase 1** (schema `users.digest_enabled`/`last_digest_at` + opt-in toggle + settings UI) — but **first resolve open Q4: does `/relay/analyze` fit the synthesis call, or is a new relay capability needed?** (cross-repo: would touch `../recap-relay`). The other 3 open Qs (synthesis-cost owner, send hour, single-mode) have defaults in the plan.
+**Daily Digest — FEATURE-COMPLETE; 0.2.158 built + committed, install in progress** (`docs/daily-digest-plan.md`). Opt-in (off by default) once-a-day email of a user's last ~24h of library recaps, each a 1–2 paragraph overview synthesized from the recap's stored topic summaries; clones the `subscription-reminders.js` scan pattern. **All 5 phases built:** schema (`users.digest_enabled`/`last_digest_at`/`digest_unsub_token` + `migrateUserDigestPrefs`); `GET`/`POST /api/account/digest` (opt-in stamps the watermark to now) + settings-modal toggle; `server/daily-digest.js` (synthesis via `/relay/analyze`, **operator-absorbed** via operator install identity, `digestOverview` cache; `selectDigestEpisodes` watermark/cap/overflow; `runDigestScan` acts at `SEND_HOUR=8`, `MIN_RESEND_MS=20h`, advances watermark only on send, never throws; `startDigestScheduler`; public `GET /api/digest/unsubscribe?token=`); `renderDigestEmail`; `listScopeSessions`; `POST /api/admin/digest/run {test_email}`. Wired in `index.js` (multi) + `tenant-auth.js` public path. **19 digest tests, full suite 138 pass.** Verified on a real multi-mode boot (migrations apply, scheduler starts, unsubscribe 400/404/200 flips the flag end-to-end). **Q4** — `/relay/analyze` fits as-is, no relay change. **Q1** — operator-absorbed, zero operator action. **Pending: on-box smoke test** (relay synthesis + SMTP only run on the box) — `POST /api/admin/digest/run {test_email}` to eyeball the render, then opt in + add a recap + force a scan. **No version bump yet.** (Aside: relay `AGENTS.md:78` mis-describes `/relay/analyze` as `{transcript}→topic sections JSON` — stale; flagged for `../recap-relay` in the inbox.)
**Pending operator actions:**
1. **Verify the mobile can't-scroll-to-top fix on the iPad** — UNVERIFIED in 0.2.157 (iOS-layout-specific, not reproducible off-device); send a screen recording if it persists. Inbox item kept open + annotated.
diff --git a/docs/daily-digest-plan.md b/docs/daily-digest-plan.md
index a86fbb4..dcf2539 100644
--- a/docs/daily-digest-plan.md
+++ b/docs/daily-digest-plan.md
@@ -74,20 +74,72 @@ the user's credits. Confirm.
## Open questions (defaults chosen; confirm or adjust)
-1. **Synthesis cost owner** — operator-absorbed (default) vs user credits?
+1. **Synthesis cost owner** — ~~operator-absorbed (default) vs user credits?~~
+ **RESOLVED 2026-06-15: operator-absorbed, zero operator action.** The synthesis
+ provider is built with `resolveProviderOpts("relay", { req: null })` → the operator's
+ install identity, the *same* relay credit pool free signed-in users' summaries already
+ draw from (`providers/index.js` `pickRelayIdentity`). No comped system user-id needed.
+ Flipping to user-billing later = pass the recipient's cloud identity at the marked line
+ in `daily-digest.js` `buildSynthesisProvider()`.
2. **Send hour** — 08:00 server time (default)?
3. **Single-mode operator digest** — defer to a follow-on (default: multi-mode only v1)?
-4. **Relay contract** — does an existing relay endpoint (`/relay/analyze`) fit the
- "summarize these topic summaries into 2 paragraphs" call, or is a small new relay
- capability/prompt-mode needed? If new, update `../recap-relay` + both repos'
- `AGENTS.md`/`ROADMAP.md` per the cross-repo rule. **Resolve before phase 2.**
+4. **Relay contract** — ~~does an existing relay endpoint (`/relay/analyze`) fit~~
+ **RESOLVED 2026-06-15: `/relay/analyze` fits as-is, no new relay capability.** The
+ route (`recap-relay/server/routes/analyze.js`) takes a free-form `{ prompt: string }`
+ and returns `{ result: { text } }`; the client already wraps it as
+ `relay.js` `analyzeText({ prompt }) → result.text`. "Topic sections JSON" is only what
+ today's `chunked-analyze.js` caller asks for in *its* prompt — the endpoint is generic.
+ Synthesis = build a "summarize these summaries into 1–2 paragraphs" prompt, read
+ `result.text`. **No cross-repo change.** (Aside: relay `AGENTS.md:78` still describes
+ this endpoint as `{ transcript, … } → topic sections JSON` — stale; flag for that repo.)
+ Billing: each standalone analyze charges 1 credit on the call's credit key unless it
+ shares an `X-Recap-Job-Id` — that's the Q1 (cost-owner) mechanism, decided at phase 2.
## Build phases
-1. Schema + opt-in toggle (migration, account endpoint, settings UI).
-2. Synthesis + cache (relay call + write-back + operator-string scrub). Resolve the
- relay-contract question first.
-3. Email template + scan loop + scheduler + watermark dedup + overflow cap.
-4. Operator test trigger.
-5. Tests — pure-function coverage (episode selection vs watermark, cap/overflow, empty
- → skip), in the `subscription-reminders` test style.
+1. **BUILT 2026-06-15.** Schema + opt-in toggle. `db.js`: `users.digest_enabled`
+ (default 0) + `users.last_digest_at` (ms, nullable) via SCHEMA_SQL +
+ `migrateUserDigestPrefs`. `account-routes.js`: `GET`/`POST /api/account/digest`
+ (enabling stamps `last_digest_at = now` so the first send isn't a backlog dump).
+ `public/index.html`: settings-modal toggle (`renderDigestBlock` + `loadMyDigest` /
+ `setDigestEnabled`, optimistic with revert).
+2. **BUILT 2026-06-15.** Synthesis + cache → `server/daily-digest.js`:
+ `buildOverviewPrompt` (pure), `scrubOperatorStrings` (conservative backstop — infra
+ proper nouns + LAN/private hosts; dropped CUDA to avoid mangling legit tech content),
+ `synthesizeEpisodeOverview` (relay `analyzeText`, operator-absorbed identity, stable
+ per-episode jobId), `getOrCreateEpisodeOverview` (`digestOverview` cache + best-effort
+ `patchSession` write-back). NOT wired into a scheduler yet — dormant until phase 3.
+ Tests: `test/daily-digest.test.js` (12, pass). Note: chunks carry a `summary` text per
+ topic (not bullets — the Data section's "bullet summaries" wording was loose).
+3. **BUILT 2026-06-15.** Email + scan + scheduler + dedup + overflow cap.
+ `email-template.js` `renderDigestEmail` (minimal inline style, per-episode title→source
+ link + overview, overflow line, one-click unsubscribe). `daily-digest.js`:
+ `selectDigestEpisodes` (pure: watermark filter + cap + overflow), `runDigestScan`
+ (hourly tick, acts at `SEND_HOUR=8`; per-user `MIN_RESEND_MS=20h` + watermark dedup;
+ skips empty; advances watermark only on successful send; never throws),
+ `startDigestScheduler`, `setupDigestRoutes` (public `GET /api/digest/unsubscribe?token=`).
+ `history.js` `listScopeSessions`. `db.js` adds `users.digest_unsub_token` (minted lazily
+ on first send). Wired in `index.js` (multi-mode) + `tenant-auth.js` public path.
+4. **BUILT 2026-06-15.** `POST /api/admin/digest/run` — `{test_email}` sends a sample
+ render; bare body forces a real scan now (bypasses the hour gate, not the resend gate).
+ Mirrors `/api/admin/reminders/run`.
+5. **DONE.** `test/daily-digest.test.js` — 19 tests (prompt, scrub, synth/cache,
+ `selectDigestEpisodes` watermark/cap/overflow/empty, `scopeForUser`, email render).
+ Full suite **138 pass**. Verified on a real multi-mode boot: migrations apply, scheduler
+ starts, and the unsubscribe route (400/404/200 + flips `digest_enabled`) works end-to-end.
+
+## Status: feature-complete, awaiting on-box smoke test
+
+Built end-to-end but **not yet installed** (no version bump). The relay synthesis call and
+SMTP send can only be exercised on the operator's box. Operator smoke test:
+`POST /api/admin/digest/run {test_email}` to eyeball the render; then opt in, add a recap,
+and force a scan (or wait for 08:00) to see a real synthesized digest.
+
+**Fresh-eyes review applied (2026-06-15).** Three correctness fixes after a reviewer pass:
+(1) the watermark now advances to the newest *sent* recap but never past a failed/deferred
+one (`nextDigestWatermark`) — the old `now` stamp silently dropped both synthesis-failures
+and over-cap overflow recaps forever; (2) `force` no longer bypasses the in-progress lock,
+so an operator force-run during the scheduled tick can't double-send; (3) `idx_users_unsub_token`
+is created in the migration, not `SCHEMA_SQL` (the latter runs before the column exists on
+upgraded DBs → would crash boot). Existing-DB upgrade verified on a realistic pre-digest
+schema. Also added an index on the unauthenticated token lookup + a null-scope guard.
diff --git a/public/index.html b/public/index.html
index 01e79e5..0f5459f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2362,6 +2362,9 @@
// User's own active sessions (lite-settings tenant view). Loaded
// on demand when the Settings modal opens.
mySessions: { rows: null, loading: false, error: null },
+ // Daily Digest opt-in (lite-settings tenant view). enabled is null
+ // until loaded from /api/account/digest when the modal opens.
+ digest: { enabled: null, loading: false, saving: false },
// "Take Recap home" — fetches the tenant's raw license key on
// demand. We don't load this in /api/account/whoami because the
// key is a bearer credential we'd rather not pass through the
@@ -7343,6 +7346,7 @@
${state.account?.user ? renderClaimPurchaseBlock() : ""}
${state.account?.user ? renderPasswordBlock() : ""}
${state.account?.user ? renderMySessionsBlock() : ""}
+ ${state.account?.user ? renderDigestBlock() : ""}
${renderLibraryTransfer()}
${state.account?.user ? renderTenantDangerZone() : ""}
@@ -7874,6 +7878,33 @@
`;
}
+ // Daily Digest opt-in toggle. Off by default; a single switch that
+ // POSTs /api/account/digest. enabled is null until loaded — show a
+ // muted "Loading…" rather than a misleading unchecked box during the
+ // round-trip so the user never sees the wrong initial state.
+ function renderDigestBlock() {
+ const d = state.digest || {};
+ const checkboxAttrs =
+ d.enabled === null || d.loading || d.saving ? "disabled" : "";
+ return `
+
+
+
+
+ `;
+ }
+
// Danger Zone — sits at the very bottom of the tenant lite-settings
// modal. One action for now (Delete Account); future destructive
// self-actions land here. Visually muted by default to avoid being
@@ -10264,6 +10295,7 @@
loadAdminActivity(state.ops.activityHours || 24);
} else if (state.account?.user) {
loadMySessions();
+ loadMyDigest();
}
}
render();
@@ -12012,6 +12044,46 @@
}
}
+ async function loadMyDigest() {
+ state.digest.loading = true;
+ render();
+ try {
+ const res = await fetch(`${API_BASE}/api/account/digest`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ state.digest.enabled = !!data.enabled;
+ } catch (e) {
+ // Leave enabled null; the block keeps showing "Loading…" rather
+ // than asserting a state we couldn't confirm.
+ } finally {
+ state.digest.loading = false;
+ render();
+ }
+ }
+
+ async function setDigestEnabled(enabled) {
+ const prev = state.digest.enabled;
+ // Optimistic flip so the switch responds instantly; revert on error.
+ state.digest.enabled = enabled;
+ state.digest.saving = true;
+ render();
+ try {
+ const res = await fetch(`${API_BASE}/api/account/digest`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ showToast(enabled ? "Daily digest on" : "Daily digest off", "✓");
+ } catch (e) {
+ state.digest.enabled = prev;
+ showToast("Couldn't save that — try again", "!");
+ } finally {
+ state.digest.saving = false;
+ render();
+ }
+ }
+
async function revokeMySession(sessionId) {
try {
const res = await fetch(
diff --git a/server/account-routes.js b/server/account-routes.js
index 5c5e3ab..6f6a511 100644
--- a/server/account-routes.js
+++ b/server/account-routes.js
@@ -279,4 +279,60 @@ export function setupAccountRoutes(app) {
}
},
);
+
+ // ── Daily Digest opt-in ────────────────────────────────────────────
+ // Opt-in (off by default) daily email of the last ~24h of library
+ // recaps. The relay-owned subscription tier is unrelated — any
+ // signed-in user may toggle this. GET reads current state; POST
+ // {enabled:bool} flips it. Enabling stamps last_digest_at to "now"
+ // so the first digest covers only recaps added AFTER opt-in, never
+ // the user's whole backlog (the scan picks createdAt > watermark).
+ app.get("/api/account/digest", requireUser, (req, res) => {
+ if (!req.user || !req.user.id) {
+ return res.status(401).json({ error: "auth_required" });
+ }
+ try {
+ const row = getDb()
+ .prepare("SELECT digest_enabled, last_digest_at FROM users WHERE id = ?")
+ .get(req.user.id);
+ res.json({
+ enabled: !!row?.digest_enabled,
+ last_digest_at: row?.last_digest_at ?? null,
+ });
+ } catch (err) {
+ console.error("[account] digest read failed:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ });
+
+ app.post("/api/account/digest", requireUser, (req, res) => {
+ if (!req.user || !req.user.id) {
+ return res.status(401).json({ error: "auth_required" });
+ }
+ const enabled = req.body?.enabled;
+ if (typeof enabled !== "boolean") {
+ return res.status(400).json({ error: "enabled_must_be_boolean" });
+ }
+ try {
+ if (enabled) {
+ // Start the watermark at opt-in so the first send isn't a backlog dump.
+ getDb()
+ .prepare(
+ "UPDATE users SET digest_enabled = 1, last_digest_at = ? WHERE id = ?",
+ )
+ .run(Date.now(), req.user.id);
+ } else {
+ getDb()
+ .prepare("UPDATE users SET digest_enabled = 0 WHERE id = ?")
+ .run(req.user.id);
+ }
+ console.log(
+ `[account] digest ${enabled ? "enabled" : "disabled"} for user ${req.user.id}`,
+ );
+ res.json({ ok: true, enabled });
+ } catch (err) {
+ console.error("[account] digest toggle failed:", err);
+ res.status(500).json({ error: "internal_error" });
+ }
+ });
}
diff --git a/server/admin-routes.js b/server/admin-routes.js
index 405f6cf..c6a54b6 100644
--- a/server/admin-routes.js
+++ b/server/admin-routes.js
@@ -84,6 +84,70 @@ export function setupAdminRoutes(app) {
}
});
+ // Daily-digest test trigger. With {test_email}, sends a sample digest
+ // to that address so the operator can eyeball the rendering without
+ // opted-in users or waiting for the send hour. Without it, forces a
+ // real scan now (bypassing the 08:00 gate, NOT the per-user resend gate).
+ app.post("/api/admin/digest/run", requireOperator, async (req, res) => {
+ try {
+ const testEmail =
+ typeof req.body?.test_email === "string" ? req.body.test_email.trim() : "";
+ if (testEmail) {
+ const { isSmtpReady, sendMail } = await import("./smtp.js");
+ if (!isSmtpReady()) {
+ return res.status(503).json({
+ error: "smtp_not_ready",
+ message: "Configure StartOS System SMTP first.",
+ });
+ }
+ const { renderDigestEmail } = await import("./email-template.js");
+ const { getConfigSnapshot } = await import("./config.js");
+ const snap = await getConfigSnapshot();
+ const publicUrl = (snap.recap_public_url || "")
+ .trim()
+ .replace(/\/$/, "");
+ const msg = renderDigestEmail({
+ brandName: "Recaps",
+ episodes: [
+ {
+ title: "Sample podcast episode",
+ type: "podcast",
+ url: "https://example.com/episode",
+ overview:
+ "This is a sample overview paragraph so you can see how a digest entry renders. The real thing is synthesized from each recap's stored topic summaries.",
+ },
+ {
+ title: "Sample YouTube video",
+ type: "youtube",
+ url: "https://youtube.com/watch?v=example",
+ overview:
+ "A second sample entry, showing how multiple recaps stack in one email.",
+ },
+ ],
+ overflowCount: 0,
+ manageUrl: `${publicUrl}/`,
+ unsubscribeUrl: `${publicUrl}/api/digest/unsubscribe?token=sample`,
+ });
+ await sendMail({
+ to: testEmail,
+ subject: msg.subject,
+ text: msg.text,
+ html: msg.html,
+ });
+ return res.json({ ok: true, test_email_sent_to: testEmail });
+ }
+ const { runDigestScan } = await import("./daily-digest.js");
+ const result = await runDigestScan({ force: true });
+ res.json({ ok: true, ...result });
+ } catch (err) {
+ console.error("[admin] digest run failed:", err?.message || err);
+ res.status(500).json({
+ error: "digest_run_failed",
+ message: err?.message || String(err),
+ });
+ }
+ });
+
// ── List all tenants ───────────────────────────────────────────────────
app.get("/api/admin/tenants", requireOperator, (req, res) => {
try {
diff --git a/server/daily-digest.js b/server/daily-digest.js
new file mode 100644
index 0000000..eac3953
--- /dev/null
+++ b/server/daily-digest.js
@@ -0,0 +1,426 @@
+// Daily Digest — per-episode overview synthesis (multi-mode / cloud).
+//
+// Phase 2 of the Daily Digest feature: turn a saved recap's stored topic
+// summaries into a 1–2 paragraph overview via the relay LLM, then cache
+// the result back onto the session JSON (`digestOverview`) so it's
+// generated at most once per episode. The daily scan + email (phase 3)
+// will call getOrCreateEpisodeOverview(); no scheduler lives here yet.
+//
+// Cost ownership (Q1 = operator-absorbed): the synthesis call uses the
+// OPERATOR's relay identity — the same credit pool that free signed-in
+// users' summaries already draw from (resolveProviderOpts with req=null
+// → operator install identity). A retention email shouldn't silently
+// drain the recipient's quota for recaps they already made. To bill the
+// recipient instead, build the provider with their cloud identity at the
+// one marked line below.
+
+import { randomBytes } from "crypto";
+import { getProvider, resolveProviderOpts } from "./providers/index.js";
+import { patchSession, loadSession, listScopeSessions } from "./history.js";
+import { getDb } from "./db.js";
+import { sendMail, isSmtpReady } from "./smtp.js";
+import { renderDigestEmail } from "./email-template.js";
+import { getConfigSnapshot } from "./config.js";
+
+// Operator-internal vocabulary the sibling relay could surface in model
+// output (backend / hardware names, LAN hosts). Scrubbed before any
+// digest text reaches a cloud user — the same error-boundary rule the
+// rest of the app follows. This is a backstop: the synthesis input is
+// the recap's own (already user-facing) topic summaries, so a leak here
+// is unlikely. Kept conservative to avoid mangling legitimate prose —
+// only unambiguous infra tokens and private/LAN hosts, never common
+// words or public data.
+const OPERATOR_TERMS = [
+ /\bspark[\s-]?control\b/gi,
+ /\bparakeet\b/gi,
+ /\bsortformer\b/gi,
+ /\btitanet\b/gi,
+ /\bvllm\b/gi,
+];
+const LAN_HOST_RE = /\bhttps?:\/\/[^\s)]*\.local\b[^\s)]*/gi;
+const PRIVATE_IP_RE =
+ /\b(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|\b192\.168\.\d{1,3}\.\d{1,3}\b|\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/g;
+
+export function scrubOperatorStrings(text) {
+ if (!text) return "";
+ let out = String(text);
+ for (const re of OPERATOR_TERMS) out = out.replace(re, "");
+ out = out.replace(LAN_HOST_RE, "");
+ out = out.replace(PRIVATE_IP_RE, "");
+ // Tidy whitespace / orphaned punctuation the removals may have left.
+ return out
+ .replace(/[ \t]{2,}/g, " ")
+ .replace(/\s+([.,;:])/g, "$1")
+ .trim();
+}
+
+// Build the LLM prompt from a saved recap record. Pure — exported for
+// testing. Uses each topic's title + summary (the chunk shape is
+// { title, summary, … }); a topic with no summary still contributes its
+// title so the overview knows it was covered.
+export function buildOverviewPrompt(record) {
+ const title = (record?.title || "Untitled").trim();
+ const type =
+ record?.type === "podcast"
+ ? "podcast episode"
+ : record?.type === "youtube"
+ ? "video"
+ : "recording";
+ const topics = Array.isArray(record?.chunks) ? record.chunks : [];
+ const topicBlock = topics
+ .map((c, i) => {
+ const t = (c?.title || `Topic ${i + 1}`).trim();
+ const s = (c?.summary || "").trim();
+ return s ? `- ${t}: ${s}` : `- ${t}`;
+ })
+ .join("\n");
+ return [
+ `Below are the per-topic summaries of a ${type} titled "${title}".`,
+ "",
+ "Write a tight 1–2 paragraph overview (about 100–150 words) that captures " +
+ "the main throughline and the few most important takeaways, as if briefing " +
+ "a busy reader who hasn't seen it. Do not invent anything beyond the " +
+ "summaries below, use no headings or bullet points, and write in plain prose.",
+ "",
+ "Topic summaries:",
+ topicBlock,
+ ].join("\n");
+}
+
+// Operator-identity relay provider for synthesis (operator-absorbed).
+// Throws if the relay isn't configured (no install id / base URL) — the
+// caller treats that as "skip this episode", not a fatal error.
+function buildSynthesisProvider() {
+ // req=null → operator install identity. Swap in a per-recipient cloud
+ // identity here to bill the user instead of the operator.
+ const opts = resolveProviderOpts("relay", { req: null });
+ return getProvider("relay", opts);
+}
+
+// Synthesize (no cache) — call the relay, scrub, return the overview
+// text. Throws on no-topics or an empty model result. `provider` is
+// injectable for testing; defaults to the operator-identity relay.
+export async function synthesizeEpisodeOverview(record, { provider } = {}) {
+ const topics = Array.isArray(record?.chunks) ? record.chunks : [];
+ if (topics.length === 0) {
+ throw new Error("no topic summaries to synthesize");
+ }
+ const p = provider || buildSynthesisProvider();
+ const prompt = buildOverviewPrompt(record);
+ const result = await p.analyzeText({
+ prompt,
+ retries: 1,
+ // Stable per-episode billing key: a retry within the relay's job
+ // window reuses the same credit rather than charging twice.
+ jobId: record?.id ? `digest-${record.id}` : undefined,
+ });
+ const text = scrubOperatorStrings(result?.text || "");
+ if (!text) throw new Error("empty synthesis result");
+ return text;
+}
+
+// Get-or-generate the cached overview. Returns { overview, cached }. On a
+// cache miss it synthesizes and (unless save:false) writes the result
+// back onto the session JSON so the next caller is a cache hit. The
+// write-back is best-effort — a failed patch just means we re-synthesize
+// next time, never a user-visible error.
+export async function getOrCreateEpisodeOverview({
+ scope,
+ id,
+ record,
+ provider,
+ save = true,
+}) {
+ const cached = (record?.digestOverview || "").trim();
+ if (cached) return { overview: cached, cached: true };
+ const overview = await synthesizeEpisodeOverview(record, { provider });
+ if (save && scope && id) {
+ try {
+ await patchSession(scope, id, { digestOverview: overview });
+ } catch {
+ // best-effort cache; ignore
+ }
+ }
+ return { overview, cached: false };
+}
+
+// ── Daily scan + scheduler (mirrors subscription-reminders.js) ──────────
+
+const SEND_HOUR = 8; // 08:00 server-local — when the daily scan acts
+const SCAN_INTERVAL_MS = 60 * 60 * 1000; // tick hourly; act only at SEND_HOUR
+const BOOT_DELAY_MS = 2 * 60 * 1000;
+// A user gets at most one digest per ~day even if the loop ticks more
+// than once inside the send hour or they add content right after a send.
+const MIN_RESEND_MS = 20 * 60 * 60 * 1000;
+const MAX_EPISODES = 10; // cap per email; the rest become an overflow count
+
+let scanning = false;
+let scheduled = false;
+
+// Which library scope a user's recaps live under. Mirrors
+// history.js scopeForRequest: the multi-mode admin keeps the "owner"
+// scope; everyone else is scoped by their user id. Pure — exported for
+// testing.
+export function scopeForUser(user) {
+ return user?.is_admin ? "owner" : user?.id;
+}
+
+// Pick the recaps created after the watermark, oldest first, capped.
+// Pure — exported for testing. Returns { episodes, overflow, total }.
+export function selectDigestEpisodes(sessions, watermarkMs, cap = MAX_EPISODES) {
+ const since = typeof watermarkMs === "number" ? watermarkMs : 0;
+ const fresh = (sessions || [])
+ .filter((s) => {
+ const t = new Date(s?.createdAt).getTime();
+ return Number.isFinite(t) && t > since;
+ })
+ .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
+ return {
+ episodes: fresh.slice(0, cap),
+ overflow: Math.max(0, fresh.length - cap),
+ total: fresh.length,
+ };
+}
+
+function maskEmail(email) {
+ return String(email).replace(/^(.).*(@.*)$/, "$1***$2");
+}
+
+// Mint (and persist) a user's unsubscribe token if they don't have one
+// yet. Returns the token. Stable per user — re-enabling reuses it.
+function ensureUnsubToken(db, user) {
+ if (user.digest_unsub_token) return user.digest_unsub_token;
+ const token = randomBytes(32).toString("base64url");
+ db.prepare("UPDATE users SET digest_unsub_token = ? WHERE id = ?").run(
+ token,
+ user.id,
+ );
+ return token;
+}
+
+// Build one user's digest: synthesize an overview per selected episode
+// (operator-absorbed, cached). Returns { built, failed } where built are
+// the episode payloads ready for the template (each carrying its source
+// createdAt) and failed is the createdAt list of episodes that errored —
+// the caller uses both to set a watermark that never skips a failure.
+async function buildUserEpisodes(scope, selected) {
+ const built = [];
+ const failed = [];
+ for (const ep of selected) {
+ try {
+ const record = await loadSession(scope, ep.id);
+ if (!record) {
+ failed.push(ep.createdAt);
+ continue;
+ }
+ const { overview } = await getOrCreateEpisodeOverview({
+ scope,
+ id: ep.id,
+ record,
+ });
+ built.push({
+ title: ep.title,
+ type: ep.type,
+ url: ep.url,
+ overview,
+ createdAt: ep.createdAt,
+ });
+ } catch (err) {
+ failed.push(ep.createdAt);
+ console.warn(
+ `[digest] synthesis failed for ${scope}/${ep.id}: ${err?.message || err}`,
+ );
+ }
+ }
+ return { built, failed };
+}
+
+// The watermark to stamp after a send. Advances to the newest
+// successfully-sent recap, but never past the oldest one that FAILED (so
+// the next scan retries gaps) and never past un-synthesized overflow
+// recaps (their createdAt is newer than any sent one, so they're picked
+// up next scan too). Pure — exported for testing. Returns null when
+// nothing was sent (caller should not advance). createdAt inputs are ISO
+// strings; output is ms epoch.
+export function nextDigestWatermark(sentCreatedAts, failedCreatedAts) {
+ const toMs = (x) => new Date(x).getTime();
+ const sent = (sentCreatedAts || []).map(toMs).filter(Number.isFinite);
+ if (sent.length === 0) return null;
+ const failed = (failedCreatedAts || []).map(toMs).filter(Number.isFinite);
+ const newestSent = Math.max(...sent);
+ const oldestFailed = failed.length ? Math.min(...failed) : Infinity;
+ return Math.min(newestSent, oldestFailed - 1);
+}
+
+// One scan pass. Self-gating, deduped, NEVER throws — returns a small
+// summary so the scheduler stays alive. `force` bypasses the send-hour
+// gate (used by the operator test trigger), not the per-user resend gate.
+export async function runDigestScan({ force = false } = {}) {
+ // `force` bypasses the send-hour gate (operator test trigger), NOT the
+ // in-progress lock — a forced run alongside the scheduled tick would
+ // otherwise double-send to every opted-in user.
+ if (scanning) return { skipped: "already_running" };
+ scanning = true;
+ try {
+ const now = Date.now();
+ if (!force && new Date(now).getHours() !== SEND_HOUR) {
+ return { skipped: "off_hour" };
+ }
+ if (!isSmtpReady()) return { skipped: "smtp_not_ready" };
+ const snap = await getConfigSnapshot();
+ const publicUrl = (snap.recap_public_url || "").trim().replace(/\/$/, "");
+ if (!publicUrl) return { skipped: "public_url_not_set" };
+
+ const db = getDb();
+ const users = db
+ .prepare(
+ "SELECT id, email, is_admin, last_digest_at, digest_unsub_token FROM users WHERE digest_enabled = 1",
+ )
+ .all();
+
+ let sent = 0;
+ let skipped = 0;
+ for (const user of users) {
+ try {
+ const email = (user.email || "").trim();
+ if (!email) {
+ skipped++;
+ continue;
+ }
+ // Defensive: a row with no watermark (set via SQL, not the opt-in
+ // endpoint) would dump the whole backlog — start the clock now
+ // and pick up new recaps next scan instead.
+ if (typeof user.last_digest_at !== "number") {
+ db.prepare("UPDATE users SET last_digest_at = ? WHERE id = ?").run(
+ now,
+ user.id,
+ );
+ skipped++;
+ continue;
+ }
+ if (now - user.last_digest_at < MIN_RESEND_MS) {
+ skipped++;
+ continue;
+ }
+ const scope = scopeForUser(user);
+ if (!scope) {
+ // No usable id (shouldn't happen for a real row) — skip rather
+ // than read an "undefined" scope dir.
+ skipped++;
+ continue;
+ }
+ const sessions = await listScopeSessions(scope);
+ const { episodes: selected, overflow } = selectDigestEpisodes(
+ sessions,
+ user.last_digest_at,
+ MAX_EPISODES,
+ );
+ if (selected.length === 0) {
+ skipped++; // nothing new — skip the email, leave the watermark
+ continue;
+ }
+ const { built, failed } = await buildUserEpisodes(scope, selected);
+ if (built.length === 0) {
+ // Synthesis failed for all of them — don't advance the
+ // watermark, so the next scan retries the same recaps.
+ skipped++;
+ continue;
+ }
+ const token = ensureUnsubToken(db, user);
+ const message = renderDigestEmail({
+ brandName: "Recaps",
+ episodes: built,
+ overflowCount: overflow,
+ manageUrl: `${publicUrl}/`,
+ unsubscribeUrl: `${publicUrl}/api/digest/unsubscribe?token=${encodeURIComponent(token)}`,
+ });
+ await sendMail({
+ to: email,
+ subject: message.subject,
+ text: message.text,
+ html: message.html,
+ });
+ // Advance the watermark only after a successful send — to the
+ // newest sent recap, but never past a failed or deferred one, so
+ // the next scan retries gaps instead of skipping them.
+ const watermark = nextDigestWatermark(
+ built.map((e) => e.createdAt),
+ failed,
+ );
+ db.prepare("UPDATE users SET last_digest_at = ? WHERE id = ?").run(
+ watermark ?? now,
+ user.id,
+ );
+ sent++;
+ console.log(
+ `[digest] sent to ${maskEmail(email)} (${episodes.length} recap${episodes.length === 1 ? "" : "s"}${overflow ? `, +${overflow} more` : ""})`,
+ );
+ } catch (err) {
+ console.warn(
+ `[digest] user ${user.id} failed: ${err?.message || err}`,
+ );
+ skipped++;
+ }
+ }
+ if (sent) {
+ console.log(`[digest] scan complete: ${sent} sent, ${skipped} skipped`);
+ }
+ return { sent, skipped };
+ } catch (err) {
+ console.warn(`[digest] scan error: ${err?.message || err}`);
+ return { skipped: "error", error: err?.message || String(err) };
+ } finally {
+ scanning = false;
+ }
+}
+
+// Start the hourly scan loop. Idempotent; self-gates inside the scan, so
+// it's safe to call whenever multi mode boots.
+export function startDigestScheduler() {
+ if (scheduled) return;
+ scheduled = true;
+ setTimeout(() => {
+ runDigestScan().catch(() => {});
+ }, BOOT_DELAY_MS);
+ setInterval(() => {
+ runDigestScan().catch(() => {});
+ }, SCAN_INTERVAL_MS);
+ console.log("[digest] daily-digest scheduler started");
+}
+
+// One-click unsubscribe — a public GET (no session) keyed by the per-user
+// token in the email. Mounted in index.js (multi mode) and whitelisted in
+// tenant-auth's public paths. Flips digest_enabled off; the in-app toggle
+// can turn it back on.
+export function setupDigestRoutes(app) {
+ app.get("/api/digest/unsubscribe", (req, res) => {
+ const token = String(req.query?.token || "").trim();
+ const page = (msg) =>
+ `
${msg}
`;
+ if (!token) {
+ return res.status(400).send(page("Invalid unsubscribe link."));
+ }
+ try {
+ const r = getDb()
+ .prepare(
+ "UPDATE users SET digest_enabled = 0 WHERE digest_unsub_token = ?",
+ )
+ .run(token);
+ if (r.changes === 0) {
+ return res
+ .status(404)
+ .send(page("This unsubscribe link is no longer valid."));
+ }
+ return res.send(
+ page(
+ "You've been unsubscribed from the daily digest. You can turn it back on anytime in Settings.",
+ ),
+ );
+ } catch (err) {
+ console.error("[digest] unsubscribe failed:", err);
+ return res
+ .status(500)
+ .send(page("Something went wrong. Please try again later."));
+ }
+ });
+}
diff --git a/server/db.js b/server/db.js
index 9a09835..4acc471 100644
--- a/server/db.js
+++ b/server/db.js
@@ -41,10 +41,24 @@ CREATE TABLE IF NOT EXISTS users (
-- NOT used for auth decisions — just data for the operator to grep
-- when an abuse pattern shows up in the admin dashboard.
signup_ip TEXT,
- signup_user_agent TEXT
+ signup_user_agent TEXT,
+ -- Daily Digest (opt-in, multi-mode): a daily email of the user's last
+ -- ~24h of library recaps. Off by default. last_digest_at is the
+ -- ms-epoch watermark of the last send; the scan covers recaps created
+ -- AFTER it (dedup), and opt-in stamps it to "now" so the first digest
+ -- doesn't dump the whole backlog. NULL = never sent.
+ -- digest_unsub_token is a per-user random string for the one-click
+ -- unsubscribe link in each digest email (no login needed); minted
+ -- lazily on first send.
+ digest_enabled INTEGER NOT NULL DEFAULT 0,
+ last_digest_at INTEGER,
+ digest_unsub_token TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip);
+-- NB: idx_users_unsub_token is created in migrateUserDigestPrefs, not here
+-- — SCHEMA_SQL runs before the column migration on existing DBs, so an
+-- index over digest_unsub_token here would fail with "no such column".
-- ── sessions ───────────────────────────────────────────────────────────
-- Server-side session store so we can revoke individual sessions from
@@ -293,6 +307,7 @@ export async function initDb({ dataDir }) {
migrateTenantCreditsSchema(db);
migrateMagicLinkTokensTrialCookie(db);
migrateUsersTier(db);
+ migrateUserDigestPrefs(db);
dbInstance = db;
console.log(`[db] opened ${dbPath} (multi-tenant store)`);
@@ -314,6 +329,35 @@ function migrateUsersTier(db) {
}
}
+// Daily Digest — add the opt-in columns to existing DBs (fresh installs get
+// them from SCHEMA_SQL). Idempotent: ALTERs only the columns still missing.
+function migrateUserDigestPrefs(db) {
+ let cols;
+ try {
+ cols = db.prepare("PRAGMA table_info(users)").all();
+ } catch {
+ return;
+ }
+ if (!cols.some((c) => c.name === "digest_enabled")) {
+ db.exec("ALTER TABLE users ADD COLUMN digest_enabled INTEGER NOT NULL DEFAULT 0");
+ console.log("[db] added users.digest_enabled column (daily-digest)");
+ }
+ if (!cols.some((c) => c.name === "last_digest_at")) {
+ db.exec("ALTER TABLE users ADD COLUMN last_digest_at INTEGER");
+ console.log("[db] added users.last_digest_at column (daily-digest)");
+ }
+ if (!cols.some((c) => c.name === "digest_unsub_token")) {
+ db.exec("ALTER TABLE users ADD COLUMN digest_unsub_token TEXT");
+ console.log("[db] added users.digest_unsub_token column (daily-digest)");
+ }
+ // Created here (not in SCHEMA_SQL) so it runs AFTER the column exists on
+ // both fresh and migrated DBs. Idempotent. Keeps the public unsubscribe
+ // token lookup off a full-table scan.
+ db.exec(
+ "CREATE INDEX IF NOT EXISTS idx_users_unsub_token ON users(digest_unsub_token)",
+ );
+}
+
// v0.2.92 — split the single tenant_credits.balance into two buckets
// (purchased + replenish) so we can refill the latter periodically
// without wiping the former.
diff --git a/server/email-template.js b/server/email-template.js
index 8abf586..12c8dee 100644
--- a/server/email-template.js
+++ b/server/email-template.js
@@ -167,6 +167,110 @@ export function renderSubscriptionReminderEmail({
return { subject, text, html };
}
+// renderDigestEmail({ brandName, episodes, overflowCount, manageUrl,
+// unsubscribeUrl }) → { subject, text, html }
+// episodes: [{ title, type, url, overview }] — already capped + synthesized
+// by the scan. overflowCount: how many more are in the library beyond the
+// shown set (0 = none). Same minimal, spam-filter-friendly style as the
+// other emails: no images, inline CSS, one CTA. The unsubscribe link is a
+// one-click GET (no login) — required for deliverability + consent.
+export function renderDigestEmail({
+ brandName = "Recaps",
+ episodes = [],
+ overflowCount = 0,
+ manageUrl,
+ unsubscribeUrl,
+}) {
+ const n = episodes.length;
+ const subject =
+ n === 1
+ ? `Your ${brandName} digest: 1 new recap`
+ : `Your ${brandName} digest: ${n} new recaps`;
+
+ const typeLabel = (t) =>
+ t === "podcast" ? "Podcast" : t === "youtube" ? "Video" : "Recording";
+
+ const epText = episodes
+ .map((ep) =>
+ [
+ `${ep.title || "Untitled"} (${typeLabel(ep.type)})`,
+ ep.overview || "",
+ ep.url || "",
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ )
+ .join("\n\n");
+
+ const text = [
+ `Here's what you added to ${brandName} in the last day:`,
+ "",
+ epText,
+ "",
+ overflowCount > 0
+ ? `…and ${overflowCount} more in your library: ${manageUrl}`
+ : `Open your library: ${manageUrl}`,
+ "",
+ `You're receiving this because you turned on the daily digest. Unsubscribe: ${unsubscribeUrl}`,
+ ].join("\n");
+
+ const episodeBlocks = episodes
+ .map((ep) => {
+ const title = escapeHtml(ep.title || "Untitled");
+ const titleHtml = ep.url
+ ? `${title}`
+ : title;
+ return `
+