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
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user