// Self-serve subscription expiry reminders (multi-mode / cloud only). // // The relay owns each user's prepaid-period expiry (subscription source of // truth); Recaps owns the email address + the recaps.cc SMTP transport. So // a daily scan here asks the relay who's expiring (or just lapsed), maps // user_id → users.email, and sends a reminder via the same sendMail() the // magic-link flow uses. The subscription_reminders table dedups so each // (user, period, kind) email goes out at most once; a renewal changes the // expiry instant, which re-arms a fresh set of reminders for the new period. // // Self-gating: no-ops unless SMTP is configured, the public URL is set, and // the relay is reachable. Safe to start unconditionally in multi mode. import { getDb } from "./db.js"; import { sendMail, isSmtpReady } from "./smtp.js"; import { renderSubscriptionReminderEmail } from "./email-template.js"; import { getRelayExpiringSubscriptions } from "./providers/relay.js"; import { getConfigSnapshot } from "./config.js"; // Reminder thresholds, in days before expiry. Each maps to a distinct // `kind` so a user gets one heads-up at each crossing (deduped by kind). const UPCOMING_THRESHOLDS = [ { days: 7, kind: "upcoming_7d" }, { days: 1, kind: "upcoming_1d" }, ]; const LAPSED_KIND = "lapsed"; // Only email recently-lapsed users; after this they age out of the relay // window (and we never re-send the same period's lapsed notice anyway). const LAPSED_WINDOW_DAYS = 3; const SCAN_INTERVAL_MS = 12 * 60 * 60 * 1000; // twice a day const BOOT_DELAY_MS = 90 * 1000; // first scan ~90s after boot let scanning = false; let scheduled = false; // Decide which single reminder kind (if any) applies to a subscription // right now. Pure — exported for testing. `sub` is a relay row: // { tier, expires_at, expired, days_left }. export function reminderKindFor( sub, { upcoming = UPCOMING_THRESHOLDS, lapsedWindowDays = LAPSED_WINDOW_DAYS } = {}, ) { if (!sub || typeof sub.days_left !== "number") return null; const daysLeft = sub.days_left; if (sub.expired) { // Recently lapsed → a single lapsed notice (days_left is <= 0). return daysLeft >= -lapsedWindowDays ? LAPSED_KIND : null; } // Upcoming: smallest threshold the sub has crossed wins, so daysLeft=1 // sends 'upcoming_1d' while daysLeft in (1, 7] sends 'upcoming_7d'. const sorted = [...upcoming].sort((a, b) => a.days - b.days); for (const t of sorted) { if (daysLeft <= t.days) return t.kind; } return null; } function alreadySent(db, userId, periodIso, kind) { return !!db .prepare( "SELECT 1 FROM subscription_reminders WHERE user_id=? AND period_expires_at=? AND kind=?", ) .get(userId, periodIso, kind); } function recordSent(db, userId, periodIso, kind) { db.prepare( "INSERT OR IGNORE INTO subscription_reminders (user_id, period_expires_at, kind, sent_at) VALUES (?,?,?,?)", ).run(userId, periodIso, kind, Date.now()); } function maskEmail(email) { return String(email).replace(/^(.).*(@.*)$/, "$1***$2"); } // One scan pass. Returns a small summary object; never throws (logs + // returns a {skipped} reason instead) so the scheduler stays alive. export async function runReminderScan({ force = false } = {}) { if (scanning && !force) return { skipped: "already_running" }; scanning = true; try { 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 maxDays = Math.max(1, ...UPCOMING_THRESHOLDS.map((t) => t.days)); const report = await getRelayExpiringSubscriptions({ withinDays: maxDays, lapsedDays: LAPSED_WINDOW_DAYS, }); if (!report || !Array.isArray(report.subscriptions)) { return { skipped: "relay_unavailable" }; } const db = getDb(); const manageUrl = `${publicUrl}/?renew=1`; let sent = 0; let skipped = 0; for (const sub of report.subscriptions) { const kind = reminderKindFor(sub); if (!kind) { skipped++; continue; } const periodIso = sub.expires_at; if (!periodIso || alreadySent(db, sub.user_id, periodIso, kind)) { skipped++; continue; } const u = db .prepare("SELECT email FROM users WHERE id=?") .get(sub.user_id); const email = (u?.email || "").trim(); if (!email) { // No local user for this id (e.g. another instance's tenant) — skip. skipped++; continue; } const message = renderSubscriptionReminderEmail({ brandName: "Recaps", tier: sub.tier, expiresAt: sub.expires_at, daysLeft: sub.days_left, kind, manageUrl, }); try { await sendMail({ to: email, subject: message.subject, text: message.text, html: message.html, }); recordSent(db, sub.user_id, periodIso, kind); sent++; console.log( `[reminders] sent ${kind} to ${maskEmail(email)} (${sub.tier}, ${sub.days_left}d)`, ); } catch (err) { // Don't record on failure → retried next scan. console.warn( `[reminders] sendMail failed for ${sub.user_id}: ${err?.message || err}`, ); skipped++; } } if (sent) { console.log(`[reminders] scan complete: ${sent} sent, ${skipped} skipped`); } return { sent, skipped }; } catch (err) { console.warn(`[reminders] scan error: ${err?.message || err}`); return { skipped: "error", error: err?.message || String(err) }; } finally { scanning = false; } } // Start the daily-ish scan loop. Idempotent. Self-gates inside the scan, // so it's safe to call whenever multi mode boots. export function startReminderScheduler() { if (scheduled) return; scheduled = true; setTimeout(() => { runReminderScan().catch(() => {}); }, BOOT_DELAY_MS); setInterval(() => { runReminderScan().catch(() => {}); }, SCAN_INTERVAL_MS); console.log("[reminders] expiry-reminder scheduler started"); }