import { env } from "cloudflare:workers"; import { all, chunks, first, placeholders, run } from "../db"; import { hmacSha256Hex, randomId, timingSafeEqualStrings } from "../crypto"; import { fromBase64Url, toBase64Url } from "~/lib/base64url"; import { publicUrl } from "../events/emit"; import { CATEGORY_INFO, NOTIFICATION_CATEGORIES } from "~/lib/notification-categories"; import type { DigestMode, NotificationCategory } from "~/lib/notification-categories"; import type { EmailFooter } from "../email"; export type CategoryPref = { web: boolean; email: boolean; digest: DigestMode }; /** Unsubscribe targets: a category, or the legacy participating/watching email switches. */ export type UnsubscribeTarget = NotificationCategory | "participating" | "watching"; const HOUR_MS = 60 * 60 * 1000; const encoder = new TextEncoder(); const decoder = new TextDecoder(); /** Per-recipient email budget per hour; overflow is folded into the next digest instead of being dropped. */ export const EMAILS_PER_HOUR = 20; function defaults(category: NotificationCategory): CategoryPref { const info = CATEGORY_INFO[category]; return { web: info.web, email: info.email, digest: "off" }; } type PrefRow = { user_id: string; category: string; web: number; email: number; digest: string }; function fromRow(category: NotificationCategory, row: PrefRow | undefined): CategoryPref { if (!row) return defaults(category); const digest = row.digest === "daily" && CATEGORY_INFO[category].digestable ? "daily" : "off"; return { web: row.web === 1, email: row.email === 1, digest }; } export async function loadCategoryPrefs(userId: string): Promise> { const rows = await all("SELECT * FROM notification_category_prefs WHERE user_id = ?", userId); const byCategory = new Map(rows.map((row) => [row.category, row])); const out = {} as Record; for (const category of NOTIFICATION_CATEGORIES) out[category] = fromRow(category, byCategory.get(category)); return out; } export async function categoryPrefsFor( userIds: string[], category: NotificationCategory, ): Promise> { const out = new Map(); for (const part of chunks(userIds)) { const rows = await all( `SELECT * FROM notification_category_prefs WHERE category = ? AND user_id IN (${placeholders(part.length)})`, category, ...part, ); const byUser = new Map(rows.map((row) => [row.user_id, row])); for (const userId of part) out.set(userId, fromRow(category, byUser.get(userId))); } return out; } export async function saveCategoryPref( userId: string, category: NotificationCategory, pref: CategoryPref, ): Promise { const digest = CATEGORY_INFO[category].digestable ? pref.digest : "off"; await run( `INSERT INTO notification_category_prefs (user_id, category, web, email, digest, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, category) DO UPDATE SET web = excluded.web, email = excluded.email, digest = excluded.digest, updated_at = excluded.updated_at`, userId, category, pref.web ? 1 : 0, pref.email ? 1 : 0, digest, Date.now(), ); } const UNSUBSCRIBE_TOKEN_TTL_MS = 30 * 24 * HOUR_MS; const UNSUBSCRIBE_KEY_LABEL = "gitfor-unsubscribe-v1"; /** `[userId, target, issuedAt]` */ const TOKEN_FIELDS = 3; /** Email that must never be silenced from a link in an email: a leaked message would otherwise disable security mail. */ export function canUnsubscribe(target: UnsubscribeTarget): boolean { return target !== "security_alert"; } /** * Per-user secret mixed into the signing key; deleting the row (e.g. on password change) revokes every * unsubscribe link already sent to that user. */ async function unsubscribeSecret(userId: string, create: boolean): Promise { const existing = await first<{ token: string }>( "SELECT token FROM notification_unsubscribe_tokens WHERE user_id = ?", userId, ); if (existing || !create) return existing?.token ?? null; await run( "INSERT OR IGNORE INTO notification_unsubscribe_tokens (user_id, token, created_at) VALUES (?, ?, ?)", userId, randomId(32), Date.now(), ); const row = await first<{ token: string }>( "SELECT token FROM notification_unsubscribe_tokens WHERE user_id = ?", userId, ); return row?.token ?? null; } async function unsubscribeKey(userSecret: string): Promise { return hmacSha256Hex(env.SECRETS_KEY || UNSUBSCRIBE_KEY_LABEL, `${UNSUBSCRIBE_KEY_LABEL}:${userSecret}`); } export async function revokeUnsubscribeTokens(userId: string): Promise { await run("DELETE FROM notification_unsubscribe_tokens WHERE user_id = ?", userId); } /** `base64url(userId.target.issuedAt).hmac` — bound to one user and one category, valid for 30 days. */ async function signUnsubscribeToken(userId: string, target: UnsubscribeTarget): Promise { const secret = await unsubscribeSecret(userId, true); if (!secret) return null; const payload = toBase64Url(encoder.encode(JSON.stringify([userId, target, Date.now()]))); const signature = await hmacSha256Hex(await unsubscribeKey(secret), payload); return `${payload}.${signature}`; } /** Returns the user id when the token is well-formed, unexpired, signed for this user and for `target`. */ export async function userForUnsubscribeToken(token: string, target: UnsubscribeTarget): Promise { const [payload, signature, ...rest] = token.split("."); if (!payload || !signature || rest.length > 0) return null; let parsed: unknown; try { parsed = JSON.parse(decoder.decode(fromBase64Url(payload))); } catch { return null; } if (!Array.isArray(parsed) || parsed.length !== TOKEN_FIELDS) return null; const [userId, tokenTarget, issuedAt] = parsed as unknown[]; if (typeof userId !== "string" || typeof tokenTarget !== "string" || typeof issuedAt !== "number") return null; if (tokenTarget !== target) return null; if ( !Number.isFinite(issuedAt) || issuedAt > Date.now() + HOUR_MS || issuedAt + UNSUBSCRIBE_TOKEN_TTL_MS < Date.now() ) { return null; } const secret = await unsubscribeSecret(userId, false); if (!secret) return null; const expected = await hmacSha256Hex(await unsubscribeKey(secret), payload); return timingSafeEqualStrings(expected, signature) ? userId : null; } const REASON_COPY: Record = { participating: "You are receiving this because you are participating in this thread.", watching: "You are receiving this because you are watching this repository.", ...(Object.fromEntries( NOTIFICATION_CATEGORIES.map((category) => [ category, `You are receiving this because ${CATEGORY_INFO[category].label.toLowerCase()} emails are on.`, ]), ) as Record), }; export async function emailFooter(userId: string, target: UnsubscribeTarget): Promise { const token = canUnsubscribe(target) ? await signUnsubscribeToken(userId, target) : null; return { reason: REASON_COPY[target], manageUrl: publicUrl("/settings/notifications"), unsubscribeUrl: token ? publicUrl(`/email/unsubscribe?${new URLSearchParams({ token, category: target })}`) : null, }; } /** Turns off email for one unsubscribe target; web notifications are left alone. */ export async function unsubscribe(userId: string, target: UnsubscribeTarget): Promise { if (!canUnsubscribe(target)) throw new Error(`${target} emails cannot be unsubscribed by link`); if (target === "participating" || target === "watching") { const column = target === "participating" ? "email_participating" : "email_watching"; await run( `INSERT INTO notification_settings (user_id, ${column}, updated_at) VALUES (?, 0, ?) ON CONFLICT (user_id) DO UPDATE SET ${column} = 0, updated_at = excluded.updated_at`, userId, Date.now(), ); return; } const current = (await categoryPrefsFor([userId], target)).get(userId) ?? defaults(target); await saveCategoryPref(userId, target, { ...current, email: false }); } /** Counts one email against the recipient's hourly budget; false means the budget is spent. */ export async function takeEmailBudget(userId: string): Promise { const window = Math.floor(Date.now() / HOUR_MS) * HOUR_MS; const row = await first<{ sent: number }>( `INSERT INTO notification_email_counters (user_id, window_start, sent) VALUES (?, ?, 1) ON CONFLICT (user_id, window_start) DO UPDATE SET sent = sent + 1 RETURNING sent`, userId, window, ); return (row?.sent ?? 1) <= EMAILS_PER_HOUR; } export async function pruneEmailCounters(): Promise { await run("DELETE FROM notification_email_counters WHERE window_start < ?", Date.now() - 2 * HOUR_MS); }