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