import type { AddressInfo, Socket } from "node:net"; import { createServer, type Server } from "node:net"; import { SMTPServer } from "smtp-server"; import { afterEach, describe, expect, it } from "vitest"; import { createMailProviderConfig, type MailProviderConfig } from "../src/mail/config.js"; import { MailError, validateMailMessage } from "../src/mail/message.js"; import type { MailProvider } from "../src/mail/provider.js"; import { createSmtpMailProvider } from "../src/mail/smtp-provider.js"; /** * The SMTP provider against a **real SMTP server** speaking the real protocol. * * The unit suite proves the contract with no provider at all; this proves the things only a server * can. Every claim here is a property of the agreement between Nodemailer and an SMTP server rather * than of code in this repository: that the host's sender really is the one on the wire, that a * refused recipient is distinguishable from an outage, that a silent server eventually gives up * rather than hanging forever, and that a STARTTLS transport refuses to continue in the clear. * * `smtp-server` is used rather than a mock because it is the reference implementation from the same * project as the client, it runs in-process, and it needs no container — so this adds no CI * infrastructure and sends no real email. **Nothing here reaches the internet**, and no live * credential exists anywhere in this suite. */ interface CapturedMessage { readonly from: string; readonly to: readonly string[]; readonly data: string; /** The argument this client actually sent in `EHLO`, lowercased by the server. */ readonly greetedAs: string; } interface Harness { readonly port: number; readonly captured: CapturedMessage[]; readonly close: () => Promise; } const openHarnesses: Array<() => Promise> = []; const openProviders: MailProvider[] = []; afterEach(async () => { for (const provider of openProviders.splice(0)) { await provider.close().catch(() => undefined); } for (const close of openHarnesses.splice(0)) { await close().catch(() => undefined); } }); interface HarnessOptions { /** When set, the server demands AUTH and accepts only this exact pair. */ readonly auth?: { readonly username: string; readonly password: string }; /** Rejects exactly these recipients at RCPT TO, as a real relay does for an unknown mailbox. */ readonly rejectRecipients?: readonly string[]; /** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */ readonly offerStartTls?: boolean; /** * Refuses any client whose `EHLO` argument is not exactly this name, as a relay that requires a * fully qualified client identity does. * * The refusal is a `421`, the same class of response the real relay returned: a temporary failure * that closes the connection before a message is offered. */ readonly requireGreeting?: string; } async function startSmtpServer(options: HarnessOptions = {}): Promise { const captured: CapturedMessage[] = []; const server = new SMTPServer({ authOptional: options.auth === undefined, // Deliberately no TLS unless a scenario asks: proving the client's downgrade guard needs a // server that does not offer the upgrade. hideSTARTTLS: options.offerStartTls !== true, onAuth(auth, _session, callback) { if ( options.auth !== undefined && auth.username === options.auth.username && auth.password === options.auth.password ) { callback(null, { user: auth.username }); return; } callback(new Error("Invalid credentials")); }, onData(stream, session, callback) { let data = ""; stream.on("data", (chunk: Buffer) => { data += chunk.toString("utf8"); }); stream.on("end", () => { captured.push({ data, from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address, greetedAs: session.hostNameAppearsAs, to: session.envelope.rcptTo.map((entry) => entry.address), }); callback(); }); }, onMailFrom(_address, session, callback) { if ( options.requireGreeting !== undefined && session.hostNameAppearsAs !== options.requireGreeting.toLowerCase() ) { const error = new Error("Try again later, closing connection.") as Error & { responseCode?: number; }; error.responseCode = 421; callback(error); return; } callback(); }, onRcptTo(address, _session, callback) { if (options.rejectRecipients?.includes(address.address)) { // 550 is a permanent refusal of this mailbox, which is what a real relay returns. const error = new Error("Mailbox unavailable") as Error & { responseCode?: number }; error.responseCode = 550; callback(error); return; } callback(); }, }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.server.address() as AddressInfo; const close = async (): Promise => { await new Promise((resolve) => server.close(() => resolve())); }; openHarnesses.push(close); return { captured, close, port: address.port }; } /** * A relay that refuses the greeting itself, byte for byte as the real one did. * * `smtp-server` exposes no `EHLO` hook — its earliest usable one is `MAIL FROM`, which * `transporter.verify()` never reaches — so modelling this failure at the point it actually happens * needs a socket that speaks the handful of SMTP verbs involved. That matters: the whole reason the * defect was hard to diagnose is that it aborts the session *before* a message is offered, which is * also why Doctor's send-nothing probe can see it. */ async function startGreetingStrictRelay(expected: string): Promise { const sockets = new Set(); const server = createServer((socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); socket.on("error", () => undefined); socket.write("220 relay.invalid ESMTP\r\n"); let buffer = ""; socket.on("data", (chunk: Buffer) => { buffer += chunk.toString("utf8"); let index = buffer.indexOf("\r\n"); while (index !== -1) { const line = buffer.slice(0, index); buffer = buffer.slice(index + 2); const [verb = "", argument = ""] = line.split(" "); if (verb.toUpperCase() === "EHLO" || verb.toUpperCase() === "HELO") { if (argument.toLowerCase() !== expected.toLowerCase()) { // The exact shape the deployment saw: a 421 naming no reason, then the door shut. socket.write("421-4.7.0 Try again later, closing connection. (EHLO)\r\n"); socket.write("421 4.7.0 Goodbye\r\n"); socket.end(); return; } socket.write("250-relay.invalid\r\n250 OK\r\n"); } else if (verb.toUpperCase() === "QUIT") { socket.write("221 Bye\r\n"); socket.end(); return; } else { socket.write("250 OK\r\n"); } index = buffer.indexOf("\r\n"); } }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address() as AddressInfo; const close = async (): Promise => { // `close()` waits for open sockets, and a refused client may still be holding one. for (const socket of sockets) { socket.destroy(); } await new Promise((resolve) => server.close(() => resolve())); }; openHarnesses.push(close); return { captured: [], close, port: address.port }; } function config(port: number, overrides: Record = {}): MailProviderConfig { const built = createMailProviderConfig( { fromAddress: "noreply@example.org", fromName: "Ekklesia", host: "127.0.0.1", port, // The local test server speaks no TLS. Production configuration refuses this mode outright, // which is exactly why the development environment has to be selected explicitly here. security: "plaintext", ...overrides, }, { environment: "development" }, ); if (!built) { throw new Error("The SMTP integration configuration is invalid."); } return built; } function provider(providerConfig: MailProviderConfig, bounds = {}): MailProvider { const created = createSmtpMailProvider(providerConfig, bounds); openProviders.push(created); return created; } describe.sequential("live SMTP transport", () => { it("delivers a transactional message with the host's sender, subject, and both bodies", async () => { const harness = await startSmtpServer(); const mail = provider(config(harness.port)); const result = await mail.send( validateMailMessage({ html: "

Open the link to continue.

", subject: "Verify your email address", text: "Open the link to continue.", to: "person@example.org", }), ); expect(harness.captured).toHaveLength(1); const message = harness.captured[0]; expect(message?.to).toEqual(["person@example.org"]); // The envelope sender is the host's configured address — the caller never chose it and has no // field in which to. expect(message?.from).toBe("noreply@example.org"); expect(message?.data).toContain("From: Ekklesia "); expect(message?.data).toContain("Subject: Verify your email address"); expect(message?.data).toContain("Open the link to continue."); expect(message?.data).toContain("

Open the link to continue.

"); // A provider message id is the only thing that comes back, and it names no recipient. expect(result.messageId).toMatch(/@/u); expect(result.messageId ?? "").not.toContain("person@example.org"); }); it("sends to a small bounded list, and to exactly the addresses given", async () => { const harness = await startSmtpServer(); const mail = provider(config(harness.port)); await mail.send( validateMailMessage({ subject: "Invitation", text: "You are invited.", to: ["one@example.org", "two@example.org"], }), ); expect(harness.captured[0]?.to).toEqual(["one@example.org", "two@example.org"]); // No silent extra recipient: nothing in this platform adds a Bcc. expect(harness.captured[0]?.data).not.toMatch(/^Bcc:/imu); }); it("carries a reply-to without letting it become the sender", async () => { const harness = await startSmtpServer(); const mail = provider(config(harness.port)); await mail.send( validateMailMessage({ replyTo: "office@example.org", subject: "Notice", text: "Body.", to: "person@example.org", }), ); const data = harness.captured[0]?.data ?? ""; expect(data).toContain("Reply-To: office@example.org"); expect(data).toContain("From: Ekklesia "); expect(harness.captured[0]?.from).toBe("noreply@example.org"); }); it("authenticates when the relay demands it, and reports a refusal as rejected", async () => { const harness = await startSmtpServer({ auth: { password: "relay-password", username: "relay@example.org" }, }); const authenticated = provider( config(harness.port, { password: "relay-password", username: "relay@example.org" }), ); await expect( authenticated.send( validateMailMessage({ subject: "Authenticated", text: "Body.", to: "person@example.org" }), ), ).resolves.toMatchObject({}); expect(harness.captured).toHaveLength(1); const wrong = provider( config(harness.port, { password: "wrong-password", username: "relay@example.org" }), ); let caught: unknown; try { await wrong.send( validateMailMessage({ subject: "Rejected", text: "Body.", to: "person@example.org" }), ); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(MailError); expect((caught as MailError).code).toBe("MAIL_REJECTED"); // The server's response text — which can name the account — never reaches the caller. const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`; expect(rendered).not.toContain("wrong-password"); expect(rendered).not.toContain("relay@example.org"); expect(rendered).not.toContain("Invalid credentials"); // And nothing was delivered. expect(harness.captured).toHaveLength(1); }); it("distinguishes a refused mailbox from an outage", async () => { const harness = await startSmtpServer({ rejectRecipients: ["absent@example.org"] }); const mail = provider(config(harness.port)); let caught: unknown; try { await mail.send( validateMailMessage({ subject: "Notice", text: "Body.", to: "absent@example.org" }), ); } catch (error) { caught = error; } // The one failure a human can usually act on keeps its own code. expect((caught as MailError).code).toBe("MAIL_RECIPIENT_INVALID"); expect(harness.captured).toHaveLength(0); }); it("reports a refused connection as provider-unavailable rather than hanging", async () => { // A port nothing listens on: a real connection failure rather than a simulated one. const mail = provider(config(1)); let caught: unknown; try { await mail.send( validateMailMessage({ subject: "Notice", text: "Body.", to: "person@example.org" }), ); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(MailError); expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE"); }); it("gives up on a server that accepts the connection and then never greets", async () => { // A refused connection fails fast on its own; a server that *answers* and then stalls is the // failure with no natural end, and the one that has to be bounded deliberately. This is the // lesson the object-storage pass learned the hard way. const sockets = new Set(); const blackHole: Server = createServer((socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); await new Promise((resolve) => blackHole.listen(0, "127.0.0.1", resolve)); const port = (blackHole.address() as AddressInfo).port; const mail = provider(config(port), { // The production defaults are deliberately patient; this proves the mechanism, not the number. connectionTimeoutMs: 300, greetingTimeoutMs: 300, socketTimeoutMs: 300, }); const startedAt = Date.now(); let caught: unknown; try { await mail.send( validateMailMessage({ subject: "Notice", text: "Body.", to: "person@example.org" }), ); } catch (error) { caught = error; } const elapsed = Date.now() - startedAt; expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE"); // The point of the assertion is that it *ended*. Without a deadline Nodemailer waits minutes, // and a person waiting on a password-reset email waits with it. expect(elapsed).toBeLessThan(5_000); for (const socket of sockets) { socket.destroy(); } await new Promise((resolve) => blackHole.close(() => resolve())); }); it("refuses to continue in the clear when STARTTLS was required but is not offered", async () => { const harness = await startSmtpServer({ offerStartTls: false }); const mail = provider(config(harness.port, { security: "starttls" })); let caught: unknown; try { await mail.send( validateMailMessage({ subject: "Notice", text: "Body.", to: "person@example.org" }), ); } catch (error) { caught = error; } // The downgrade guard. A server that stops advertising STARTTLS — or an attacker who strips it // — produces a failed send, never a plaintext session carrying a password. expect(caught).toBeInstanceOf(MailError); expect(harness.captured).toHaveLength(0); }); it("verifies connectivity without sending anything", async () => { const harness = await startSmtpServer(); const mail = provider(config(harness.port)); await expect(mail.verify()).resolves.toBeUndefined(); // The assertion Doctor depends on: a diagnostic that emailed somebody every time an operator ran // it would be its own incident. expect(harness.captured).toHaveLength(0); const unreachable = provider(config(1)); await expect(unreachable.verify()).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE", }); }); it("sends exactly once, with no retry behind the caller's back", async () => { const harness = await startSmtpServer(); const mail = provider(config(harness.port)); await mail.send( validateMailMessage({ subject: "Once", text: "Body.", to: "person@example.org" }), ); // One call, one message. Retrying an ambiguous send is how one password-reset request becomes // three emails, so nothing here retries (ADR 0037). expect(harness.captured).toHaveLength(1); }); }); describe("SMTP client identity on the wire", () => { /** * The regression guard for a real production defect. * * A deployment relaying through an IP-authorized relay could not send at all: every attempt * surfaced as `MAIL_PROVIDER_UNAVAILABLE`. The relay was healthy — `swaks` sent from the same * machine — and the cause was the greeting. 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. The relay closed the * connection on it with a `421`. * * These tests are written against the wire rather than against the transport options, because the * defect was invisible in the options: everything about the configuration looked correct. */ it("greets with the configured name, exactly as configured", async () => { const harness = await startSmtpServer(); const mail = provider(config(harness.port, { heloName: "mail.example.org" })); await mail.send( validateMailMessage({ subject: "Greeting", text: "Body.", to: "person@example.org" }), ); expect(harness.captured[0]?.greetedAs).toBe("mail.example.org"); }); it("never greets as the address literal when a name is configured", async () => { const harness = await startSmtpServer(); const mail = provider(config(harness.port, { heloName: "mail.example.org" })); await mail.send( validateMailMessage({ subject: "Greeting", text: "Body.", to: "person@example.org" }), ); // The exact greeting the relay refused. expect(harness.captured[0]?.greetedAs).not.toBe("[127.0.0.1]"); }); it("delivers to a relay that demands a fully qualified greeting", async () => { const harness = await startSmtpServer({ requireGreeting: "mail.example.org" }); const mail = provider(config(harness.port, { heloName: "mail.example.org" })); await mail.send( validateMailMessage({ subject: "Accepted", text: "Body.", to: "person@example.org" }), ); expect(harness.captured).toHaveLength(1); expect(harness.captured[0]?.greetedAs).toBe("mail.example.org"); }); it("is refused by that same relay when no name is configured", async () => { const harness = await startGreetingStrictRelay("mail.example.org"); const mail = provider(config(harness.port)); // Deterministic regardless of what this machine is called: the server accepts exactly one name, // and Nodemailer's derivation cannot produce `mail.example.org` on any host. await expect( mail.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "p@example.org" })), ).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE" }); expect(harness.captured).toHaveLength(0); }); it("carries the refused greeting into no error a caller can see", async () => { const harness = await startGreetingStrictRelay("mail.example.org"); const mail = provider(config(harness.port)); const failure = await mail .send(validateMailMessage({ subject: "Refused", text: "Body.", to: "p@example.org" })) .catch((error: unknown) => error); // A `421` is SMTP's "try later", so it stays `MAIL_PROVIDER_UNAVAILABLE` rather than becoming a // permanent rejection — and the server's response text never reaches the caller. const serialized = `${(failure as Error).message} ${(failure as Error).stack ?? ""}`; expect(serialized).not.toContain("Try again later"); expect(serialized).not.toContain("closing connection"); }); it("still verifies through the same greeting Doctor's probe will use", async () => { const harness = await startGreetingStrictRelay("mail.example.org"); const configured = provider(config(harness.port, { heloName: "mail.example.org" })); const unconfigured = provider(config(harness.port)); // Doctor builds its provider from the identical validated configuration, so an operator whose // relay refuses the greeting sees it in `pnpm doctor` rather than only when a reset email // silently fails to arrive. await expect(configured.verify()).resolves.toBeUndefined(); await expect(unconfigured.verify()).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE", }); }); });