feat: add a host-provided outbound email transport
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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ba32499456
commit
fa3021e49b
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
||||
planMigrations: async () => {
|
||||
throw new Error("database must not be planned");
|
||||
},
|
||||
probeMail: async () => {
|
||||
throw new Error("mail must not be probed");
|
||||
},
|
||||
probeStorage: async () => {
|
||||
throw new Error("storage must not be probed");
|
||||
},
|
||||
|
||||
@@ -138,6 +138,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
probeMail: vi.fn(async () => undefined),
|
||||
probeStorage: vi.fn(async () => undefined),
|
||||
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
@@ -187,6 +188,99 @@ function planEntry(
|
||||
});
|
||||
}
|
||||
|
||||
describe("Ekklesia Doctor outbound email reporting", () => {
|
||||
const mailConfiguration = {
|
||||
fromAddress: "[email protected]",
|
||||
fromName: "Ekklesia",
|
||||
host: "smtp.example.org",
|
||||
password: "sup3r-secret-relay-password",
|
||||
security: "starttls",
|
||||
username: "[email protected]",
|
||||
};
|
||||
|
||||
it("reports an installation with no SMTP host as complete rather than degraded", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("skipped");
|
||||
expect(mailCheck.summary).toBe("Outbound email not configured");
|
||||
// Nothing sends email yet, so an absent provider must not make a deployment read as broken.
|
||||
expect(report.result).not.toBe("not-ready");
|
||||
expect(services.probeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a reachable provider without ever naming a credential", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("pass");
|
||||
const rendered = mailCheck.details.join(" ");
|
||||
expect(rendered).toContain("smtp.example.org:587");
|
||||
expect(rendered).toContain("starttls");
|
||||
expect(rendered).toContain("authenticated");
|
||||
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
||||
expect(rendered).not.toContain("[email protected]");
|
||||
// Honest about what a connection proves: it did not send anything, and deliverability is not
|
||||
// something this check can establish.
|
||||
expect(rendered).toMatch(/did not|without sending/iu);
|
||||
});
|
||||
|
||||
it("warns rather than failing when a configured relay does not answer", async () => {
|
||||
const services = dependencies({
|
||||
probeMail: vi.fn(async () => {
|
||||
throw new Error("connection refused to smtp.example.org with sup3r-secret-relay-password");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
// A warning, never a failure: the configuration is valid and nothing depends on mail yet.
|
||||
expect(mailCheck.status).toBe("warning");
|
||||
expect(report.result).not.toBe("not-ready");
|
||||
// The thrown value carried a credential; none of it reaches the report.
|
||||
expect(mailCheck.details.join(" ")).not.toContain("sup3r-secret-relay-password");
|
||||
});
|
||||
|
||||
it("fails a malformed configuration with a sanitized code and probes nothing", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(), mailConfiguration: { ...mailConfiguration, port: "not-a-port" } },
|
||||
services,
|
||||
);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("failure");
|
||||
const rendered = mailCheck.details.join(" ");
|
||||
expect(rendered).toContain("MAIL_PORT_INVALID");
|
||||
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
||||
// A configuration this broken is never connected to.
|
||||
expect(services.probeMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses plaintext SMTP when the deployment validates as production", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
databaseEnvironment: "production",
|
||||
mailConfiguration: { ...mailConfiguration, security: "plaintext" },
|
||||
},
|
||||
services,
|
||||
);
|
||||
|
||||
expect(check(report, "outbound-email.configuration").details.join(" ")).toContain(
|
||||
"MAIL_SECURITY_INSECURE_IN_PRODUCTION",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia Doctor diagnostics", () => {
|
||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||
const people = discoveredModule("people");
|
||||
@@ -219,6 +313,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"identity.client-address",
|
||||
"object-storage.integration",
|
||||
"object-storage.bindings",
|
||||
"outbound-email.configuration",
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Structural guarantees about outbound email that no runtime assertion can make (ADR 0037).
|
||||
*
|
||||
* These read the repository itself, because the properties that matter here are about what code
|
||||
* *exists* and what depends on what — a mail credential that never reaches a browser bundle, and a
|
||||
* platform that offers no route anyone could use as an open relay.
|
||||
*/
|
||||
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
async function readJson(relativePath: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(path.join(repositoryRoot, relativePath), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
async function readTextFiles(directory: string): Promise<Array<{ path: string; text: string }>> {
|
||||
const { readdir } = await import("node:fs/promises");
|
||||
const results: Array<{ path: string; text: string }> = [];
|
||||
const walk = async (current: string): Promise<void> => {
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||
continue;
|
||||
}
|
||||
await walk(full);
|
||||
continue;
|
||||
}
|
||||
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||
results.push({ path: full, text: await readFile(full, "utf8") });
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(path.join(repositoryRoot, directory));
|
||||
return results;
|
||||
}
|
||||
|
||||
describe("outbound email stays server-only", () => {
|
||||
it("declares the SMTP dependency in Core alone", async () => {
|
||||
const core = await readJson("core/package.json");
|
||||
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
|
||||
|
||||
// Every package a browser can end up executing, plus the contracts barrel a surface must not
|
||||
// reach. None may carry a mail transport.
|
||||
for (const manifest of [
|
||||
"packages/contracts/package.json",
|
||||
"packages/module-sdk/package.json",
|
||||
"packages/module-ui-sdk/package.json",
|
||||
"packages/module-ui-host/package.json",
|
||||
"surfaces/shell/package.json",
|
||||
"apps/api/package.json",
|
||||
]) {
|
||||
const parsed = await readJson(manifest);
|
||||
const dependencies = {
|
||||
...((parsed.dependencies ?? {}) as Record<string, string>),
|
||||
...((parsed.devDependencies ?? {}) as Record<string, string>),
|
||||
};
|
||||
const mailDependencies = Object.keys(dependencies).filter((name) =>
|
||||
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name),
|
||||
);
|
||||
expect(mailDependencies, manifest).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("never imports the transport outside Core's own mail module", async () => {
|
||||
for (const directory of [
|
||||
"packages/contracts/src",
|
||||
"packages/module-sdk/src",
|
||||
"packages/module-ui-sdk/src",
|
||||
"packages/module-ui-host/src",
|
||||
"surfaces/shell/src",
|
||||
]) {
|
||||
for (const file of await readTextFiles(directory)) {
|
||||
expect(file.text, file.path).not.toContain("nodemailer");
|
||||
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives module code no way to send email at all", async () => {
|
||||
// Deliberate scope: Core sends, modules do not (yet). `ModuleRuntimeServices` is the one place a
|
||||
// module capability would appear, and it is added one service at a time when something real
|
||||
// needs it — nothing does here (ADR 0037).
|
||||
const services = await readFile(
|
||||
path.join(repositoryRoot, "packages/contracts/src/module-database.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(services).not.toMatch(/mail|email/iu);
|
||||
|
||||
for (const directory of ["packages/module-sdk/src", "packages/module-ui-sdk/src"]) {
|
||||
for (const file of await readTextFiles(directory)) {
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes no HTTP route that could send mail", async () => {
|
||||
// An authenticated relay endpoint is an open relay with extra steps. Nothing in the transport
|
||||
// layer may reference the mail provider at all.
|
||||
for (const file of await readTextFiles("apps/api/src")) {
|
||||
if (file.path.endsWith("server.ts") || file.path.endsWith("config.ts")) {
|
||||
// The composition root legitimately constructs and closes the provider.
|
||||
continue;
|
||||
}
|
||||
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
|
||||
}
|
||||
|
||||
for (const file of await readTextFiles("apps/api/src")) {
|
||||
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*send-email[^"'`]*["'`]/u);
|
||||
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*\/api\/v1\/mail[^"'`]*["'`]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads SMTP environment only in the executable that owns configuration", async () => {
|
||||
// Reusable Core code never touches `process.env`; the composition root turns environment into
|
||||
// validated configuration and injects it.
|
||||
for (const file of await readTextFiles("core/src/mail")) {
|
||||
expect(file.text, file.path).not.toContain("process.env");
|
||||
}
|
||||
const server = await readFile(path.join(repositoryRoot, "apps/api/src/server.ts"), "utf8");
|
||||
expect(server).toContain("EKKLESIA_SMTP_HOST");
|
||||
expect(server).toContain("EKKLESIA_SMTP_PASSWORD");
|
||||
});
|
||||
|
||||
it("keeps no SMTP password out of the committed environment example", async () => {
|
||||
const example = await readFile(path.join(repositoryRoot, ".env.example"), "utf8");
|
||||
// Documented, but never with a value: a bootstrap credential in a committed file is a
|
||||
// credential in every clone.
|
||||
expect(example).toContain("EKKLESIA_SMTP_HOST");
|
||||
expect(example).not.toMatch(/^EKKLESIA_SMTP_PASSWORD=.+$/mu);
|
||||
expect(example).not.toContain("smtp-relay.gmail.com");
|
||||
});
|
||||
|
||||
it("hardcodes no provider hostname in Core", async () => {
|
||||
// Google Workspace is a deployment target, not the architecture.
|
||||
for (const file of await readTextFiles("core/src/mail")) {
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("gmail");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("resend");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("brevo");
|
||||
}
|
||||
});
|
||||
|
||||
it("never disables TLS certificate verification", async () => {
|
||||
for (const file of await readTextFiles("core/src/mail")) {
|
||||
expect(file.text, file.path).not.toContain("rejectUnauthorized");
|
||||
expect(file.text, file.path).not.toContain("NODE_TLS_REJECT_UNAUTHORIZED");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMailProviderConfig,
|
||||
describeMailProvider,
|
||||
MailConfigError,
|
||||
} from "../src/mail/config.js";
|
||||
import {
|
||||
MAIL_MAX_HTML_BYTES,
|
||||
MAIL_MAX_RECIPIENTS,
|
||||
MAIL_MAX_SUBJECT_LENGTH,
|
||||
MAIL_MAX_TEXT_BYTES,
|
||||
MailError,
|
||||
validateMailMessage,
|
||||
} from "../src/mail/message.js";
|
||||
|
||||
/**
|
||||
* The mail contract's boundary, with no provider and no network.
|
||||
*
|
||||
* Most of these are negative: the contract's job is to make an impossible message impossible to
|
||||
* construct, so what it *refuses* is the interesting part (ADR 0037).
|
||||
*/
|
||||
const password = "sup3r-secret-relay-password";
|
||||
const validConfig = {
|
||||
fromAddress: "[email protected]",
|
||||
fromName: "Ekklesia",
|
||||
host: "smtp.example.org",
|
||||
security: "starttls",
|
||||
};
|
||||
|
||||
describe("host mail configuration", () => {
|
||||
it("treats an absent host as an installation that sends no email", () => {
|
||||
// The whole platform must still start. Nothing sends mail yet, so this is a complete deployment.
|
||||
expect(createMailProviderConfig(undefined)).toBeUndefined();
|
||||
expect(createMailProviderConfig({})).toBeUndefined();
|
||||
expect(createMailProviderConfig({ host: " " })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a relay with no credentials, which is how Google Workspace is usually deployed", () => {
|
||||
const config = createMailProviderConfig(validConfig);
|
||||
|
||||
// Requiring a username and password — as most SMTP examples do — would make the intended
|
||||
// production arrangement, a relay authorized by source IP, unconfigurable.
|
||||
expect(config).toEqual({
|
||||
fromAddress: "[email protected]",
|
||||
fromName: "Ekklesia",
|
||||
host: "smtp.example.org",
|
||||
port: 587,
|
||||
security: "starttls",
|
||||
});
|
||||
expect(config?.username).toBeUndefined();
|
||||
expect(config?.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults the port per security mode, and defaults the mode to an encrypted one", () => {
|
||||
expect(createMailProviderConfig({ ...validConfig, security: undefined })?.security).toBe(
|
||||
"starttls",
|
||||
);
|
||||
expect(createMailProviderConfig({ ...validConfig, security: "implicit-tls" })?.port).toBe(465);
|
||||
expect(createMailProviderConfig({ ...validConfig, security: "starttls" })?.port).toBe(587);
|
||||
expect(createMailProviderConfig({ ...validConfig, port: "2525" })?.port).toBe(2525);
|
||||
});
|
||||
|
||||
it("refuses half a credential pair in either direction", () => {
|
||||
expect(() =>
|
||||
createMailProviderConfig({ ...validConfig, username: "[email protected]" }),
|
||||
).toThrow(expect.objectContaining({ code: "MAIL_USERNAME_WITHOUT_PASSWORD" }));
|
||||
expect(() => createMailProviderConfig({ ...validConfig, password })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_PASSWORD_WITHOUT_USERNAME" }),
|
||||
);
|
||||
// Both together is the authenticated case and is accepted.
|
||||
expect(
|
||||
createMailProviderConfig({ ...validConfig, password, username: "[email protected]" })
|
||||
?.username,
|
||||
).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a scheme", "smtp://smtp.example.org"],
|
||||
["a port", "smtp.example.org:587"],
|
||||
["credentials", "user:[email protected]"],
|
||||
["a path", "smtp.example.org/relay"],
|
||||
["a space", "smtp example org"],
|
||||
["nothing but dots", "..."],
|
||||
])("refuses an SMTP host containing %s", (_label, host) => {
|
||||
// A host is a hostname, never a connection string — a connection string is how a password ends
|
||||
// up in a log.
|
||||
expect(() => createMailProviderConfig({ ...validConfig, host })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_HOST_INVALID" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["0"],
|
||||
["65536"],
|
||||
["-1"],
|
||||
["587.5"],
|
||||
["not-a-port"],
|
||||
])("refuses the malformed port %s", (port) => {
|
||||
expect(() => createMailProviderConfig({ ...validConfig, port })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_PORT_INVALID" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an empty port as unset rather than malformed", () => {
|
||||
expect(createMailProviderConfig({ ...validConfig, port: "" })?.port).toBe(587);
|
||||
});
|
||||
|
||||
it("refuses an unknown security mode rather than guessing one", () => {
|
||||
for (const security of ["tls", "ssl", "none", "STARTTLS", "true"]) {
|
||||
expect(() => createMailProviderConfig({ ...validConfig, security }), security).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_SECURITY_INVALID" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses plaintext SMTP in production but allows it in development", () => {
|
||||
expect(() =>
|
||||
createMailProviderConfig(
|
||||
{ ...validConfig, security: "plaintext" },
|
||||
{ environment: "production" },
|
||||
),
|
||||
).toThrow(expect.objectContaining({ code: "MAIL_SECURITY_INSECURE_IN_PRODUCTION" }));
|
||||
expect(
|
||||
createMailProviderConfig(
|
||||
{ ...validConfig, security: "plaintext" },
|
||||
{ environment: "development" },
|
||||
)?.security,
|
||||
).toBe("plaintext");
|
||||
});
|
||||
|
||||
it("requires a valid sender address and refuses an injectable display name", () => {
|
||||
expect(() => createMailProviderConfig({ ...validConfig, fromAddress: undefined })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_MISSING" }),
|
||||
);
|
||||
for (const fromAddress of [
|
||||
"not-an-address",
|
||||
"a@b",
|
||||
"a@@b.org",
|
||||
"a [email protected]",
|
||||
"<[email protected]>",
|
||||
]) {
|
||||
expect(() => createMailProviderConfig({ ...validConfig, fromAddress }), fromAddress).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_INVALID" }),
|
||||
);
|
||||
}
|
||||
for (const fromName of ['Ekklesia"', "Ekklesia\\", "Ekklesia\r\nBcc: [email protected]"]) {
|
||||
expect(() => createMailProviderConfig({ ...validConfig, fromName })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_FROM_NAME_INVALID" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("never puts a credential into a configuration error or a description", () => {
|
||||
const configured = createMailProviderConfig({
|
||||
...validConfig,
|
||||
password,
|
||||
username: "[email protected]",
|
||||
});
|
||||
if (!configured) {
|
||||
throw new Error("The mail configuration fixture produced no configuration.");
|
||||
}
|
||||
|
||||
const described = describeMailProvider(configured);
|
||||
// An operator sees where mail goes and whether it authenticates — never with what.
|
||||
expect(described).toContain("smtp.example.org:587");
|
||||
expect(described).toContain("starttls");
|
||||
expect(described).toContain("[email protected]");
|
||||
expect(described).toContain("authenticated");
|
||||
expect(described).not.toContain(password);
|
||||
expect(described).not.toContain("[email protected]");
|
||||
|
||||
const unauthenticated = createMailProviderConfig(validConfig);
|
||||
if (!unauthenticated) {
|
||||
throw new Error("The mail configuration fixture produced no configuration.");
|
||||
}
|
||||
expect(describeMailProvider(unauthenticated)).toContain("unauthenticated relay");
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
createMailProviderConfig({
|
||||
...validConfig,
|
||||
host: "smtp://bad",
|
||||
password,
|
||||
username: "[email protected]",
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MailConfigError);
|
||||
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||
expect(rendered).not.toContain(password);
|
||||
expect(rendered).not.toContain("smtp://bad");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transactional message validation", () => {
|
||||
const message = {
|
||||
subject: "Verify your email address",
|
||||
text: "Open the link to continue.",
|
||||
to: "[email protected]",
|
||||
};
|
||||
|
||||
it("accepts one recipient and normalizes it to a list", () => {
|
||||
expect(validateMailMessage(message)).toEqual({
|
||||
subject: "Verify your email address",
|
||||
text: "Open the link to continue.",
|
||||
to: ["[email protected]"],
|
||||
});
|
||||
});
|
||||
|
||||
it("has no sender field at all, so no caller can choose one", () => {
|
||||
const validated = validateMailMessage({ ...message, html: "<p>Hi</p>", replyTo: "[email protected]" });
|
||||
|
||||
// The load-bearing structural fact: the sender comes from host configuration and there is
|
||||
// nowhere in a message to put one.
|
||||
expect(Object.keys(validated).sort()).toEqual(["html", "replyTo", "subject", "text", "to"]);
|
||||
expect(validated).not.toHaveProperty("from");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a display name", "Person <[email protected]>"],
|
||||
["a second address", "[email protected], [email protected]"],
|
||||
["a semicolon list", "[email protected];[email protected]"],
|
||||
["a line feed", "[email protected]\nBcc: [email protected]"],
|
||||
["a carriage return", "[email protected]\r"],
|
||||
["no domain dot", "person@localhost"],
|
||||
["no local part", "@example.org"],
|
||||
["nothing", ""],
|
||||
])("refuses a recipient containing %s", (_label, to) => {
|
||||
expect(() => validateMailMessage({ ...message, to })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds the recipient list and refuses duplicates", () => {
|
||||
const many = Array.from(
|
||||
{ length: MAIL_MAX_RECIPIENTS + 1 },
|
||||
(_value, index) => `person${index}@example.org`,
|
||||
);
|
||||
expect(() => validateMailMessage({ ...message, to: many })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
|
||||
);
|
||||
expect(() => validateMailMessage({ ...message, to: [] })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
|
||||
);
|
||||
// Case-insensitively: the same mailbox twice would send one person two copies.
|
||||
expect(() =>
|
||||
validateMailMessage({ ...message, to: ["[email protected]", "[email protected]"] }),
|
||||
).toThrow(expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }));
|
||||
expect(
|
||||
validateMailMessage({ ...message, to: many.slice(0, MAIL_MAX_RECIPIENTS) }).to,
|
||||
).toHaveLength(MAIL_MAX_RECIPIENTS);
|
||||
});
|
||||
|
||||
it("refuses a subject carrying a header break or no content", () => {
|
||||
for (const subject of [
|
||||
"Reset\r\nBcc: [email protected]",
|
||||
"Reset\nX-Injected: 1",
|
||||
" ",
|
||||
"",
|
||||
]) {
|
||||
expect(() => validateMailMessage({ ...message, subject })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds the subject and both bodies", () => {
|
||||
expect(() =>
|
||||
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH + 1) }),
|
||||
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||
expect(() =>
|
||||
validateMailMessage({ ...message, text: "x".repeat(MAIL_MAX_TEXT_BYTES + 1) }),
|
||||
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||
expect(() =>
|
||||
validateMailMessage({
|
||||
html: "x".repeat(MAIL_MAX_HTML_BYTES + 1),
|
||||
subject: message.subject,
|
||||
to: message.to,
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||
// Exactly at the bound is accepted, so the limit is the limit.
|
||||
expect(
|
||||
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH) }).subject,
|
||||
).toHaveLength(MAIL_MAX_SUBJECT_LENGTH);
|
||||
});
|
||||
|
||||
it("requires at least one body, and accepts either alone", () => {
|
||||
expect(() => validateMailMessage({ subject: message.subject, to: message.to })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
|
||||
);
|
||||
expect(() => validateMailMessage({ ...message, text: "" })).toThrow(
|
||||
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
|
||||
);
|
||||
expect(
|
||||
validateMailMessage({ html: "<p>Hi</p>", subject: message.subject, to: message.to }).html,
|
||||
).toBe("<p>Hi</p>");
|
||||
});
|
||||
|
||||
it("refuses an injectable reply-to", () => {
|
||||
expect(() =>
|
||||
validateMailMessage({
|
||||
...message,
|
||||
replyTo: "[email protected]\r\nBcc: [email protected]",
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||
expect(validateMailMessage({ ...message, replyTo: "[email protected]" }).replyTo).toBe(
|
||||
"[email protected]",
|
||||
);
|
||||
});
|
||||
|
||||
it("never echoes the message into the error", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
validateMailMessage({
|
||||
...message,
|
||||
subject: `secret-${password}` + "\r\nBcc: [email protected]",
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(MailError);
|
||||
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||
expect(rendered).not.toContain(password);
|
||||
expect(rendered).not.toContain("[email protected]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user