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 = {}, ): MailProvider { const transporter: Transporter = 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 { transporter.close(); }, async send(message): Promise { 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 { try { await transporter.verify(); } catch (error) { throw translateMailError(error); } }, }; return Object.freeze(provider); }