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