import { isValidMailAddress, isValidMailDisplayName, MAIL_MAX_DISPLAY_NAME_LENGTH, } from "./message.js"; export type MailConfigErrorCode = | "MAIL_FROM_ADDRESS_INVALID" | "MAIL_FROM_ADDRESS_MISSING" | "MAIL_FROM_NAME_INVALID" | "MAIL_HELO_NAME_INVALID" | "MAIL_HOST_INVALID" | "MAIL_PASSWORD_WITHOUT_USERNAME" | "MAIL_PORT_INVALID" | "MAIL_SECURITY_INVALID" | "MAIL_SECURITY_INSECURE_IN_PRODUCTION" | "MAIL_USERNAME_WITHOUT_PASSWORD"; const mailConfigMessages: Readonly> = Object.freeze({ MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.", MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.", MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`, MAIL_HELO_NAME_INVALID: "The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.", MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.", MAIL_PASSWORD_WITHOUT_USERNAME: "An SMTP password requires a username; supply both or neither for an unauthenticated relay.", MAIL_PORT_INVALID: "The SMTP port must be an integer between 1 and 65535.", MAIL_SECURITY_INSECURE_IN_PRODUCTION: "A production SMTP transport must use implicit TLS or STARTTLS; plaintext is development-only.", MAIL_SECURITY_INVALID: 'The SMTP security mode must be exactly "implicit-tls", "starttls", or "plaintext".', MAIL_USERNAME_WITHOUT_PASSWORD: "An SMTP username requires a password; supply both or neither for an unauthenticated relay.", }); /** * Never carries the host, port, username, password, or sender — only a fixed code and message. * Matching `StorageConfigError` exactly, and for the same sharper reason: one of the values this * validates is a credential. */ export class MailConfigError extends Error { readonly code: MailConfigErrorCode; constructor(code: MailConfigErrorCode) { super(mailConfigMessages[code]); this.name = "MailConfigError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export type MailEnvironment = "development" | "production"; /** * How the connection is protected, stated explicitly rather than inferred. * * A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear * and hope STARTTLS is offered", and a server that quietly stops advertising STARTTLS — or an * attacker who strips it — turns that into a plaintext session carrying a password, with nothing in * the configuration that changed. Naming the mode makes the downgrade impossible to reach by * accident: `starttls` **requires** the upgrade and fails the send if it is unavailable. * * - `implicit-tls` — TLS from the first byte, conventionally port 465. * - `starttls` — plaintext connect, then a *mandatory* upgrade, conventionally 587 (or 25 on a * relay). This is what Google Workspace SMTP Relay and most modern providers expect. * - `plaintext` — no TLS at all. Development and a trusted local relay only; production refuses it. */ export const MAIL_SECURITY_MODES = ["implicit-tls", "starttls", "plaintext"] as const; export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number]; export interface MailProviderConfigInput { readonly host?: string | undefined; readonly heloName?: string | undefined; readonly port?: string | number | undefined; readonly security?: string | undefined; readonly username?: string | undefined; readonly password?: string | undefined; readonly fromAddress?: string | undefined; readonly fromName?: string | undefined; } /** * The validated host mail configuration. * * **No feature or module ever sees this.** It reaches Core's provider and nothing else; the * `MailProvider` seam speaks only in validated messages, and there is no accessor anywhere that * could return the host, the port, or the password (ADR 0037). */ export interface MailProviderConfig { readonly host: string; /** * The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets * one. * * This exists because of a real failure, and the failure is generic rather than tied to any one * provider. An SMTP client must greet the server with its own identity, and when nothing is * configured Nodemailer derives it from the machine's hostname — falling back to the address * literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state * of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the * connection with a `421` before any message is offered, which surfaces here as a bare * `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down. * * Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to * the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting * correctly — some relays check it against the connecting address's reverse DNS — and silently * replacing a working identity would trade one deployment's bug for another's. */ readonly heloName?: string; readonly port: number; readonly security: MailSecurityMode; /** Present only for an authenticated relay. A relay authorized by source IP — the common Google * Workspace arrangement — has neither. */ readonly username?: string; readonly password?: string; readonly fromAddress: string; readonly fromName?: string; } /** Conventional defaults per mode, so an operator who names a mode does not also have to remember a * port number. Any port may still be set explicitly. */ const DEFAULT_PORTS: Readonly> = Object.freeze({ "implicit-tls": 465, plaintext: 25, starttls: 587, }); /** A bare hostname or IP literal: no scheme, no port, no path, no credentials. Anything richer is a * connection string, and a connection string is where a password ends up in a log. */ const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/iu; const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/u; function validateHost(host: string): string { const trimmed = host.trim(); if (trimmed.length === 0 || (!HOST_PATTERN.test(trimmed) && !IPV4_PATTERN.test(trimmed))) { throw new MailConfigError("MAIL_HOST_INVALID"); } return trimmed; } /** * A fully qualified domain name, and deliberately nothing else. * * Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a * single unqualified label is precisely the input Nodemailer already refuses to use — configuring * one would reproduce the failure this setting exists to fix. And it requires the final label to * begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax, * and the bracketed address-literal form that *is* valid is the greeting the affected relays reject. * * Everything the requirement rules out falls out of the anchors and the character class rather than * from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a * carriage return or line feed, and any other prose all fail to match. */ const HELO_NAME_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu; function validateHeloName(heloName: string): string { if (!HELO_NAME_PATTERN.test(heloName)) { throw new MailConfigError("MAIL_HELO_NAME_INVALID"); } return heloName; } function validatePort(port: string | number | undefined, security: MailSecurityMode): number { if (port === undefined || (typeof port === "string" && port.trim() === "")) { return DEFAULT_PORTS[security]; } const parsed = typeof port === "number" ? port : Number(port.trim()); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { throw new MailConfigError("MAIL_PORT_INVALID"); } return parsed; } function validateSecurity( security: string | undefined, environment: MailEnvironment, ): MailSecurityMode { // Defaulting to STARTTLS rather than to whatever the port implies: an omitted mode must never be // the one that sends a password in the clear. const mode = security === undefined || security.trim() === "" ? "starttls" : security.trim(); if (!(MAIL_SECURITY_MODES as readonly string[]).includes(mode)) { throw new MailConfigError("MAIL_SECURITY_INVALID"); } if (environment === "production" && mode === "plaintext") { throw new MailConfigError("MAIL_SECURITY_INSECURE_IN_PRODUCTION"); } return mode as MailSecurityMode; } /** * Validates the host's outbound email configuration, or reports that this installation has none. * * **Outbound email is optional.** An installation with no `host` returns `undefined`, and that is a * complete, valid deployment — the platform starts, every existing feature works, and only a future * feature that actually needs to send is affected, which it discovers as a structured * `MAIL_NOT_CONFIGURED` rather than a crash (ADR 0037). * * Credentials are validated as a **pair**. Many SMTP examples assume a username and password, but a * relay authorized by source IP — which is how Google Workspace SMTP Relay is commonly deployed — * has neither, so requiring them would make the intended production arrangement unconfigurable. * Half a pair is always a mistake, though: it means someone set one variable and expected * authentication, so it fails loudly instead of silently connecting anonymously. */ export function createMailProviderConfig( input: MailProviderConfigInput | undefined, options: { readonly environment?: MailEnvironment } = {}, ): MailProviderConfig | undefined { const host = input?.host?.trim(); if (host === undefined || host === "") { return undefined; } const environment = options.environment ?? "production"; const security = validateSecurity(input?.security, environment); const validatedHost = validateHost(host); const port = validatePort(input?.port, security); // Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding // whitespace as a copy-paste artefact; this one must not, because a stray character in the value // goes straight onto the wire in a protocol command. It is refused rather than repaired, the same // rule an address follows. const heloNameInput = input?.heloName; const heloName = heloNameInput === undefined || heloNameInput === "" ? undefined : validateHeloName(heloNameInput); const username = input?.username?.trim() === "" ? undefined : input?.username?.trim(); const password = input?.password === "" ? undefined : input?.password; if (username !== undefined && password === undefined) { throw new MailConfigError("MAIL_USERNAME_WITHOUT_PASSWORD"); } if (password !== undefined && username === undefined) { throw new MailConfigError("MAIL_PASSWORD_WITHOUT_USERNAME"); } const fromAddress = input?.fromAddress?.trim(); if (fromAddress === undefined || fromAddress === "") { throw new MailConfigError("MAIL_FROM_ADDRESS_MISSING"); } if (!isValidMailAddress(fromAddress)) { throw new MailConfigError("MAIL_FROM_ADDRESS_INVALID"); } const fromName = input?.fromName?.trim() === "" ? undefined : input?.fromName?.trim(); if (fromName !== undefined && !isValidMailDisplayName(fromName)) { throw new MailConfigError("MAIL_FROM_NAME_INVALID"); } return Object.freeze({ fromAddress, host: validatedHost, port, security, ...(heloName === undefined ? {} : { heloName }), ...(fromName === undefined ? {} : { fromName }), ...(username === undefined || password === undefined ? {} : { password, username }), }); } /** * A one-line operator-facing description that is safe to print anywhere — Doctor output, a log line, * a support transcript. * * It names the host, port, security mode, and sender, and states **whether** authentication is * configured without ever naming the username or password. `describeStorageProvider` sets the same * precedent. */ export function describeMailProvider(config: MailProviderConfig): string { const sender = config.fromName === undefined ? config.fromAddress : `${config.fromName} <${config.fromAddress}>`; // The HELO name is host infrastructure, not a credential: it is the identity this client announces // in the clear on every connection, so printing it costs nothing and is exactly what an operator // needs when a relay has refused the greeting. const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`; return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${ config.username === undefined ? "unauthenticated relay" : "authenticated" }${helo}`; }