Files
redefined-designs/backend/src/mailer.ts
T
bermudalambandClaude Opus 5 0dd4ac36ff fix(mail): bound the transporter's timeouts, and fix a now-stale comment (#260)
adminUploadLinks.ts awaits sendMail on the admin's request path, the first awaited send on a user-facing request in this codebase, but the transporter in mailer.ts set no connectionTimeout, greetingTimeout or socketTimeout. nodemailer's defaults then apply: two minutes to connect, ten minutes on the socket. If the SMTP host is unreachable in a way that drops packets rather than refusing, the link row and its token are already committed by the time sendMail is called, the response hangs for up to two minutes, the browser or reverse proxy gives up first, and the token — shown exactly once and unrecoverable — is never rendered. That is the link being lost in exactly the way this feature's central invariant forbids.

All three timeouts are now set to 5000ms, with a comment explaining why a send on a request path has to fail fast rather than inherit nodemailer's fire-and-forget defaults. Five seconds is generous for a reachable host and short enough that a dead one fails while the admin is still willing to wait, leaving them the "not emailed" warning and a link they can still copy instead of a stuck spinner and a token nobody ever saw.

Also corrects the comment directly above the skipped-blocked return, which said "returning as though it sent" — true before #260, and precisely backwards now that the outcome is reported through MailOutcome rather than swallowed. Reworded to describe what the code actually does today, keeping the explanation of why it skips rather than throws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:30:49 -05:00

140 lines
5.5 KiB
TypeScript
Executable File

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<MailOutcome> {
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';
}