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]>
158 lines
6.6 KiB
TypeScript
158 lines
6.6 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 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");
|
|
}
|
|
});
|
|
});
|