import nodemailer from 'nodemailer'; // Defaults written for Gmail. An environment on a different provider — QA is on // Brevo — has to set host, port and SMTP_SECURE explicitly rather than // inheriting these, and getting that wrong fails at send time rather than at // boot. See #64 on validating this at startup instead. // #260 put the first awaited send on a user-facing request path (the admin // creating an upload link). nodemailer's defaults are two minutes to connect // and ten minutes on the socket, which is fine for a fire-and-forget send but // is not a bound anyone waiting on a response can live with: the link row and // its token are already committed by the time sendMail is called, the token // is shown exactly once, and a request that hangs long enough for the browser // or reverse proxy to give up first loses it for good. Five seconds each is // long enough for a reachable host and short enough that a dead one fails // fast, leaving the admin with the "not emailed" warning and a link they can // still copy, instead of a stuck spinner and a token nobody ever saw. const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST || 'smtp.gmail.com', port: parseInt(process.env.SMTP_PORT || '465', 10), secure: process.env.SMTP_SECURE !== 'false', auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }, connectionTimeout: 5000, greetingTimeout: 5000, socketTimeout: 5000 }); interface ParsedAddress { local: string; domain: string; } // Lowercased, and with any `+suffix` removed from the local part. Returns null // for anything that is not a usable address, so a caller can refuse rather than // compare nonsense. function parseAddress(address: string): ParsedAddress | null { const trimmed = address.trim().toLowerCase(); const at = trimmed.lastIndexOf('@'); // Needs something on both sides of a single trailing @. if (at <= 0 || at === trimmed.length - 1) { return null; } const localWithSuffix = trimmed.slice(0, at); return { // split always yields at least one element, so this cannot actually be // undefined — but String.split's type cannot express that. local: localWithSuffix.split('+')[0] ?? localWithSuffix, domain: trimmed.slice(at + 1) }; } /** * Whether this environment is permitted to email this recipient. * * `allowlist` is the raw MAIL_ALLOWLIST value: a comma-separated list where an * entry is either a full address, which also covers its `+suffix` variants, or * `@domain`, which covers every mailbox there. * * Undefined means unrestricted, which is production — it must be able to mail * real customers. Present but empty means refuse everyone: someone writing * `MAIL_ALLOWLIST=` is expressing an intent to restrict, and reading that as * "no restriction" would turn a typo into an outbound mail incident. * * Comparison is by exact equality on both halves, never a suffix test, so a * lookalike domain ending in an allowed one does not get through. * * Exported for its unit test. This function is the entire safety property of * mail in a non-production environment, and it is pure, so it is worth testing * directly rather than through a send. */ export function isAllowedRecipient(to: string, allowlist: string | undefined): boolean { if (allowlist === undefined) { return true; } const entries = allowlist .split(',') .map((entry) => entry.trim().toLowerCase()) .filter((entry) => entry !== ''); if (entries.length === 0) { return false; } const recipient = parseAddress(to); if (!recipient) { return false; } return entries.some((entry) => { if (entry.startsWith('@')) { return recipient.domain === entry.slice(1); } const allowed = parseAddress(entry); return allowed !== null && allowed.local === recipient.local && allowed.domain === recipient.domain; }); } /** * What a send attempt actually did. * * `sendMail` returns early in two cases that used to be indistinguishable from * success — no SMTP credentials, and a recipient outside MAIL_ALLOWLIST — which * meant a caller could report "emailed" for a message nobody would ever * receive. QA restricts delivery by design, so that was not a hypothetical: it * is the normal case there. See #260. */ export type MailOutcome = 'sent' | 'skipped-unconfigured' | 'skipped-blocked'; export async function sendMail(to: string, subject: string, html: string): Promise { if (!process.env.SMTP_USER || !process.env.SMTP_PASSWORD) { console.warn(`SMTP not configured — skipping email to ${to}: "${subject}"`); return 'skipped-unconfigured'; } // Guarded here rather than at the four call sites, so every sender is covered // by construction and a fifth added later cannot bypass it by forgetting. // // Skipping rather than throwing, and reporting the skip through MailOutcome // rather than pretending nothing happened: three of the callers already // swallow send failures into a log, so throwing would mostly be caught and // logged anyway while risking a 500 on the signup path. The flow under test // finishes, and both the log and the returned outcome say why no mail // arrived — which is the part that was missing when QA was simply muted. if (!isAllowedRecipient(to, process.env.MAIL_ALLOWLIST)) { console.warn(`[mail-blocked] ${to} is not on MAIL_ALLOWLIST — skipping "${subject}"`); return 'skipped-blocked'; } await transporter.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to, subject, html }); return 'sent'; }