import { all, batch, first, run, stmt } from "./db"; import { accountDb } from "./scoped-db"; import { forgetLocator } from "./scoped-db/locators"; import { badRequest, conflict, notFoundError } from "./errors"; import { notifySecurityEvent, sendEmailVerification } from "./account-security"; import { invalidateUserSignatures } from "./security/signatures"; import { promoteAdminEmailOnVerify, reclaimUnverifiedEmail } from "./accounts"; import { userChanged } from "./user-row"; const MAX_EMAILS = 20; export type AccountEmail = { email: string; primary: boolean; verified: boolean; createdAt: number | null }; type PrimaryRow = { email: string; email_verified_at: number | null }; async function primaryOf(userId: string): Promise { const row = await first("SELECT email, email_verified_at FROM users WHERE account_id = ?", userId); if (!row) throw notFoundError("Account not found"); return row; } function same(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } export async function listAccountEmails(userId: string): Promise { const primary = await primaryOf(userId); const others = await all<{ email: string; verified_at: number | null; created_at: number }>( "SELECT email, verified_at, created_at FROM user_emails WHERE user_id = ? ORDER BY created_at", userId, ); return [ { email: primary.email, primary: true, verified: primary.email_verified_at !== null, createdAt: null }, ...others.map((row) => ({ email: row.email, primary: false, verified: row.verified_at !== null, createdAt: row.created_at, })), ]; } /** Adds an address as pending and mails a confirmation link; it is only usable once confirmed. */ export async function addAccountEmail(userId: string, email: string): Promise { const primary = await primaryOf(userId); if (same(primary.email, email)) throw conflict("That is already your primary email address"); const existing = await first("SELECT 1 FROM user_emails WHERE user_id = ? AND email = ?", userId, email); if (existing) throw conflict("That address is already on your account"); const count = await first<{ count: number }>("SELECT COUNT(*) AS count FROM user_emails WHERE user_id = ?", userId); if ((count?.count ?? 0) >= MAX_EMAILS) throw badRequest(`You can add at most ${MAX_EMAILS} email addresses`); const verifiedElsewhere = await first( `SELECT 1 FROM users WHERE email = ?1 AND account_id != ?2 AND email_verified_at IS NOT NULL UNION ALL SELECT 1 FROM user_emails WHERE email = ?1 AND user_id != ?2 AND verified_at IS NOT NULL`, email, userId, ); if (verifiedElsewhere) throw conflict("That email address is already in use"); await run( "INSERT INTO user_emails (user_id, email, verified_at, created_at) VALUES (?, ?, NULL, ?)", userId, email, Date.now(), ); await sendEmailVerification(userId, email); await notifySecurityEvent(userId, `${email} was added to your account and is waiting to be confirmed`); } export async function resendAccountEmailVerification(userId: string, email: string): Promise { const primary = await primaryOf(userId); if (same(primary.email, email)) { if (primary.email_verified_at !== null) throw badRequest("That address is already verified"); await sendEmailVerification(userId, primary.email); return; } const row = await first<{ email: string; verified_at: number | null }>( "SELECT email, verified_at FROM user_emails WHERE user_id = ? AND email = ?", userId, email, ); if (!row) throw notFoundError("That address is not on your account"); if (row.verified_at !== null) throw badRequest("That address is already verified"); await sendEmailVerification(userId, row.email); } export async function removeAccountEmail(userId: string, email: string): Promise { const primary = await primaryOf(userId); if (same(primary.email, email)) throw badRequest("Make another address primary before removing this one"); const removed = await first<{ email: string; verified_at: number | null }>( "DELETE FROM user_emails WHERE user_id = ? AND email = ? RETURNING email, verified_at", userId, email, ); if (!removed) throw notFoundError("That address is not on your account"); // Reset links may have gone to the removed address; removal is how an owner locks out whoever holds that inbox. const spent = await accountDb(userId).all<{ token_hash: string }>( `DELETE FROM auth_tokens WHERE user_id = ? AND ((kind IN ('email-verify', 'email-verify-spent') AND json_extract(payload, '$.email') = ? COLLATE NOCASE) OR kind = 'password-reset') RETURNING token_hash`, userId, removed.email, ); await Promise.all(spent.map((row) => forgetLocator("auth_token", row.token_hash))); if (removed.verified_at !== null) await invalidateUserSignatures(userId); await notifySecurityEvent( userId, `${removed.email} was removed from your account`, removed.verified_at !== null ? [removed.email] : [], ); } /** * Swaps a verified address into users.email. A verified former primary stays on the account as another * address; an unverified one is dropped since nobody proved they own it. */ export async function setPrimaryAccountEmail(userId: string, email: string): Promise { const primary = await primaryOf(userId); if (same(primary.email, email)) return; const row = await first<{ email: string; verified_at: number | null }>( "SELECT email, verified_at FROM user_emails WHERE user_id = ? AND email = ?", userId, email, ); if (!row) throw notFoundError("That address is not on your account"); if (row.verified_at === null) throw badRequest("Verify that address before making it primary"); const taken = await first("SELECT 1 FROM users WHERE email = ? AND account_id != ?", row.email, userId); if (taken && !(await reclaimUnverifiedEmail(row.email))) throw conflict("That email address is already in use"); const now = Date.now(); await batch([ stmt("DELETE FROM user_emails WHERE user_id = ? AND email = ?", userId, row.email), ...(primary.email_verified_at !== null ? [ stmt( "INSERT INTO user_emails (user_id, email, verified_at, created_at) VALUES (?, ?, ?, ?)", userId, primary.email, primary.email_verified_at, now, ), ] : []), stmt("UPDATE users SET email = ?, email_verified_at = ? WHERE account_id = ?", row.email, row.verified_at, userId), ]); await userChanged(userId); await promoteAdminEmailOnVerify(userId); await invalidateUserSignatures(userId); await notifySecurityEvent( userId, `your primary email address was changed from ${primary.email} to ${row.email}`, primary.email_verified_at !== null ? [primary.email] : [], ); }