feat: add a host-provided outbound email transport
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s

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:
ekklesia
2026-08-24 18:37:16 -05:00
co-authored by Claude Opus 5
parent ba32499456
commit fa3021e49b
26 changed files with 2435 additions and 20 deletions
+225
View File
@@ -0,0 +1,225 @@
import {
isValidMailAddress,
isValidMailDisplayName,
MAIL_MAX_DISPLAY_NAME_LENGTH,
} from "./message.js";
export type MailConfigErrorCode =
| "MAIL_FROM_ADDRESS_INVALID"
| "MAIL_FROM_ADDRESS_MISSING"
| "MAIL_FROM_NAME_INVALID"
| "MAIL_HOST_INVALID"
| "MAIL_PASSWORD_WITHOUT_USERNAME"
| "MAIL_PORT_INVALID"
| "MAIL_SECURITY_INVALID"
| "MAIL_SECURITY_INSECURE_IN_PRODUCTION"
| "MAIL_USERNAME_WITHOUT_PASSWORD";
const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object.freeze({
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
MAIL_PASSWORD_WITHOUT_USERNAME:
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
MAIL_PORT_INVALID: "The SMTP port must be an integer between 1 and 65535.",
MAIL_SECURITY_INSECURE_IN_PRODUCTION:
"A production SMTP transport must use implicit TLS or STARTTLS; plaintext is development-only.",
MAIL_SECURITY_INVALID:
'The SMTP security mode must be exactly "implicit-tls", "starttls", or "plaintext".',
MAIL_USERNAME_WITHOUT_PASSWORD:
"An SMTP username requires a password; supply both or neither for an unauthenticated relay.",
});
/**
* Never carries the host, port, username, password, or sender — only a fixed code and message.
* Matching `StorageConfigError` exactly, and for the same sharper reason: one of the values this
* validates is a credential.
*/
export class MailConfigError extends Error {
readonly code: MailConfigErrorCode;
constructor(code: MailConfigErrorCode) {
super(mailConfigMessages[code]);
this.name = "MailConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export type MailEnvironment = "development" | "production";
/**
* How the connection is protected, stated explicitly rather than inferred.
*
* A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear
* and hope STARTTLS is offered", and a server that quietly stops advertising STARTTLS — or an
* attacker who strips it — turns that into a plaintext session carrying a password, with nothing in
* the configuration that changed. Naming the mode makes the downgrade impossible to reach by
* accident: `starttls` **requires** the upgrade and fails the send if it is unavailable.
*
* - `implicit-tls` — TLS from the first byte, conventionally port 465.
* - `starttls` — plaintext connect, then a *mandatory* upgrade, conventionally 587 (or 25 on a
* relay). This is what Google Workspace SMTP Relay and most modern providers expect.
* - `plaintext` — no TLS at all. Development and a trusted local relay only; production refuses it.
*/
export const MAIL_SECURITY_MODES = ["implicit-tls", "starttls", "plaintext"] as const;
export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
export interface MailProviderConfigInput {
readonly host?: string | undefined;
readonly port?: string | number | undefined;
readonly security?: string | undefined;
readonly username?: string | undefined;
readonly password?: string | undefined;
readonly fromAddress?: string | undefined;
readonly fromName?: string | undefined;
}
/**
* The validated host mail configuration.
*
* **No feature or module ever sees this.** It reaches Core's provider and nothing else; the
* `MailProvider` seam speaks only in validated messages, and there is no accessor anywhere that
* could return the host, the port, or the password (ADR 0037).
*/
export interface MailProviderConfig {
readonly host: string;
readonly port: number;
readonly security: MailSecurityMode;
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
* Workspace arrangement — has neither. */
readonly username?: string;
readonly password?: string;
readonly fromAddress: string;
readonly fromName?: string;
}
/** Conventional defaults per mode, so an operator who names a mode does not also have to remember a
* port number. Any port may still be set explicitly. */
const DEFAULT_PORTS: Readonly<Record<MailSecurityMode, number>> = Object.freeze({
"implicit-tls": 465,
plaintext: 25,
starttls: 587,
});
/** A bare hostname or IP literal: no scheme, no port, no path, no credentials. Anything richer is a
* connection string, and a connection string is where a password ends up in a log. */
const HOST_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/iu;
const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/u;
function validateHost(host: string): string {
const trimmed = host.trim();
if (trimmed.length === 0 || (!HOST_PATTERN.test(trimmed) && !IPV4_PATTERN.test(trimmed))) {
throw new MailConfigError("MAIL_HOST_INVALID");
}
return trimmed;
}
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
return DEFAULT_PORTS[security];
}
const parsed = typeof port === "number" ? port : Number(port.trim());
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new MailConfigError("MAIL_PORT_INVALID");
}
return parsed;
}
function validateSecurity(
security: string | undefined,
environment: MailEnvironment,
): MailSecurityMode {
// Defaulting to STARTTLS rather than to whatever the port implies: an omitted mode must never be
// the one that sends a password in the clear.
const mode = security === undefined || security.trim() === "" ? "starttls" : security.trim();
if (!(MAIL_SECURITY_MODES as readonly string[]).includes(mode)) {
throw new MailConfigError("MAIL_SECURITY_INVALID");
}
if (environment === "production" && mode === "plaintext") {
throw new MailConfigError("MAIL_SECURITY_INSECURE_IN_PRODUCTION");
}
return mode as MailSecurityMode;
}
/**
* Validates the host's outbound email configuration, or reports that this installation has none.
*
* **Outbound email is optional.** An installation with no `host` returns `undefined`, and that is a
* complete, valid deployment — the platform starts, every existing feature works, and only a future
* feature that actually needs to send is affected, which it discovers as a structured
* `MAIL_NOT_CONFIGURED` rather than a crash (ADR 0037).
*
* Credentials are validated as a **pair**. Many SMTP examples assume a username and password, but a
* relay authorized by source IP — which is how Google Workspace SMTP Relay is commonly deployed —
* has neither, so requiring them would make the intended production arrangement unconfigurable.
* Half a pair is always a mistake, though: it means someone set one variable and expected
* authentication, so it fails loudly instead of silently connecting anonymously.
*/
export function createMailProviderConfig(
input: MailProviderConfigInput | undefined,
options: { readonly environment?: MailEnvironment } = {},
): MailProviderConfig | undefined {
const host = input?.host?.trim();
if (host === undefined || host === "") {
return undefined;
}
const environment = options.environment ?? "production";
const security = validateSecurity(input?.security, environment);
const validatedHost = validateHost(host);
const port = validatePort(input?.port, security);
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
const password = input?.password === "" ? undefined : input?.password;
if (username !== undefined && password === undefined) {
throw new MailConfigError("MAIL_USERNAME_WITHOUT_PASSWORD");
}
if (password !== undefined && username === undefined) {
throw new MailConfigError("MAIL_PASSWORD_WITHOUT_USERNAME");
}
const fromAddress = input?.fromAddress?.trim();
if (fromAddress === undefined || fromAddress === "") {
throw new MailConfigError("MAIL_FROM_ADDRESS_MISSING");
}
if (!isValidMailAddress(fromAddress)) {
throw new MailConfigError("MAIL_FROM_ADDRESS_INVALID");
}
const fromName = input?.fromName?.trim() === "" ? undefined : input?.fromName?.trim();
if (fromName !== undefined && !isValidMailDisplayName(fromName)) {
throw new MailConfigError("MAIL_FROM_NAME_INVALID");
}
return Object.freeze({
fromAddress,
host: validatedHost,
port,
security,
...(fromName === undefined ? {} : { fromName }),
...(username === undefined || password === undefined ? {} : { password, username }),
});
}
/**
* A one-line operator-facing description that is safe to print anywhere — Doctor output, a log line,
* a support transcript.
*
* It names the host, port, security mode, and sender, and states **whether** authentication is
* configured without ever naming the username or password. `describeStorageProvider` sets the same
* precedent.
*/
export function describeMailProvider(config: MailProviderConfig): string {
const sender =
config.fromName === undefined
? config.fromAddress
: `${config.fromName} <${config.fromAddress}>`;
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
config.username === undefined ? "unauthenticated relay" : "authenticated"
}`;
}