import { env } from "cloudflare:workers"; import { invalidateUnread } from "../user-inbox"; import { all, chunks, placeholders } from "../db"; import { accountDb } from "../scoped-db"; import { randomId } from "../crypto"; import { CAPS } from "../limits"; import { emailTemplates, queueEmail } from "../email"; import { publicUrl } from "../events/emit"; import type { DirectNotifyMessage, QueueMessage } from "../events/emit"; import { isNotificationCategory } from "~/lib/notification-categories"; import type { NotificationCategory } from "~/lib/notification-categories"; import { categoryPrefsFor, emailFooter, takeEmailBudget } from "./prefs"; import type { CategoryPref } from "./prefs"; import { ignoredWatchers, recipientHasAccess } from "./recipients"; import { isDevForge } from "./dev-mail"; const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; const MAX_DETAILS = 6; /** Daily digest window; dev forges may shorten it with NOTIFY_DIGEST_DELAY_MS so tests can run end to end. */ function digestDelayMs(): number { const override = Number(env.NOTIFY_DIGEST_DELAY_MS); return isDevForge() && env.NOTIFY_DIGEST_DELAY_MS !== "" && Number.isFinite(override) && override >= 0 ? override : DAY_MS; } const MAX_TITLE = 200; const DIGEST_RETRY_MS = 5 * 60 * 1000; /** Categories flushed per alarm; anything left re-arms the alarm. */ const DIGEST_CATEGORIES_PER_RUN = 20; const DIGEST_ITEMS_PER_EMAIL = 50; /** Categories where a repo's "ignore" watch level also mutes the notification; security and billing never mute. */ const MUTED_BY_IGNORE = new Set(["ci", "ci_watching", "deploy", "mergefix"]); export type NotifyInput = Omit & { recipients: (string | null | undefined)[]; /** Deterministic key for events that may be emitted more than once (e.g. a retried Workflow step). */ dedupeKey?: string; }; /** Queues a categorised notification; recipients are de-duplicated and capped, access is re-checked at send time. */ export async function notifyUsers(input: NotifyInput): Promise { const recipients = [...new Set(input.recipients.filter((id): id is string => Boolean(id)))].slice( 0, CAPS.notificationRecipients, ); if (recipients.length === 0) return; const { dedupeKey, ...rest } = input; const message: QueueMessage = { ...rest, kind: "notify.direct", id: dedupeKey ? `${input.category}:${dedupeKey}` : randomId(), recipients, title: input.title.slice(0, MAX_TITLE), details: input.details.slice(0, MAX_DETAILS), }; await env.EVENTS.send(message); } /** Wraps a notification helper so any failure (lookups included) is logged and never breaks the caller. */ export function quiet( name: string, work: (...args: Args) => Promise, ): (...args: Args) => Promise { return async (...args) => { try { await work(...args); } catch (error) { console.error(`Notification ${name} failed`, error); } }; } /** Logs instead of throwing so a notification failure never fails the event that caused it. */ export function notifyQuietly(input: NotifyInput): Promise { return notifyUsers(input).catch((error: unknown) => console.error("Failed to queue notification", error)); } type UserRow = { account_id: string; email: string; email_verified_at: number | null }; async function verifiedEmails(userIds: string[]): Promise> { const out = new Map(); for (const part of chunks(userIds)) { const rows = await all( `SELECT account_id, email, email_verified_at FROM users WHERE account_id IN (${placeholders(part.length)})`, ...part, ); for (const row of rows) if (row.email_verified_at !== null) out.set(row.account_id, row.email); } return out; } async function queueDigestItem( userId: string, message: Pick, flushAfter: number, ): Promise { await accountDb(userId).run( `INSERT INTO notification_digest_items (id, user_id, repo_id, category, title, url, account_id, subject, access, flush_after, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, randomId(), userId, message.repoId, message.category, message.title, message.url, message.accountId, message.subject ?? null, message.access, flushAfter, Date.now(), ); } /** * Runs `deliver` at most once per (message, user) across queue retries: the marker is claimed first and * released if delivery throws, so only recipients that already succeeded are skipped on the next attempt. * Returns false when an earlier attempt already delivered. */ export async function deliverOnce(messageId: string, userId: string, deliver: () => Promise): Promise { const db = accountDb(userId); const claimed = await db.run( "INSERT OR IGNORE INTO notification_deliveries (message_id, user_id, created_at) VALUES (?, ?, ?)", messageId, userId, Date.now(), ); if (claimed.meta.changes === 0) return false; try { await deliver(); } catch (error) { await db.run("DELETE FROM notification_deliveries WHERE message_id = ? AND user_id = ?", messageId, userId); throw error; } return true; } /** Sends a rate-limited email; over budget, the email is deferred into an hourly catch-up digest. */ export async function sendBudgetedEmail( userId: string, to: string, send: () => Promise, deferred: Parameters[1] | null, ): Promise { if (await takeEmailBudget(userId)) { await send(); return; } if (deferred) await queueDigestItem(userId, deferred, Date.now() + HOUR_MS); else console.warn("Email budget exhausted; dropping email for", userId, to.replace(/^[^@]+/, "***")); } export async function deliverDirect(message: DirectNotifyMessage): Promise { const [prefs, emails, ignored] = await Promise.all([ categoryPrefsFor(message.recipients, message.category), verifiedEmails(message.recipients), message.repoId && MUTED_BY_IGNORE.has(message.category) ? ignoredWatchers(message.repoId) : Promise.resolve(new Set()), ]); const messageId = message.id ?? randomId(); let failed = false; for (const userId of message.recipients) { const pref = prefs.get(userId); if (!pref || ignored.has(userId)) continue; const email = pref.email ? (emails.get(userId) ?? null) : null; if (!pref.web && !email) continue; try { if (!(await recipientHasAccess(message, userId))) continue; try { await deliverOnce(messageId, userId, () => deliverTo(message, userId, pref, email)); } catch (error) { failed = true; throw error; } } catch (error) { console.error("Notification delivery failed for one recipient", message.category, error); } } if (failed) throw new Error("Some notification deliveries failed; retrying the rest"); } async function deliverTo( message: DirectNotifyMessage, userId: string, pref: CategoryPref, email: string | null, ): Promise { const now = Date.now(); if (pref.web) { await accountDb(userId).run( "INSERT INTO notifications (id, user_id, repo_id, reason, title, url, category, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", randomId(), userId, message.repoId, message.category, message.title, publicUrl(message.url), message.category, now, ); await invalidateUnread([userId]); } if (!email) return; if (pref.digest === "daily") { await queueDigestItem(userId, message, now + digestDelayMs()); return; } await sendBudgetedEmail( userId, email, async () => queueEmail( email, emailTemplates.category( message.category, message.title, message.details, message.url, await emailFooter(userId, message.category), ), ), message, ); } type DigestRow = { id: string; user_id: string; repo_id: string | null; account_id: string | null; subject: string | null; category: string; title: string; url: string; access: string; }; function isAccess(value: string): value is DirectNotifyMessage["access"] { return ["read", "admin", "security", "account", "advisory"].includes(value); } /** * Sends one digest email per due category, re-checking access per item. Run by the recipient's AccountState * alarm when its oldest digest item comes due (account-state/housekeeping.ts), never by a sweep over accounts. */ export async function flushAccountDigests(userId: string, now: number): Promise { const categories = await accountDb(userId).all<{ category: string }>( "SELECT DISTINCT category FROM notification_digest_items WHERE flush_after <= ? LIMIT ?", now, DIGEST_CATEGORIES_PER_RUN, ); for (const { category } of categories) { try { await flushGroup(userId, category, now); } catch (error) { console.error("Digest flush failed", category, error); // Push the category's due items back, so a lasting failure is retried every few minutes, not every alarm. await accountDb(userId) .run( "UPDATE notification_digest_items SET flush_after = ? WHERE user_id = ? AND category = ? AND flush_after <= ?", now + DIGEST_RETRY_MS, userId, category, now, ) .catch(() => undefined); } } } async function flushGroup(userId: string, category: string, now: number): Promise { const db = accountDb(userId); const items = await db.all( `SELECT * FROM notification_digest_items WHERE user_id = ? AND category = ? AND flush_after <= ? ORDER BY created_at LIMIT ?`, userId, category, now, DIGEST_ITEMS_PER_EMAIL, ); const visible: DigestRow[] = []; if (isNotificationCategory(category)) { for (const item of items) { if (!isAccess(item.access)) continue; const target = { access: item.access, repoId: item.repo_id, accountId: item.account_id, subject: item.subject }; if (await recipientHasAccess(target, item.user_id)) visible.push(item); } const to = (await verifiedEmails([userId])).get(userId); if (to && visible.length > 0) { await queueEmail(to, emailTemplates.digest(category, visible, await emailFooter(userId, category))); } } for (const part of chunks(items.map((item) => item.id))) { await db.run(`DELETE FROM notification_digest_items WHERE id IN (${placeholders(part.length)})`, ...part); } }