import { env } from "cloudflare:workers"; import { accountDb } from "../scoped-db"; import { forEachScope } from "../account-scope"; import { hmacSha256Hex, randomId, timingSafeEqualStrings } from "../crypto"; import { fromBase64Url, toBase64Url } from "~/lib/base64url"; import { publicUrl } from "../events/emit"; import { takeInboxEmailBudget } from "../user-inbox"; 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 accountDb(userId).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(); const ids = [...new Set(userIds)]; const rows = await forEachScope(ids, (userId) => accountDb(userId).first( "SELECT * FROM notification_category_prefs WHERE category = ? AND user_id = ?", category, userId, ), ); ids.forEach((userId, index) => out.set(userId, fromRow(category, rows[index] ?? undefined))); return out; } export async function saveCategoryPref( userId: string, category: NotificationCategory, pref: CategoryPref, ): Promise { const digest = CATEGORY_INFO[category].digestable ? pref.digest : "off"; await accountDb(userId).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 db = accountDb(userId); const existing = await db.first<{ token: string }>( "SELECT token FROM notification_unsubscribe_tokens WHERE user_id = ?", userId, ); if (existing || !create) return existing?.token ?? null; await db.run( "INSERT OR IGNORE INTO notification_unsubscribe_tokens (user_id, token, created_at) VALUES (?, ?, ?)", userId, randomId(32), Date.now(), ); const row = await db.first<{ token: string }>( "SELECT token FROM notification_unsubscribe_tokens WHERE user_id = ?", userId, ); return row?.token ?? null; } /** No constant fallback: a missing key would make every unsubscribe link forgeable from the user secret alone. */ async function unsubscribeKey(userSecret: string): Promise { if (!env.SECRETS_KEY) throw new Error("SECRETS_KEY is not configured"); return hmacSha256Hex(env.SECRETS_KEY, `${UNSUBSCRIBE_KEY_LABEL}:${userSecret}`); } export async function revokeUnsubscribeTokens(userId: string): Promise { await accountDb(userId).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 accountDb(userId).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 { return takeInboxEmailBudget(userId, EMAILS_PER_HOUR, HOUR_MS); }