A real deployment could not send mail through an IP-authorized relay. Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was healthy and swaks sent from the same machine. The cause was the greeting, and it is generic rather than tied to any provider. An SMTP client identifies itself in EHLO, and with nothing configured Nodemailer derives that from the machine hostname — substituting the address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal state of a container. The relay closed the connection on it with a 421 before any message was offered, which is indistinguishable from the relay being down. EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It is validated as a fully qualified domain name — no scheme, port, brackets, whitespace, or control characters, and at least one dot, since a single unqualified label is exactly the input Nodemailer already discards. It is not a credential: it travels in the clear on every connection, so Doctor prints it where an operator can compare it against what the relay expects, and Doctor's probe performs the same greeting the runtime does. Absent preserves Nodemailer's behaviour exactly. It is deliberately not defaulted to the sender's domain: a machine whose hostname is already a valid FQDN may be greeting correctly, and some relays check that against reverse DNS, so replacing it silently would trade one deployment's bug for another's. The regression guard is a raw SMTP server that refuses any greeting but the expected one, because smtp-server exposes no EHLO hook and its earliest hook is never reached by a send-nothing probe. Co-Authored-By: Claude Opus 5 <[email protected]>
212 lines
9.5 KiB
TypeScript
212 lines
9.5 KiB
TypeScript
import { createTransport, type Transporter } from "nodemailer";
|
|
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
|
|
import type { MailProviderConfig } from "./config.js";
|
|
import { MailError } from "./message.js";
|
|
import type { MailProvider, MailSendResult } from "./provider.js";
|
|
|
|
/**
|
|
* Ekklesia's first real `MailProvider`, speaking SMTP.
|
|
*
|
|
* **It is not named after a service, and it must not become one.** No provider's hostname appears
|
|
* anywhere in this file, and a test asserts that. Everything specific to a deployment arrives as
|
|
* validated configuration, so the same code addresses a Google Workspace relay, Microsoft 365, a
|
|
* transactional provider's SMTP endpoint, a local Postfix, or anything else that speaks the
|
|
* protocol (ADR 0037).
|
|
*
|
|
* Server-only, and structurally so: this module imports Nodemailer and reaches the network, and no
|
|
* browser package can import it — a surface cannot import Core at all, which the boundary checker
|
|
* enforces.
|
|
*/
|
|
|
|
/**
|
|
* How long a single SMTP exchange may take before it is abandoned.
|
|
*
|
|
* These exist because the object-storage pass learned the lesson the hard way: a network client left
|
|
* on its library's defaults will wait indefinitely, and a provider that accepts a connection and
|
|
* then goes silent holds the calling request open forever. Nodemailer's own defaults are two
|
|
* minutes for a connection and ten for a socket, which is not a deadline anyone would choose for a
|
|
* password-reset email a person is waiting on.
|
|
*
|
|
* `socketTimeout` is the most generous of the three because it also covers transmitting the message
|
|
* body, and it is inactivity-based rather than total elapsed time, so a large message is never cut
|
|
* off for being large — only for going quiet. A wholly unresponsive server therefore surfaces as a
|
|
* structured `MAIL_PROVIDER_UNAVAILABLE` in well under a minute rather than never.
|
|
*/
|
|
export interface SmtpTransportBounds {
|
|
/** Milliseconds allowed to establish the TCP connection. */
|
|
readonly connectionTimeoutMs: number;
|
|
/** Milliseconds allowed for the server's opening greeting. */
|
|
readonly greetingTimeoutMs: number;
|
|
/** Milliseconds the socket may sit idle mid-exchange; resets whenever bytes move. */
|
|
readonly socketTimeoutMs: number;
|
|
}
|
|
|
|
const DEFAULT_TRANSPORT_BOUNDS: SmtpTransportBounds = Object.freeze({
|
|
connectionTimeoutMs: 10_000,
|
|
greetingTimeoutMs: 10_000,
|
|
socketTimeoutMs: 30_000,
|
|
});
|
|
|
|
export const SMTP_CONNECTION_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.connectionTimeoutMs;
|
|
export const SMTP_GREETING_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.greetingTimeoutMs;
|
|
export const SMTP_SOCKET_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.socketTimeoutMs;
|
|
|
|
interface SmtpLikeError {
|
|
readonly code?: unknown;
|
|
readonly responseCode?: unknown;
|
|
readonly rejected?: unknown;
|
|
}
|
|
|
|
function errorCode(error: unknown): string {
|
|
const candidate = error as SmtpLikeError | null;
|
|
return typeof candidate?.code === "string" ? candidate.code : "";
|
|
}
|
|
|
|
function responseCode(error: unknown): number | undefined {
|
|
const candidate = error as SmtpLikeError | null;
|
|
return typeof candidate?.responseCode === "number" ? candidate.responseCode : undefined;
|
|
}
|
|
|
|
/**
|
|
* Reduces any Nodemailer or socket failure to one of the contract's closed codes.
|
|
*
|
|
* **Nothing from the caught error survives this function** — not its message, not the server's
|
|
* response text, not the greeting, not the auth challenge, and not the envelope. SMTP servers
|
|
* routinely echo back what was sent to them, and an authentication failure's response can name the
|
|
* account; forwarding any of that verbatim is how a credential or a recipient reaches a log.
|
|
*/
|
|
export function translateMailError(error: unknown): MailError {
|
|
const code = errorCode(error);
|
|
const status = responseCode(error);
|
|
|
|
// A named recipient the server refused: the one failure a human can usually act on, so it keeps
|
|
// its own code rather than collapsing into a generic send failure.
|
|
if (code === "EENVELOPE") {
|
|
const rejected = (error as SmtpLikeError).rejected;
|
|
if (Array.isArray(rejected) && rejected.length > 0) {
|
|
return new MailError("MAIL_RECIPIENT_INVALID");
|
|
}
|
|
}
|
|
|
|
// Connection-level failures, including both timeouts configured above.
|
|
if (
|
|
code === "ECONNECTION" ||
|
|
code === "ESOCKET" ||
|
|
code === "ETIMEDOUT" ||
|
|
code === "EDNS" ||
|
|
code === "ECONNRESET" ||
|
|
code === "ECONNREFUSED" ||
|
|
code === "EHOSTUNREACH" ||
|
|
code === "ENOTFOUND"
|
|
) {
|
|
return new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
|
}
|
|
|
|
if (status !== undefined) {
|
|
// 4xx is SMTP's "try later" and 5xx its "no". Splitting on that is what lets a caller tell a
|
|
// transient outage from a message that will never be accepted.
|
|
return status >= 500
|
|
? new MailError("MAIL_REJECTED")
|
|
: new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
|
}
|
|
|
|
return new MailError("MAIL_SEND_FAILED");
|
|
}
|
|
|
|
function transportOptions(
|
|
config: MailProviderConfig,
|
|
bounds: SmtpTransportBounds,
|
|
): SMTPTransport.Options {
|
|
return {
|
|
// Bounded on every axis. See the constants above.
|
|
connectionTimeout: bounds.connectionTimeoutMs,
|
|
// Defence in depth: this platform never sends attachments, so Nodemailer must never be able to
|
|
// read a local file or fetch a URL on the strength of something that reached a message body.
|
|
disableFileAccess: true,
|
|
disableUrlAccess: true,
|
|
greetingTimeout: bounds.greetingTimeoutMs,
|
|
host: config.host,
|
|
// The identity this client announces in `EHLO`. Left to Nodemailer when the deployment
|
|
// configures none — it derives one from the machine hostname, falling back to the address
|
|
// literal `[127.0.0.1]` when that hostname is not fully qualified, which some relays refuse
|
|
// with a `421` before a message is ever offered (ADR 0037).
|
|
...(config.heloName === undefined ? {} : { name: config.heloName }),
|
|
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
|
|
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
|
|
// platform sending a handful of transactional messages buys nothing and makes the failure modes
|
|
// harder to reason about.
|
|
port: config.port,
|
|
// `secure` means implicit TLS from the first byte. For STARTTLS it must be false and the upgrade
|
|
// demanded separately, below.
|
|
secure: config.security === "implicit-tls",
|
|
socketTimeout: bounds.socketTimeoutMs,
|
|
// The downgrade guard. With `requireTLS`, a STARTTLS transport refuses to continue in the clear
|
|
// if the server does not offer the upgrade — so a server that stops advertising it, or an
|
|
// attacker who strips it from the EHLO response, produces a failed send rather than a plaintext
|
|
// session carrying a password. `ignoreTLS` is the opposite instruction and is set only for the
|
|
// mode that has explicitly asked for no TLS at all, which production configuration refuses.
|
|
...(config.security === "starttls" ? { requireTLS: true } : {}),
|
|
...(config.security === "plaintext" ? { ignoreTLS: true } : {}),
|
|
// Certificate verification is left at Nodemailer's default, which verifies. There is
|
|
// deliberately no configuration path that could switch it off, and a test asserts this file
|
|
// never names the option: a setting that disables verification is one someone eventually
|
|
// disables in production.
|
|
...(config.username === undefined || config.password === undefined
|
|
? {}
|
|
: { auth: { pass: config.password, user: config.username } }),
|
|
};
|
|
}
|
|
|
|
export function createSmtpMailProvider(
|
|
config: MailProviderConfig,
|
|
/** Tightened only by the live provider suite, so it can prove a deadline actually fires without
|
|
* spending the production budget waiting for it. Hosts always take the defaults. */
|
|
bounds: Partial<SmtpTransportBounds> = {},
|
|
): MailProvider {
|
|
const transporter: Transporter<SMTPTransport.SentMessageInfo> = createTransport(
|
|
transportOptions(config, { ...DEFAULT_TRANSPORT_BOUNDS, ...bounds }),
|
|
);
|
|
|
|
// Built once, from host configuration, and applied to every message. A caller cannot reach it and
|
|
// there is no per-message override, so no feature can send as anyone else.
|
|
const from =
|
|
config.fromName === undefined
|
|
? { address: config.fromAddress, name: "" }
|
|
: { address: config.fromAddress, name: config.fromName };
|
|
|
|
const provider: MailProvider = {
|
|
async close(): Promise<void> {
|
|
transporter.close();
|
|
},
|
|
|
|
async send(message): Promise<MailSendResult> {
|
|
try {
|
|
const accepted = await transporter.sendMail({
|
|
from,
|
|
subject: message.subject,
|
|
to: [...message.to],
|
|
...(message.text === undefined ? {} : { text: message.text }),
|
|
...(message.html === undefined ? {} : { html: message.html }),
|
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
});
|
|
// Exactly one attempt. Nodemailer does not retry a `sendMail` on its own, and nothing here
|
|
// adds one: a failure after the message data has gone out is ambiguous, and retrying an
|
|
// ambiguous send is how one password-reset request becomes three emails (ADR 0037).
|
|
const messageId = (accepted as { messageId?: unknown }).messageId;
|
|
return Object.freeze(typeof messageId === "string" ? { messageId } : {});
|
|
} catch (error) {
|
|
throw translateMailError(error);
|
|
}
|
|
},
|
|
|
|
async verify(): Promise<void> {
|
|
try {
|
|
await transporter.verify();
|
|
} catch (error) {
|
|
throw translateMailError(error);
|
|
}
|
|
},
|
|
};
|
|
return Object.freeze(provider);
|
|
}
|