Ekklesia could authenticate people and pair devices but could not send a single message. Better Auth has been configured with emailAndPassword since ADR 0011 and neither of its mail hooks — sendResetPassword and emailVerification.sendVerificationEmail — was ever implemented, so email verification, password recovery, and invitations were all blocked on the same missing thing. That missing thing is a transport, not a Google Workspace integration. Email is now a host-provided infrastructure service with a provider seam, shaped exactly like object storage: feature code says "send this transactional message" and the provider owns the host, port, TLS, credentials, connection behaviour, and every transport error. No provider's hostname appears anywhere in Core, and a test asserts it. Google Workspace SMTP Relay is the expected first deployment and a compatibility target — the same variables address Microsoft 365, a transactional provider, or a local Postfix. The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or HTML, an optional reply-to, and nothing else. There is no `from` field at all — the sender is host configuration, so no feature and no future module can choose who mail appears to come from, which is how a platform otherwise acquires an impersonation primitive. Control characters are refused rather than stripped in addresses, display names, subjects, and reply-to values; writing that rule down was not enough, because the first implementation trimmed an address before checking it and silently accepted a trailing carriage return until a test caught it. Security modes are named rather than inferred from a boolean and a port, which is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and hope STARTTLS is offered". `starttls` sets requireTLS so a stripped upgrade fails the send instead of continuing in plaintext, production refuses `plaintext` outright, and certificate verification has no off switch. Credentials are optional as a pair, because a relay authorized by source IP has neither and requiring them would make the intended production arrangement unconfigurable; half a pair fails loudly. Connection, greeting, and socket timeouts are all explicit — the object-storage pass already showed what a network client on library defaults does to a request. Nothing retries: a failure after the message data has gone out is ambiguous, and retrying it turns one password-reset request into several emails, so this is a single bounded attempt and deliberately not an exactly-once promise. Email stays optional. An installation with no SMTP host is a complete deployment, nothing sends at startup, and constructing the transport opens no socket. There is no POST /api/v1/send-email and no route that touches the provider — an authenticated send endpoint is an open relay with extra steps. Modules get nothing either; services are added one at a time when something real needs one, and nothing does yet. Proven against a real in-process SMTP server rather than a mock: delivery with the host's sender on the wire, authentication and its sanitized refusal, a rejected mailbox distinguished from an outage, a refused connection, a silent server hitting its deadline, and a starttls transport refusing to continue when the upgrade is not offered. No test sends real email and no live credential exists in this repository or in CI. Co-Authored-By: Claude Opus 5 <[email protected]>
358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
import type { AddressInfo } 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;
|
|
}
|
|
|
|
interface Harness {
|
|
readonly port: number;
|
|
readonly captured: CapturedMessage[];
|
|
readonly close: () => Promise<void>;
|
|
}
|
|
|
|
const openHarnesses: Array<() => Promise<void>> = [];
|
|
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;
|
|
}
|
|
|
|
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
|
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,
|
|
to: session.envelope.rcptTo.map((entry) => entry.address),
|
|
});
|
|
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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.server.address() as AddressInfo;
|
|
const close = async (): Promise<void> => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
};
|
|
openHarnesses.push(close);
|
|
return { captured, close, port: address.port };
|
|
}
|
|
|
|
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
|
|
const built = createMailProviderConfig(
|
|
{
|
|
fromAddress: "[email protected]",
|
|
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: "<p>Open the link to continue.</p>",
|
|
subject: "Verify your email address",
|
|
text: "Open the link to continue.",
|
|
to: "[email protected]",
|
|
}),
|
|
);
|
|
|
|
expect(harness.captured).toHaveLength(1);
|
|
const message = harness.captured[0];
|
|
expect(message?.to).toEqual(["[email protected]"]);
|
|
// 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("[email protected]");
|
|
expect(message?.data).toContain("From: Ekklesia <[email protected]>");
|
|
expect(message?.data).toContain("Subject: Verify your email address");
|
|
expect(message?.data).toContain("Open the link to continue.");
|
|
expect(message?.data).toContain("<p>Open the link to continue.</p>");
|
|
// 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("[email protected]");
|
|
});
|
|
|
|
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: ["[email protected]", "[email protected]"],
|
|
}),
|
|
);
|
|
|
|
expect(harness.captured[0]?.to).toEqual(["[email protected]", "[email protected]"]);
|
|
// 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: "[email protected]",
|
|
subject: "Notice",
|
|
text: "Body.",
|
|
to: "[email protected]",
|
|
}),
|
|
);
|
|
|
|
const data = harness.captured[0]?.data ?? "";
|
|
expect(data).toContain("Reply-To: [email protected]");
|
|
expect(data).toContain("From: Ekklesia <[email protected]>");
|
|
expect(harness.captured[0]?.from).toBe("[email protected]");
|
|
});
|
|
|
|
it("authenticates when the relay demands it, and reports a refusal as rejected", async () => {
|
|
const harness = await startSmtpServer({
|
|
auth: { password: "relay-password", username: "[email protected]" },
|
|
});
|
|
|
|
const authenticated = provider(
|
|
config(harness.port, { password: "relay-password", username: "[email protected]" }),
|
|
);
|
|
await expect(
|
|
authenticated.send(
|
|
validateMailMessage({ subject: "Authenticated", text: "Body.", to: "[email protected]" }),
|
|
),
|
|
).resolves.toMatchObject({});
|
|
expect(harness.captured).toHaveLength(1);
|
|
|
|
const wrong = provider(
|
|
config(harness.port, { password: "wrong-password", username: "[email protected]" }),
|
|
);
|
|
let caught: unknown;
|
|
try {
|
|
await wrong.send(
|
|
validateMailMessage({ subject: "Rejected", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
} 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("[email protected]");
|
|
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: ["[email protected]"] });
|
|
const mail = provider(config(harness.port));
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
} 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: "[email protected]" }),
|
|
);
|
|
} 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<import("node:net").Socket>();
|
|
const blackHole: Server = createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
});
|
|
await new Promise<void>((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: "[email protected]" }),
|
|
);
|
|
} 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<void>((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: "[email protected]" }),
|
|
);
|
|
} 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: "[email protected]" }),
|
|
);
|
|
|
|
// 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);
|
|
});
|
|
});
|