import "server-only"; import { createHash, randomBytes } from "node:crypto"; import { db, filter, findFirst, withLock } from "./db"; import { linkTo, MAIL_ENABLED, sendMail, sendMailLater, type Message } from "./mail"; import { ROLE_INFO, type OrgRole } from "./org-roles"; import { getPersonByLogin, getUserById, setPassword, type User } from "./users"; /* * The emails Vectrum sends (through lib/mail.ts), and the links in them that confirm an address * or set a new password. A link carries a random token; only its hash is kept (in email_tokens), * with the address it went to, so it stops working once the account's email changes. * * Notifications (invitations, access, security notices) only go to confirmed addresses: anyone can * sign up with someone else's. */ const LIFETIME_MS = { verify: 3 * 86400_000, reset: 60 * 60_000 }; /** At most one link of a kind per account in this time, so nobody can flood an inbox with them. */ const RESEND_MS = 60_000; type Purpose = keyof typeof LIFETIME_MS; const hash = (token: string) => createHash("sha256").update(token).digest("hex"); const tokens = async () => (await db()).collection("email_tokens"); /** A new token for a link, or null if one went out too recently. */ async function issueToken(user: User, purpose: Purpose) { return withLock(`email-tokens:${user.id}`, async () => { const now = Date.now(); const since = now - RESEND_MS; if (await findFirst("email_tokens", filter("user = {:id} && purpose = {:purpose} && created_at > {:since}", { id: user.id, purpose, since }), { fields: "id" })) { return null; } const collection = await tokens(); const expired = await collection.getFullList({ filter: filter("user = {:id} && expires_at < {:now}", { id: user.id, now }), fields: "id" }); await Promise.all(expired.map((r) => collection.delete(r.id))); const token = randomBytes(32).toString("base64url"); await collection.create({ user: user.id, purpose, email: user.email, token_hash: hash(token), expires_at: now + LIFETIME_MS[purpose], created_at: now }); return token; }); } /** Sends an email with a new link, or returns false if one went out too recently. A link that couldn't be sent doesn't count. */ async function sendLink(user: User, purpose: Purpose, message: (url: string) => Message) { const token = await issueToken(user, purpose); if (!token) return false; try { await sendMail(message(linkTo(`/login/${purpose === "verify" ? "verify-email" : "reset-password"}?token=${token}`))); } catch (e) { const row = await findFirst("email_tokens", filter("token_hash = {:hash}", { hash: hash(token) }), { fields: "id" }); if (row) await (await tokens()).delete(row.id); throw e; } return true; } /** The account a link is for, while it's good. */ async function tokenUser(token: string, purpose: Purpose) { if (!token || token.length > 100) return null; const row = await findFirst("email_tokens", filter("token_hash = {:hash} && purpose = {:purpose}", { hash: hash(token), purpose })); if (!row || row.expires_at < Date.now()) return null; const user = await getUserById(row.user); if (!user || user.email.toLowerCase() !== String(row.email).toLowerCase()) return null; return user; } const markVerified = async (userId: string) => void (await (await db()).collection("users").update(userId, { verified: true })); const logFailure = (what: string) => (e: unknown) => console.error(`[mail] couldn't send ${what}:`, e instanceof Error ? e.message : e); // --------------------------------------------------------------------------- // Confirming an address /** Emails a link that confirms the user's address. "wait" if one went out less than a minute ago. */ export async function sendVerificationEmail(user: User): Promise<"sent" | "wait" | "verified"> { if (user.verified) return "verified"; const sent = await sendLink(user, "verify", (url) => ({ to: user.email, subject: "Confirm your email address", body: [`Hi ${user.name || user.username},`, `Confirm that ${user.email} is the email address of your account, @${user.username}.`], action: { label: "Confirm email address", url }, footer: "The link works for 3 days. If you didn't sign up or change your email address, ignore this email.", })); return sent ? "sent" : "wait"; } /** After signing up or changing the address: sends the link without holding up the request. */ export function sendVerificationEmailLater(user: User) { if (MAIL_ENABLED) sendVerificationEmail(user).catch(logFailure("the verification email")); } /** * Confirms the address a link was sent to. The link keeps working until it expires: mail * scanners open links before people do. */ export async function confirmEmail(token: string) { const user = await tokenUser(token, "verify"); if (user && !user.verified) await markVerified(user.id); return user; } // --------------------------------------------------------------------------- // Resetting a forgotten password /** * Emails a link to set a new password if `login` (a username or email address) is someone's * account. It doesn't tell whether it is, and doesn't wait for the email, as how long that takes * would tell too. */ export async function requestPasswordReset(login: string) { const user = await getPersonByLogin(login); if (!user?.email) return; sendLink(user, "reset", (url) => ({ to: user.email, subject: "Reset your password", body: [`Hi ${user.name || user.username},`, `Someone asked to reset the password of your account, @${user.username}. Choose a new one here:`], action: { label: "Choose a new password", url }, footer: "The link works for one hour, once. If you didn't ask for it, ignore this email: your password stays the same.", })).catch(logFailure("the password reset email")); } /** The account a password reset link is for, while it's good. */ export const passwordResetUser = (token: string) => tokenUser(token, "reset"); /** Sets a new password with a reset link, which then stops working (as do the account's sessions). */ export async function resetPassword(token: string, password: string) { const user = await passwordResetUser(token); if (!user) return false; await setPassword(user.id, password); // The link reached them, so the address is theirs. if (!user.verified) await markVerified(user.id); notifyPasswordChanged({ ...user, verified: true }); return true; } // --------------------------------------------------------------------------- // Notifications function notify(user: User, message: Omit) { if (MAIL_ENABLED && user.verified && user.email) sendMailLater({ ...message, to: user.email }); } export function notifyInvitation(user: User, org: User, inviter: User, role: OrgRole) { notify(user, { subject: `@${inviter.username} invited you to join ${org.username}`, body: [ `Hi ${user.name || user.username},`, `@${inviter.username} invited you to join the organization ${org.name || org.username}, with the ${ROLE_INFO[role].title} role.`, "Accept or decline the invitation on your dashboard.", ], action: { label: "View invitation", url: linkTo("/settings/organizations") }, footer: `You're getting this because @${inviter.username} invited your account, @${user.username}.`, }); } export function notifyCollaborator(user: User, repo: { owner: string; name: string }, by: User) { notify(user, { subject: `You can now push to ${repo.owner}/${repo.name}`, body: [`Hi ${user.name || user.username},`, `@${by.username} added you as a collaborator to ${repo.owner}/${repo.name}. You can now push to it.`], action: { label: "Open repository", url: linkTo(`/${repo.owner}/${repo.name}`) }, footer: `You're getting this because @${by.username} gave your account, @${user.username}, access.`, }); } export function notifyPasswordChanged(user: User) { notify(user, { subject: "Your password was changed", body: [ `Hi ${user.name || user.username},`, `The password of your account, @${user.username}, was just changed, and its other sessions were signed out.`, "If that wasn't you, reset your password right away.", ], action: { label: "Reset password", url: linkTo("/login/forgot-password") }, }); } /** Tells the old address (`before`) that the account's email changed, in case someone else did it. */ export function notifyEmailChanged(before: User, newEmail: string) { notify(before, { subject: "Your email address was changed", body: [ `Hi ${before.name || before.username},`, `The email address of your account, @${before.username}, was changed from ${before.email} to ${newEmail}. Emails go to the new address from now on.`, "If that wasn't you, someone else has your account: tell whoever runs this server.", ], }); }