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
+206
View File
@@ -0,0 +1,206 @@
import { createTransport, type Transporter } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
import type { MailProviderConfig } from "./config.js";
import { MailError } from "./message.js";
import type { MailProvider, MailSendResult } from "./provider.js";
/**
* Ekklesia's first real `MailProvider`, speaking SMTP.
*
* **It is not named after a service, and it must not become one.** No provider's hostname appears
* anywhere in this file, and a test asserts that. Everything specific to a deployment arrives as
* validated configuration, so the same code addresses a Google Workspace relay, Microsoft 365, a
* transactional provider's SMTP endpoint, a local Postfix, or anything else that speaks the
* protocol (ADR 0037).
*
* Server-only, and structurally so: this module imports Nodemailer and reaches the network, and no
* browser package can import it — a surface cannot import Core at all, which the boundary checker
* enforces.
*/
/**
* How long a single SMTP exchange may take before it is abandoned.
*
* These exist because the object-storage pass learned the lesson the hard way: a network client left
* on its library's defaults will wait indefinitely, and a provider that accepts a connection and
* then goes silent holds the calling request open forever. Nodemailer's own defaults are two
* minutes for a connection and ten for a socket, which is not a deadline anyone would choose for a
* password-reset email a person is waiting on.
*
* `socketTimeout` is the most generous of the three because it also covers transmitting the message
* body, and it is inactivity-based rather than total elapsed time, so a large message is never cut
* off for being large — only for going quiet. A wholly unresponsive server therefore surfaces as a
* structured `MAIL_PROVIDER_UNAVAILABLE` in well under a minute rather than never.
*/
export interface SmtpTransportBounds {
/** Milliseconds allowed to establish the TCP connection. */
readonly connectionTimeoutMs: number;
/** Milliseconds allowed for the server's opening greeting. */
readonly greetingTimeoutMs: number;
/** Milliseconds the socket may sit idle mid-exchange; resets whenever bytes move. */
readonly socketTimeoutMs: number;
}
const DEFAULT_TRANSPORT_BOUNDS: SmtpTransportBounds = Object.freeze({
connectionTimeoutMs: 10_000,
greetingTimeoutMs: 10_000,
socketTimeoutMs: 30_000,
});
export const SMTP_CONNECTION_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.connectionTimeoutMs;
export const SMTP_GREETING_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.greetingTimeoutMs;
export const SMTP_SOCKET_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.socketTimeoutMs;
interface SmtpLikeError {
readonly code?: unknown;
readonly responseCode?: unknown;
readonly rejected?: unknown;
}
function errorCode(error: unknown): string {
const candidate = error as SmtpLikeError | null;
return typeof candidate?.code === "string" ? candidate.code : "";
}
function responseCode(error: unknown): number | undefined {
const candidate = error as SmtpLikeError | null;
return typeof candidate?.responseCode === "number" ? candidate.responseCode : undefined;
}
/**
* Reduces any Nodemailer or socket failure to one of the contract's closed codes.
*
* **Nothing from the caught error survives this function** — not its message, not the server's
* response text, not the greeting, not the auth challenge, and not the envelope. SMTP servers
* routinely echo back what was sent to them, and an authentication failure's response can name the
* account; forwarding any of that verbatim is how a credential or a recipient reaches a log.
*/
export function translateMailError(error: unknown): MailError {
const code = errorCode(error);
const status = responseCode(error);
// A named recipient the server refused: the one failure a human can usually act on, so it keeps
// its own code rather than collapsing into a generic send failure.
if (code === "EENVELOPE") {
const rejected = (error as SmtpLikeError).rejected;
if (Array.isArray(rejected) && rejected.length > 0) {
return new MailError("MAIL_RECIPIENT_INVALID");
}
}
// Connection-level failures, including both timeouts configured above.
if (
code === "ECONNECTION" ||
code === "ESOCKET" ||
code === "ETIMEDOUT" ||
code === "EDNS" ||
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EHOSTUNREACH" ||
code === "ENOTFOUND"
) {
return new MailError("MAIL_PROVIDER_UNAVAILABLE");
}
if (status !== undefined) {
// 4xx is SMTP's "try later" and 5xx its "no". Splitting on that is what lets a caller tell a
// transient outage from a message that will never be accepted.
return status >= 500
? new MailError("MAIL_REJECTED")
: new MailError("MAIL_PROVIDER_UNAVAILABLE");
}
return new MailError("MAIL_SEND_FAILED");
}
function transportOptions(
config: MailProviderConfig,
bounds: SmtpTransportBounds,
): SMTPTransport.Options {
return {
// Bounded on every axis. See the constants above.
connectionTimeout: bounds.connectionTimeoutMs,
// Defence in depth: this platform never sends attachments, so Nodemailer must never be able to
// read a local file or fetch a URL on the strength of something that reached a message body.
disableFileAccess: true,
disableUrlAccess: true,
greetingTimeout: bounds.greetingTimeoutMs,
host: config.host,
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
// platform sending a handful of transactional messages buys nothing and makes the failure modes
// harder to reason about.
port: config.port,
// `secure` means implicit TLS from the first byte. For STARTTLS it must be false and the upgrade
// demanded separately, below.
secure: config.security === "implicit-tls",
socketTimeout: bounds.socketTimeoutMs,
// The downgrade guard. With `requireTLS`, a STARTTLS transport refuses to continue in the clear
// if the server does not offer the upgrade — so a server that stops advertising it, or an
// attacker who strips it from the EHLO response, produces a failed send rather than a plaintext
// session carrying a password. `ignoreTLS` is the opposite instruction and is set only for the
// mode that has explicitly asked for no TLS at all, which production configuration refuses.
...(config.security === "starttls" ? { requireTLS: true } : {}),
...(config.security === "plaintext" ? { ignoreTLS: true } : {}),
// Certificate verification is left at Nodemailer's default, which verifies. There is
// deliberately no configuration path that could switch it off, and a test asserts this file
// never names the option: a setting that disables verification is one someone eventually
// disables in production.
...(config.username === undefined || config.password === undefined
? {}
: { auth: { pass: config.password, user: config.username } }),
};
}
export function createSmtpMailProvider(
config: MailProviderConfig,
/** Tightened only by the live provider suite, so it can prove a deadline actually fires without
* spending the production budget waiting for it. Hosts always take the defaults. */
bounds: Partial<SmtpTransportBounds> = {},
): MailProvider {
const transporter: Transporter<SMTPTransport.SentMessageInfo> = createTransport(
transportOptions(config, { ...DEFAULT_TRANSPORT_BOUNDS, ...bounds }),
);
// Built once, from host configuration, and applied to every message. A caller cannot reach it and
// there is no per-message override, so no feature can send as anyone else.
const from =
config.fromName === undefined
? { address: config.fromAddress, name: "" }
: { address: config.fromAddress, name: config.fromName };
const provider: MailProvider = {
async close(): Promise<void> {
transporter.close();
},
async send(message): Promise<MailSendResult> {
try {
const accepted = await transporter.sendMail({
from,
subject: message.subject,
to: [...message.to],
...(message.text === undefined ? {} : { text: message.text }),
...(message.html === undefined ? {} : { html: message.html }),
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
});
// Exactly one attempt. Nodemailer does not retry a `sendMail` on its own, and nothing here
// adds one: a failure after the message data has gone out is ambiguous, and retrying an
// ambiguous send is how one password-reset request becomes three emails (ADR 0037).
const messageId = (accepted as { messageId?: unknown }).messageId;
return Object.freeze(typeof messageId === "string" ? { messageId } : {});
} catch (error) {
throw translateMailError(error);
}
},
async verify(): Promise<void> {
try {
await transporter.verify();
} catch (error) {
throw translateMailError(error);
}
},
};
return Object.freeze(provider);
}