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
+234
View File
@@ -0,0 +1,234 @@
/**
* The one transactional message shape Ekklesia sends, and the boundary that validates it.
*
* **There is deliberately no `from` field.** The sender is host configuration
* (`MailProviderConfig.fromAddress`), so no caller — not Core, not a future module — can choose who
* a message appears to come from. Letting feature code pick a sender is how a platform acquires an
* impersonation primitive and destroys its own domain reputation at the same time (ADR 0037).
*
* Also deliberately absent: CC, BCC, attachments, custom headers, and templates. Identity
* verification, password reset, and invitations each need exactly one recipient and a short body.
* Every field beyond that is surface a future caller could abuse, so it stays out until something
* real needs it.
*/
export type MailErrorCode =
| "MAIL_MESSAGE_INVALID"
| "MAIL_NOT_CONFIGURED"
| "MAIL_PROVIDER_UNAVAILABLE"
| "MAIL_RECIPIENT_INVALID"
| "MAIL_REJECTED"
| "MAIL_SEND_FAILED";
const mailErrorMessages: Readonly<Record<MailErrorCode, string>> = Object.freeze({
MAIL_MESSAGE_INVALID: "The message is not one this contract accepts.",
MAIL_NOT_CONFIGURED: "This installation has no outbound email provider configured.",
MAIL_PROVIDER_UNAVAILABLE: "The outbound email service could not be reached.",
MAIL_RECIPIENT_INVALID: "The recipient address is not a valid email address.",
MAIL_REJECTED: "The outbound email service refused the message.",
MAIL_SEND_FAILED: "The message could not be sent.",
});
/**
* A sanitized mail failure.
*
* Carries a code and a fixed message and **nothing else** — no SMTP host, no port, no username, no
* password, no server greeting, no auth challenge, no provider response text, and no caught cause.
* SMTP servers routinely echo the envelope and sometimes the credential state back in their reply
* text, so forwarding a provider error verbatim is how a password reaches a log. It follows the same
* discipline as `ModuleStorageError` and `StorageConfigError`.
*/
export class MailError extends Error {
readonly code: MailErrorCode;
constructor(code: MailErrorCode) {
super(mailErrorMessages[code]);
this.name = "MailError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Bounds, chosen to be generous for a transactional message and far too small for a newsletter.
*
* A verification email is a couple of kilobytes. These leave two orders of magnitude of headroom
* while making it structurally impossible to hand the provider a megabyte, or to turn one call into
* a mailing list.
*/
export const MAIL_MAX_RECIPIENTS = 10;
export const MAIL_MAX_SUBJECT_LENGTH = 200;
export const MAIL_MAX_TEXT_BYTES = 64 * 1024;
export const MAIL_MAX_HTML_BYTES = 256 * 1024;
/** RFC 5321 caps a path at 256 octets; the local part and domain limits sit inside that. */
export const MAIL_MAX_ADDRESS_LENGTH = 254;
export const MAIL_MAX_DISPLAY_NAME_LENGTH = 128;
export interface MailMessage {
/** One address, or a small bounded list. */
readonly to: string | readonly string[];
readonly subject: string;
/** At least one of `text` or `html` is required — see `validateMailMessage`. */
readonly text?: string;
readonly html?: string;
/** Where a human reply should go, when that is not the host's unattended sender. */
readonly replyTo?: string;
}
/** The message after validation: recipients normalized to a list, and every field proven safe to
* hand a provider. A provider receives only this, never the caller's raw input. */
export interface ValidatedMailMessage {
readonly to: readonly string[];
readonly subject: string;
readonly text?: string;
readonly html?: string;
readonly replyTo?: string;
}
/**
* Whether a string is free of everything that could break out of the field it is placed in.
*
* A mail header is line-oriented, so a carriage return or line feed in a subject, an address, or a
* display name lets a caller append headers of its own — a `Bcc:` to somewhere else, a replaced
* `From:`, or an entire second message body. This is the classic email header injection, and it is
* why these characters are **refused** rather than stripped: silently rewriting a caller's input
* means the value that was validated is not the value that gets sent, and the two can drift.
*
* Every other C0 control, DEL, and the C1 range go with them. None has a legitimate place in a
* transactional subject line, and several are interpreted by intermediaries.
*/
function hasUnsafeCharacters(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined) {
return true;
}
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return true;
}
}
return false;
}
/**
* A deliberately conservative address check.
*
* This is not an RFC 5322 parser and does not try to be: the point is to reject what is dangerous or
* obviously wrong before it reaches the provider, and to let the provider be authoritative about
* exotic-but-legal mailboxes. What it does guarantee is that the value is a single, bare
* `local@domain` with no display name, no angle brackets, no comma, no semicolon, and no control
* character — so it cannot smuggle a second recipient or a header break through a field the caller
* believes holds one address.
*/
export function isValidMailAddress(value: unknown): value is string {
if (typeof value !== "string") {
return false;
}
// Checked **before** trimming, deliberately. Trimming first would silently strip a trailing
// carriage return and accept the address, which contradicts the rule above: a control character in
// an address is refused, never repaired. Surrounding ordinary whitespace is still tolerated,
// because that is a copy-paste artefact rather than an injection attempt.
if (hasUnsafeCharacters(value)) {
return false;
}
const address = value.trim();
if (address.length === 0 || address.length > MAIL_MAX_ADDRESS_LENGTH) {
return false;
}
// No grouping or list punctuation: one address means one address.
if (/[,;<>"\\()[\]\s]/u.test(address)) {
return false;
}
const at = address.lastIndexOf("@");
if (at <= 0 || at === address.length - 1) {
return false;
}
const local = address.slice(0, at);
const domain = address.slice(at + 1);
if (local.length > 64 || local.includes("@")) {
return false;
}
// A domain of at least two dot-separated labels, each alphanumeric with internal hyphens. This
// rejects a bare hostname, which is legal SMTP but never a real transactional recipient and is a
// common shape for a typo.
return /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/iu.test(
domain,
);
}
/** A display name that can be placed in a `From:` without escaping surprises. Quotes and backslashes
* are refused rather than escaped, for the same reason control characters are. */
export function isValidMailDisplayName(value: unknown): value is string {
return (
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= MAIL_MAX_DISPLAY_NAME_LENGTH &&
!hasUnsafeCharacters(value) &&
!/["\\]/u.test(value)
);
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
/**
* Validates one message and normalizes its recipients.
*
* Recipient problems get their own code because they are the one failure an operator or a user can
* usually act on — a mistyped address — while everything else here is a programming error in the
* calling feature.
*/
export function validateMailMessage(message: MailMessage): ValidatedMailMessage {
const recipients = (Array.isArray(message.to) ? message.to : [message.to]) as readonly unknown[];
if (recipients.length === 0 || recipients.length > MAIL_MAX_RECIPIENTS) {
throw new MailError("MAIL_RECIPIENT_INVALID");
}
const to: string[] = [];
for (const recipient of recipients) {
if (!isValidMailAddress(recipient)) {
throw new MailError("MAIL_RECIPIENT_INVALID");
}
to.push(recipient.trim());
}
if (new Set(to.map((address) => address.toLowerCase())).size !== to.length) {
throw new MailError("MAIL_RECIPIENT_INVALID");
}
const subject = message.subject;
if (
typeof subject !== "string" ||
subject.trim().length === 0 ||
subject.length > MAIL_MAX_SUBJECT_LENGTH ||
hasUnsafeCharacters(subject)
) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
// At least one body. A message with neither is not a message, and silently sending an empty one
// would be worse than refusing it.
const hasText = typeof message.text === "string" && message.text.length > 0;
const hasHtml = typeof message.html === "string" && message.html.length > 0;
if (!hasText && !hasHtml) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
if (hasText && byteLength(message.text as string) > MAIL_MAX_TEXT_BYTES) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
if (hasHtml && byteLength(message.html as string) > MAIL_MAX_HTML_BYTES) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
if (message.replyTo !== undefined && !isValidMailAddress(message.replyTo)) {
throw new MailError("MAIL_MESSAGE_INVALID");
}
return Object.freeze({
to: Object.freeze(to),
subject,
...(hasText ? { text: message.text as string } : {}),
...(hasHtml ? { html: message.html as string } : {}),
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo.trim() }),
});
}