Files
Ekklesia/core/tests/mail-boundaries.test.ts
T
ekklesiaandClaude Opus 5 3563549e86
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m41s
fix: let a deployment name itself in the SMTP greeting
A real deployment could not send mail through an IP-authorized relay.
Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was
healthy and swaks sent from the same machine.

The cause was the greeting, and it is generic rather than tied to any
provider. 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, which is the normal state of a container. The relay
closed the connection on it with a 421 before any message was offered,
which is indistinguishable from the relay being down.

EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It
is validated as a fully qualified domain name — no scheme, port,
brackets, whitespace, or control characters, and at least one dot, since
a single unqualified label is exactly the input Nodemailer already
discards. It is not a credential: it travels in the clear on every
connection, so Doctor prints it where an operator can compare it against
what the relay expects, and Doctor's probe performs the same greeting the
runtime does.

Absent preserves Nodemailer's behaviour exactly. It is deliberately not
defaulted to the sender's domain: a machine whose hostname is already a
valid FQDN may be greeting correctly, and some relays check that against
reverse DNS, so replacing it silently would trade one deployment's bug
for another's.

The regression guard is a raw SMTP server that refuses any greeting but
the expected one, because smtp-server exposes no EHLO hook and its
earliest hook is never reached by a send-nothing probe.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 20:28:30 -05:00

209 lines
8.9 KiB
TypeScript

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 transport in Core alone", async () => {
const core = await readJson("core/package.json");
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
const mailPackages = (names: readonly string[]): readonly string[] =>
names.filter((name) => /nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name));
// Every package a browser can end up executing, plus the contracts barrel a surface must not
// reach. None may carry anything mail-related at all, in either dependency list.
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",
]) {
const parsed = await readJson(manifest);
expect(
mailPackages([
...Object.keys((parsed.dependencies ?? {}) as object),
...Object.keys((parsed.devDependencies ?? {}) as object),
]),
manifest,
).toEqual([]);
}
// The API executable ships no mail package of any kind: it reaches SMTP only through Core's
// provider. Its *test* dependencies may include `smtp-server`, which is a receiving server and
// has no capability to send anything — that is what lets the verification and recovery
// integration suites capture a real message instead of asserting against a fake (ADR 0038).
const api = await readJson("apps/api/package.json");
expect(mailPackages(Object.keys((api.dependencies ?? {}) as object))).toEqual([]);
expect(mailPackages(Object.keys((api.devDependencies ?? {}) as object))).toEqual([
"@types/smtp-server",
"smtp-server",
]);
});
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("maps SMTP environment in exactly one place, and never inside the mail provider", async () => {
// The mail provider takes validated configuration and nothing else — it never reads the
// environment, under any name.
for (const file of await readTextFiles("core/src/mail")) {
expect(file.text, file.path).not.toContain("process.env");
expect(file.text, file.path).not.toContain("EKKLESIA_SMTP_");
}
// The variable names live in the shared host-configuration loader, which both `startApi` and
// `pnpm doctor` call with their own environment. Two executables mapping these names
// independently is what let Doctor and the runtime disagree about object storage.
const loader = await readFile(
path.join(repositoryRoot, "core/src/host/configuration.ts"),
"utf8",
);
expect(loader).toContain("EKKLESIA_SMTP_HOST");
expect(loader).toContain("EKKLESIA_SMTP_PASSWORD");
// That the loader itself reads no environment is asserted in
// `host-configuration-boundaries.test.ts`, which strips comments first — this file's plain
// substring match would trip over the loader's own prose about the rule.
});
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. It is the deployment that
// exposed the generic `EHLO` defect the HELO-name setting fixes, and it is named nowhere the
// code could come to depend on it.
for (const file of await readTextFiles("core/src/mail")) {
const text = file.text.toLowerCase();
for (const forbidden of [
"gmail",
"smtp-relay",
"googlemail",
"google.com",
"outlook",
"office365",
"sendgrid",
"mailgun",
"postmark",
"resend",
"brevo",
]) {
expect(text, `${file.path} names ${forbidden}`).not.toContain(forbidden);
}
}
});
it("defaults the SMTP client identity to nothing, so no deployment's name is baked in", async () => {
const config = await readFile(path.join(repositoryRoot, "core/src/mail/config.ts"), "utf8");
const provider = await readFile(
path.join(repositoryRoot, "core/src/mail/smtp-provider.ts"),
"utf8",
);
// The value that fixed a real deployment is configuration, never a constant. A fallback literal
// here would be one church's domain compiled into every installation.
expect(config).not.toContain("riverofglorymn");
expect(provider).not.toContain("riverofglorymn");
// The provider passes it through and supplies nothing of its own when it is absent.
expect(provider).toContain("config.heloName === undefined ? {} : { name: config.heloName }");
});
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");
}
});
});