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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user