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> { return JSON.parse(await readFile(path.join(repositoryRoot, relativePath), "utf8")) as Record< string, unknown >; } async function readTextFiles(directory: string): Promise> { const { readdir } = await import("node:fs/promises"); const results: Array<{ path: string; text: string }> = []; const walk = async (current: string): Promise => { 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. 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"); } }); });