Add opt-in Daily Digest (daily email of last 24h of library recaps)

Multi-mode, off by default. Each new recap is synthesized into a 1-2
paragraph overview via the relay (operator-absorbed) and cached onto the
session JSON; a daily 08:00 scan emails opted-in users their fresh
recaps, deduped by a per-user watermark that never skips a failed or
over-cap recap. One-click tokenized unsubscribe; settings-modal toggle;
admin test trigger. Bumps to 0.2.158.
This commit is contained in:
Keysat
2026-06-15 19:50:48 -05:00
parent 962423ca10
commit b4fa5d7be8
14 changed files with 1144 additions and 17 deletions
+56
View File
@@ -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" });
}
});
}
+64
View File
@@ -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 {
+426
View File
@@ -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 12 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 12 paragraph overview (about 100150 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) =>
`<!doctype html><html><body style="margin:0;padding:48px 16px;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;text-align:center;color:#333;"><div style="max-width:420px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;font-size:15px;line-height:1.6;">${msg}</div></body></html>`;
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."));
}
});
}
+45 -1
View File
@@ -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.
+104
View File
@@ -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
? `<a href="${escapeAttr(ep.url)}" style="color:#111;text-decoration:none;">${title}</a>`
: title;
return `
<tr>
<td style="padding-bottom:20px;border-bottom:1px solid #eee;">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:#999;padding-bottom:4px;">${escapeHtml(typeLabel(ep.type))}</div>
<div style="font-size:16px;font-weight:600;color:#111;padding-bottom:8px;line-height:1.35;">${titleHtml}</div>
<div style="font-size:14px;line-height:1.55;color:#444;">${escapeHtml(ep.overview || "")}</div>
</td>
</tr>
<tr><td style="height:20px;"></td></tr>`;
})
.join("");
const overflowHtml =
overflowCount > 0
? `<tr><td style="font-size:13px;color:#888;padding-bottom:16px;">…and ${overflowCount} more in your library.</td></tr>`
: "";
const html = `<!doctype html>
<html>
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
<tr>
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:20px;">
Your ${escapeHtml(brandName)} digest
</td>
</tr>
${episodeBlocks}
${overflowHtml}
<tr>
<td align="center" style="padding:8px 0 24px;">
<a href="${escapeAttr(manageUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:6px;">Open your library</a>
</td>
</tr>
<tr>
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
You're receiving this because you turned on the daily digest. <a href="${escapeAttr(unsubscribeUrl)}" style="color:#888;">Unsubscribe</a> anytime, or manage it in Settings.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
return { subject, text, html };
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
+37
View File
@@ -220,6 +220,43 @@ export async function loadSession(scope, id) {
}
}
// List a scope's saved sessions as lightweight metadata (no entries /
// chunks), oldest first. The daily-digest scan uses this to pick recaps
// created after a watermark before loading each full record for
// synthesis. Returns [] when the scope has no library yet (or the id is
// malformed — safeComponent throws inside scopeDir, caught here).
export async function listScopeSessions(scope) {
let dir;
try {
dir = scopeDir(scope);
} catch {
return [];
}
let files = [];
try {
files = await fs.readdir(dir);
} catch {
return [];
}
const out = [];
for (const file of files.filter(
(f) => f.endsWith(".json") && !f.startsWith("_") && !ROOT_SIDECARS.has(f),
)) {
try {
const data = JSON.parse(await fs.readFile(path.join(dir, file), "utf-8"));
out.push({
id: data.id,
title: data.title,
type: data.type || "youtube",
url: data.url,
createdAt: data.createdAt,
});
} catch {}
}
out.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
return out;
}
// Shallow-merge `patch` into a session record on disk (e.g. to stamp
// `summaryAudio` availability). No-op-safe: returns null if the record
// is missing rather than throwing.
+9
View File
@@ -264,6 +264,15 @@ if (RECAP_MODE === "multi") {
// public URL + relay being configured, so it's a safe no-op until then.
const { startReminderScheduler } = await import("./subscription-reminders.js");
startReminderScheduler();
// Daily Digest: opt-in (off by default) once-a-day email of a user's
// last ~24h of library recaps. Same self-gating shape as reminders —
// no-op until SMTP + public URL are set. The one-click unsubscribe GET
// is public (whitelisted in tenant-auth) since the email has no session.
const { startDigestScheduler, setupDigestRoutes } = await import(
"./daily-digest.js"
);
setupDigestRoutes(app);
startDigestScheduler();
// /api/account/whoami — frontend hits this on every page load to
// determine which UI state to render:
+1
View File
@@ -35,6 +35,7 @@ const PUBLIC_PATH_PREFIXES = [
"/api/health",
"/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
"/api/btcpay/webhook", // BTCPay needs to reach this without a session
"/api/digest/unsubscribe", // one-click unsubscribe from a digest email (no session)
"/api/network-mode", // returns lan-vs-local; safe to expose
"/api/relay/status", // public relay capabilities — pre-trial visibility
"/api/account/whoami", // returns state — anonymous visitors must call this
+248
View File
@@ -0,0 +1,248 @@
// Pure / injectable-logic tests for Daily Digest episode synthesis. The
// relay round-trip and FS cache write-back aren't exercised here (a fake
// provider stands in, save:false skips disk); this nails prompt shaping,
// the operator-string scrub backstop, and the get-or-generate cache gate.
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import {
buildOverviewPrompt,
scrubOperatorStrings,
synthesizeEpisodeOverview,
getOrCreateEpisodeOverview,
selectDigestEpisodes,
scopeForUser,
nextDigestWatermark,
} from "../daily-digest.js";
import { renderDigestEmail } from "../email-template.js";
const record = (over = {}) => ({
id: "1700000000000-abc",
title: "How Markets Work",
type: "podcast",
chunks: [
{ title: "Supply & demand", summary: "Prices clear where the two curves meet." },
{ title: "Information", summary: "Asymmetry distorts outcomes." },
],
...over,
});
// A provider stub recording calls, returning a fixed analyze result.
const fakeProvider = (text, sink = {}) => ({
async analyzeText(args) {
sink.calls = (sink.calls || 0) + 1;
sink.lastArgs = args;
return { text };
},
});
describe("buildOverviewPrompt", () => {
test("includes title, type, and each topic's title + summary", () => {
const p = buildOverviewPrompt(record());
assert.match(p, /"How Markets Work"/);
assert.match(p, /podcast episode/);
assert.match(p, /- Supply & demand: Prices clear/);
assert.match(p, /- Information: Asymmetry distorts/);
assert.match(p, /12 paragraph/);
});
test("a topic with no summary still contributes its title", () => {
const p = buildOverviewPrompt(
record({ chunks: [{ title: "Loose ends" }] }),
);
assert.match(p, /- Loose ends/);
assert.doesNotMatch(p, /Loose ends:/); // no trailing colon when summary absent
});
test("null-safe: missing fields fall back", () => {
const p = buildOverviewPrompt({});
assert.match(p, /"Untitled"/);
assert.match(p, /recording/); // unknown type
});
});
describe("scrubOperatorStrings", () => {
test("removes operator/infra tokens", () => {
const out = scrubOperatorStrings(
"Routed via Spark Control and the vLLM box on Parakeet.",
);
assert.doesNotMatch(out, /spark control/i);
assert.doesNotMatch(out, /vllm/i);
assert.doesNotMatch(out, /parakeet/i);
});
test("strips LAN hosts and private IPs, keeps public/content data", () => {
assert.doesNotMatch(
scrubOperatorStrings("see http://immense-voyage.local/admin"),
/\.local/,
);
assert.doesNotMatch(scrubOperatorStrings("host 192.168.1.42 here"), /192\.168/);
// A public dotted quad in content is the user's data, not a leak.
assert.match(scrubOperatorStrings("DNS is 8.8.8.8"), /8\.8\.8\.8/);
});
test("leaves ordinary prose intact", () => {
const clean = "The episode covers supply, demand, and information costs.";
assert.equal(scrubOperatorStrings(clean), clean);
});
test("null-safe", () => {
assert.equal(scrubOperatorStrings(null), "");
assert.equal(scrubOperatorStrings(""), "");
});
});
describe("synthesizeEpisodeOverview", () => {
test("scrubs the model result and passes a stable per-episode jobId", async () => {
const sink = {};
const out = await synthesizeEpisodeOverview(record(), {
provider: fakeProvider("A clear overview from Spark Control.", sink),
});
assert.doesNotMatch(out, /spark control/i);
assert.equal(sink.calls, 1);
assert.equal(sink.lastArgs.jobId, "digest-1700000000000-abc");
});
test("throws when there are no topics", async () => {
await assert.rejects(
() => synthesizeEpisodeOverview(record({ chunks: [] }), { provider: fakeProvider("x") }),
/no topic summaries/,
);
});
test("throws when the model returns nothing usable", async () => {
await assert.rejects(
() => synthesizeEpisodeOverview(record(), { provider: fakeProvider(" ") }),
/empty synthesis result/,
);
});
});
describe("getOrCreateEpisodeOverview", () => {
test("cache hit returns stored overview without calling the provider", async () => {
const sink = {};
const res = await getOrCreateEpisodeOverview({
record: record({ digestOverview: "Already done." }),
provider: fakeProvider("fresh", sink),
save: false,
});
assert.equal(res.cached, true);
assert.equal(res.overview, "Already done.");
assert.equal(sink.calls || 0, 0);
});
test("cache miss synthesizes (save:false skips disk)", async () => {
const sink = {};
const res = await getOrCreateEpisodeOverview({
record: record(),
provider: fakeProvider("A fresh overview.", sink),
save: false,
});
assert.equal(res.cached, false);
assert.equal(res.overview, "A fresh overview.");
assert.equal(sink.calls, 1);
});
});
describe("selectDigestEpisodes", () => {
const at = (iso, id) => ({ id, title: id, type: "youtube", url: "", createdAt: iso });
const sessions = [
at("2026-06-14T00:00:00.000Z", "old"),
at("2026-06-15T09:00:00.000Z", "a"),
at("2026-06-15T10:00:00.000Z", "b"),
];
const watermark = new Date("2026-06-15T08:00:00.000Z").getTime();
test("keeps only recaps created after the watermark, oldest first", () => {
const { episodes, total, overflow } = selectDigestEpisodes(sessions, watermark);
assert.deepEqual(episodes.map((e) => e.id), ["a", "b"]);
assert.equal(total, 2);
assert.equal(overflow, 0);
});
test("caps the list and reports the overflow", () => {
const many = Array.from({ length: 13 }, (_, i) =>
at(`2026-06-15T1${i % 10}:00:00.000Z`, `e${i}`),
);
const { episodes, overflow, total } = selectDigestEpisodes(many, 0, 10);
assert.equal(episodes.length, 10);
assert.equal(overflow, 3);
assert.equal(total, 13);
});
test("empty / malformed-date inputs", () => {
assert.deepEqual(selectDigestEpisodes([], watermark).episodes, []);
assert.deepEqual(selectDigestEpisodes(null, watermark).episodes, []);
assert.deepEqual(
selectDigestEpisodes([at("not-a-date", "x")], 0).episodes,
[],
);
});
test("null watermark treats everything as fresh", () => {
assert.equal(selectDigestEpisodes(sessions, null).total, 3);
});
});
describe("nextDigestWatermark", () => {
const t = (iso) => new Date(iso).getTime();
const e1 = "2026-06-15T09:00:00.000Z";
const e2 = "2026-06-15T10:00:00.000Z";
const e3 = "2026-06-15T11:00:00.000Z";
test("all sent → newest sent createdAt", () => {
assert.equal(nextDigestWatermark([e1, e2, e3], []), t(e3));
});
test("never advances past the oldest failure (so it's retried)", () => {
// sent e1 & e3, e2 failed → watermark just before e2, NOT now/e3.
assert.equal(nextDigestWatermark([e1, e3], [e2]), t(e2) - 1);
});
test("failures newer than everything sent don't pull the watermark back", () => {
assert.equal(nextDigestWatermark([e1, e2], [e3]), t(e2));
});
test("nothing sent → null (caller must not advance)", () => {
assert.equal(nextDigestWatermark([], [e1]), null);
assert.equal(nextDigestWatermark(null, null), null);
});
});
describe("scopeForUser", () => {
test("admin keeps the owner scope; everyone else is their id", () => {
assert.equal(scopeForUser({ id: "u1", is_admin: 1 }), "owner");
assert.equal(scopeForUser({ id: "u1", is_admin: 0 }), "u1");
});
});
describe("renderDigestEmail", () => {
const episodes = [
{ title: "First", type: "podcast", url: "https://x/1", overview: "Ov one." },
{ title: "Second", type: "youtube", url: "", overview: "Ov two." },
];
test("subject reflects count; body carries overviews + unsubscribe link", () => {
const m = renderDigestEmail({
episodes,
overflowCount: 3,
manageUrl: "https://recaps.cc/",
unsubscribeUrl: "https://recaps.cc/api/digest/unsubscribe?token=tok",
});
assert.match(m.subject, /2 new recaps/);
assert.match(m.html, /Ov one\./);
assert.match(m.html, /unsubscribe\?token=tok/);
assert.match(m.html, /3 more/);
assert.match(m.text, /Unsubscribe: https:\/\/recaps\.cc/);
});
test("singular subject for one recap", () => {
const m = renderDigestEmail({
episodes: [episodes[0]],
manageUrl: "https://recaps.cc/",
unsubscribeUrl: "https://recaps.cc/u",
});
assert.match(m.subject, /1 new recap\b/);
});
});