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
+8
View File
@@ -46,6 +46,10 @@
"./devices": {
"types": "./dist/devices/index.d.ts",
"import": "./dist/devices/index.js"
},
"./mail": {
"types": "./dist/mail/index.d.ts",
"import": "./dist/mail/index.js"
}
},
"scripts": {
@@ -62,14 +66,18 @@
"@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23",
"drizzle-orm": "0.45.2",
"nodemailer": "9.0.5",
"pg": "8.22.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"auth": "1.6.23",
"drizzle-kit": "0.31.10",
"smtp-server": "3.19.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+12 -1
View File
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
diagnoseEkklesia,
type DoctorOptions,
diagnoseEkklesia,
doctorExitCode,
formatDoctorReport,
} from "../diagnostics/index.js";
@@ -96,6 +96,17 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
},
databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(),
// Raw, exactly like the identity settings above: Doctor validates them and reports a sanitized
// code, so a malformed SMTP value appears in the report instead of crashing the command.
mailConfiguration: {
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
host: process.env.EKKLESIA_SMTP_HOST,
password: process.env.EKKLESIA_SMTP_PASSWORD,
port: process.env.EKKLESIA_SMTP_PORT,
security: process.env.EKKLESIA_SMTP_SECURITY,
username: process.env.EKKLESIA_SMTP_USERNAME,
},
moduleDiscovery: {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
+120 -18
View File
@@ -4,25 +4,26 @@ import {
EMPTY_MODULE_SECRET_SOURCE,
isVersionCompatible,
logicalBucketIdSchema,
type ModuleSecretSource,
moduleConfigurationKeySchema,
moduleIdSchema,
type ModuleSecretSource,
permissionIdSchema,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import type { PostgresPoolOptions } from "../database/pool.js";
import {
assertProductionDatabaseTransport,
type DatabaseTransportEnvironment,
type DatabaseTransportPolicyInput,
DatabaseTransportError,
createDatabaseTransportPolicy,
type DatabaseTransportEnvironment,
DatabaseTransportError,
type DatabaseTransportPolicyInput,
describeDatabaseTransport,
} from "../database/transport.js";
import {
type ClientAddressConfigInput,
ClientAddressConfigError,
type ClientAddressConfigInput,
createClientAddressConfig,
} from "../identity/client-address.js";
import {
@@ -32,7 +33,14 @@ import {
} from "../identity/config.js";
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
import { identityAdapterSchema } from "../identity/schema.js";
import type { PostgresPoolOptions } from "../database/pool.js";
import {
createMailProviderConfig,
createSmtpMailProvider,
describeMailProvider,
MailConfigError,
type MailProviderConfig,
type MailProviderConfigInput,
} from "../mail/index.js";
import {
createPostgresMigrationDatabase,
discoverAllMigrations,
@@ -43,16 +51,8 @@ import {
type PostgresMigrationDatabaseOptions,
planModuleMigrations,
} from "../migrations/index.js";
import {
createS3StorageProvider,
describeStorageProvider,
physicalBucketName,
type StorageProviderConfig,
} from "../storage/index.js";
import {
composePlatformPermissionRegistry,
isConfigurationBlockedDetail,
isStorageBlockedDetail,
createModuleConfigurationDatabase,
createModuleConfigurationStore,
createModuleLifecycleDatabase,
@@ -60,15 +60,23 @@ import {
type DiscoveredModule,
discoverModuleSchemas,
discoverModules,
type ModuleEligibility,
ModulePermissionCompositionError,
type ModuleDiscoveryOptions,
isConfigurationBlockedDetail,
isStorageBlockedDetail,
type ModuleConfigurationSettings,
type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration,
type ModuleMigrationAggregate,
ModulePermissionCompositionError,
type ModuleSchemaAggregate,
resolveModuleEligibility,
} from "../modules/index.js";
import {
createS3StorageProvider,
describeStorageProvider,
physicalBucketName,
type StorageProviderConfig,
} from "../storage/index.js";
export type DoctorCheckId =
| "database.transport"
@@ -85,7 +93,8 @@ export type DoctorCheckId =
| "identity.configuration"
| "identity.client-address"
| "object-storage.integration"
| "object-storage.bindings";
| "object-storage.bindings"
| "outbound-email.configuration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
@@ -126,6 +135,15 @@ export interface DoctorOptions {
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
* never receives raw environment values and never prints a credential. */
readonly storage?: StorageProviderConfig | undefined;
/**
* The host's outbound mail settings as the executable read them, validated here.
*
* Deliberately **raw input** rather than a validated config, exactly like `identityConfiguration`:
* a malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has
* to be the one that validates it and reports the sanitized code. A pre-validated option would
* mean a bad value crashed the command instead of appearing in its report.
*/
readonly mailConfiguration?: MailProviderConfigInput | undefined;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
@@ -166,6 +184,9 @@ export interface DoctorDependencies {
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
}
const doctorPostgresTimeoutMs = 10_000;
@@ -220,6 +241,22 @@ async function probeStorageForRuntime(config: StorageProviderConfig): Promise<vo
}
}
/**
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
*
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
* time an operator ran it would be its own incident.
*/
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const provider = createSmtpMailProvider(config);
try {
await provider.verify();
} finally {
await provider.close().catch(() => undefined);
}
}
const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase,
@@ -227,6 +264,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
probeMail: probeMailForRuntime,
probeStorage: probeStorageForRuntime,
readModuleConfiguration: readModuleConfigurationForRuntime,
readModuleLifecycle: readModuleLifecycleForRuntime,
@@ -1081,6 +1119,69 @@ export async function diagnoseEkklesia(
"Complete module discovery is required.",
]);
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
// (ADR 0037).
let mailCheck: DoctorCheck;
let mailConfig: MailProviderConfig | undefined;
let mailConfigurationError: string | undefined;
try {
mailConfig = createMailProviderConfig(options.mailConfiguration, {
environment: options.databaseEnvironment === "development" ? "development" : "production",
});
} catch (error) {
// The sanitized code only — never the host, the username, or the password.
mailConfigurationError =
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
}
if (mailConfigurationError !== undefined) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"failure",
"Outbound email configuration invalid",
[
`The SMTP settings were rejected: ${mailConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
],
);
} else if (!mailConfig) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"skipped",
"Outbound email not configured",
[
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.",
],
);
} else {
try {
await dependencies.probeMail(mailConfig);
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
describeMailProvider(mailConfig),
// Said plainly: this connected and, where credentials are set, authenticated. It did not
// send anything, so it proves the relay accepts this deployment — not that a message would
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
// policy and cannot be established from here.
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
]);
} catch {
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
// declines a bare connectivity check — must not make the deployment read as broken.
mailCheck = freezeCheck(
"outbound-email.configuration",
"warning",
"Outbound email unreachable",
[
describeMailProvider(mailConfig),
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
],
);
}
}
return publishReport([
runtimeCheck,
moduleCheck,
@@ -1097,5 +1198,6 @@ export async function diagnoseEkklesia(
clientAddressDoctorCheck,
storageCheck,
storageBindings,
mailCheck,
]);
}
+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"
}`;
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./config.js";
export * from "./message.js";
export * from "./provider.js";
export * from "./smtp-provider.js";
+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() }),
});
}
+51
View File
@@ -0,0 +1,51 @@
import type { ValidatedMailMessage } from "./message.js";
/**
* The seam between Ekklesia's mail contract and whatever actually delivers a message.
*
* This is the **portability boundary made executable**, exactly as `StorageProvider` is for object
* storage. Everything above it — the message contract, validation, the sender policy, diagnostics —
* is provider-agnostic and would be identical under a future standalone host. Everything below it is
* one implementation (`createSmtpMailProvider`) that could be replaced by a provider HTTP API
* without any calling feature changing a line.
*
* It speaks only in **already-validated** messages: recipients, subject, and bodies have been
* checked and normalized by `validateMailMessage` before anything reaches here, so a provider never
* re-implements those rules and two providers can never disagree about what a caller may send.
*
* Deliberately absent: any way to ask for the sender, the host, the port, or a credential. A
* provider is given configuration at construction and exposes none of it afterwards.
*/
export interface MailProvider {
/**
* Sends one message. Resolves with a provider message id when the service accepted it.
*
* **This is a single bounded attempt, not a delivery guarantee.** Acceptance means the next hop
* took responsibility, not that anyone received anything — and a failure after the message data
* has been transmitted is genuinely ambiguous, because the server may have accepted it and only
* the acknowledgement was lost. Nothing here retries; see ADR 0037.
*/
send(message: ValidatedMailMessage): Promise<MailSendResult>;
/**
* Opens a connection and, when credentials are configured, authenticates — then closes, **without
* sending anything**. For diagnostics only.
*
* Its result is never persisted and never influences module lifecycle state: a mail service being
* briefly unreachable is an operational condition, not an administrative decision.
*/
verify(): Promise<void>;
/** Releases provider connections. Called once, during API shutdown. */
close(): Promise<void>;
}
export interface MailSendResult {
/**
* The provider's own identifier for the accepted message, when it supplies one.
*
* Safe to log: it identifies a message in the provider's records and contains no recipient, no
* subject, and no body. It is deliberately the *only* thing about a sent message this contract
* hands back, because a verification or reset email's contents are exactly what must not end up
* in an operator's log (ADR 0037).
*/
readonly messageId?: string;
}
+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);
}
+3
View File
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
planMigrations: async () => {
throw new Error("database must not be planned");
},
probeMail: async () => {
throw new Error("mail must not be probed");
},
probeStorage: async () => {
throw new Error("storage must not be probed");
},
+95
View File
@@ -138,6 +138,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
discoverModules: vi.fn(async () => Object.freeze([])),
discoverSchemas: vi.fn(async () => schemaAggregate()),
planMigrations: vi.fn(async () => plan()),
probeMail: vi.fn(async () => undefined),
probeStorage: vi.fn(async () => undefined),
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
@@ -187,6 +188,99 @@ function planEntry(
});
}
describe("Ekklesia Doctor outbound email reporting", () => {
const mailConfiguration = {
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
password: "sup3r-secret-relay-password",
security: "starttls",
username: "[email protected]",
};
it("reports an installation with no SMTP host as complete rather than degraded", async () => {
const services = dependencies();
const report = await diagnoseEkklesia(options(), services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("skipped");
expect(mailCheck.summary).toBe("Outbound email not configured");
// Nothing sends email yet, so an absent provider must not make a deployment read as broken.
expect(report.result).not.toBe("not-ready");
expect(services.probeMail).not.toHaveBeenCalled();
});
it("reports a reachable provider without ever naming a credential", async () => {
const services = dependencies();
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("pass");
const rendered = mailCheck.details.join(" ");
expect(rendered).toContain("smtp.example.org:587");
expect(rendered).toContain("starttls");
expect(rendered).toContain("authenticated");
expect(rendered).not.toContain("sup3r-secret-relay-password");
expect(rendered).not.toContain("[email protected]");
// Honest about what a connection proves: it did not send anything, and deliverability is not
// something this check can establish.
expect(rendered).toMatch(/did not|without sending/iu);
});
it("warns rather than failing when a configured relay does not answer", async () => {
const services = dependencies({
probeMail: vi.fn(async () => {
throw new Error("connection refused to smtp.example.org with sup3r-secret-relay-password");
}),
});
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
const mailCheck = check(report, "outbound-email.configuration");
// A warning, never a failure: the configuration is valid and nothing depends on mail yet.
expect(mailCheck.status).toBe("warning");
expect(report.result).not.toBe("not-ready");
// The thrown value carried a credential; none of it reaches the report.
expect(mailCheck.details.join(" ")).not.toContain("sup3r-secret-relay-password");
});
it("fails a malformed configuration with a sanitized code and probes nothing", async () => {
const services = dependencies();
const report = await diagnoseEkklesia(
{ ...options(), mailConfiguration: { ...mailConfiguration, port: "not-a-port" } },
services,
);
const mailCheck = check(report, "outbound-email.configuration");
expect(mailCheck.status).toBe("failure");
const rendered = mailCheck.details.join(" ");
expect(rendered).toContain("MAIL_PORT_INVALID");
expect(rendered).not.toContain("sup3r-secret-relay-password");
// A configuration this broken is never connected to.
expect(services.probeMail).not.toHaveBeenCalled();
});
it("refuses plaintext SMTP when the deployment validates as production", async () => {
const services = dependencies();
const report = await diagnoseEkklesia(
{
...options(),
databaseEnvironment: "production",
mailConfiguration: { ...mailConfiguration, security: "plaintext" },
},
services,
);
expect(check(report, "outbound-email.configuration").details.join(" ")).toContain(
"MAIL_SECURITY_INSECURE_IN_PRODUCTION",
);
});
});
describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people");
@@ -219,6 +313,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"identity.client-address",
"object-storage.integration",
"object-storage.bindings",
"outbound-email.configuration",
]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
+157
View File
@@ -0,0 +1,157 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Structural guarantees about outbound email that no runtime assertion can make (ADR 0037).
*
* These read the repository itself, because the properties that matter here are about what code
* *exists* and what depends on what — a mail credential that never reaches a browser bundle, and a
* platform that offers no route anyone could use as an open relay.
*/
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function readJson(relativePath: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(path.join(repositoryRoot, relativePath), "utf8")) as Record<
string,
unknown
>;
}
async function readTextFiles(directory: string): Promise<Array<{ path: string; text: string }>> {
const { readdir } = await import("node:fs/promises");
const results: Array<{ path: string; text: string }> = [];
const walk = async (current: string): Promise<void> => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") {
continue;
}
await walk(full);
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
results.push({ path: full, text: await readFile(full, "utf8") });
}
}
};
await walk(path.join(repositoryRoot, directory));
return results;
}
describe("outbound email stays server-only", () => {
it("declares the SMTP dependency in Core alone", async () => {
const core = await readJson("core/package.json");
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
// Every package a browser can end up executing, plus the contracts barrel a surface must not
// reach. None may carry a mail transport.
for (const manifest of [
"packages/contracts/package.json",
"packages/module-sdk/package.json",
"packages/module-ui-sdk/package.json",
"packages/module-ui-host/package.json",
"surfaces/shell/package.json",
"apps/api/package.json",
]) {
const parsed = await readJson(manifest);
const dependencies = {
...((parsed.dependencies ?? {}) as Record<string, string>),
...((parsed.devDependencies ?? {}) as Record<string, string>),
};
const mailDependencies = Object.keys(dependencies).filter((name) =>
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name),
);
expect(mailDependencies, manifest).toEqual([]);
}
});
it("never imports the transport outside Core's own mail module", async () => {
for (const directory of [
"packages/contracts/src",
"packages/module-sdk/src",
"packages/module-ui-sdk/src",
"packages/module-ui-host/src",
"surfaces/shell/src",
]) {
for (const file of await readTextFiles(directory)) {
expect(file.text, file.path).not.toContain("nodemailer");
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
}
}
});
it("gives module code no way to send email at all", async () => {
// Deliberate scope: Core sends, modules do not (yet). `ModuleRuntimeServices` is the one place a
// module capability would appear, and it is added one service at a time when something real
// needs it — nothing does here (ADR 0037).
const services = await readFile(
path.join(repositoryRoot, "packages/contracts/src/module-database.ts"),
"utf8",
);
expect(services).not.toMatch(/mail|email/iu);
for (const directory of ["packages/module-sdk/src", "packages/module-ui-sdk/src"]) {
for (const file of await readTextFiles(directory)) {
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
}
}
});
it("exposes no HTTP route that could send mail", async () => {
// An authenticated relay endpoint is an open relay with extra steps. Nothing in the transport
// layer may reference the mail provider at all.
for (const file of await readTextFiles("apps/api/src")) {
if (file.path.endsWith("server.ts") || file.path.endsWith("config.ts")) {
// The composition root legitimately constructs and closes the provider.
continue;
}
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
}
for (const file of await readTextFiles("apps/api/src")) {
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*send-email[^"'`]*["'`]/u);
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*\/api\/v1\/mail[^"'`]*["'`]/u);
}
});
it("reads SMTP environment only in the executable that owns configuration", async () => {
// Reusable Core code never touches `process.env`; the composition root turns environment into
// validated configuration and injects it.
for (const file of await readTextFiles("core/src/mail")) {
expect(file.text, file.path).not.toContain("process.env");
}
const server = await readFile(path.join(repositoryRoot, "apps/api/src/server.ts"), "utf8");
expect(server).toContain("EKKLESIA_SMTP_HOST");
expect(server).toContain("EKKLESIA_SMTP_PASSWORD");
});
it("keeps no SMTP password out of the committed environment example", async () => {
const example = await readFile(path.join(repositoryRoot, ".env.example"), "utf8");
// Documented, but never with a value: a bootstrap credential in a committed file is a
// credential in every clone.
expect(example).toContain("EKKLESIA_SMTP_HOST");
expect(example).not.toMatch(/^EKKLESIA_SMTP_PASSWORD=.+$/mu);
expect(example).not.toContain("smtp-relay.gmail.com");
});
it("hardcodes no provider hostname in Core", async () => {
// Google Workspace is a deployment target, not the architecture.
for (const file of await readTextFiles("core/src/mail")) {
expect(file.text.toLowerCase(), file.path).not.toContain("gmail");
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay");
expect(file.text.toLowerCase(), file.path).not.toContain("resend");
expect(file.text.toLowerCase(), file.path).not.toContain("brevo");
}
});
it("never disables TLS certificate verification", async () => {
for (const file of await readTextFiles("core/src/mail")) {
expect(file.text, file.path).not.toContain("rejectUnauthorized");
expect(file.text, file.path).not.toContain("NODE_TLS_REJECT_UNAUTHORIZED");
}
});
});
+327
View File
@@ -0,0 +1,327 @@
import { describe, expect, it } from "vitest";
import {
createMailProviderConfig,
describeMailProvider,
MailConfigError,
} from "../src/mail/config.js";
import {
MAIL_MAX_HTML_BYTES,
MAIL_MAX_RECIPIENTS,
MAIL_MAX_SUBJECT_LENGTH,
MAIL_MAX_TEXT_BYTES,
MailError,
validateMailMessage,
} from "../src/mail/message.js";
/**
* The mail contract's boundary, with no provider and no network.
*
* Most of these are negative: the contract's job is to make an impossible message impossible to
* construct, so what it *refuses* is the interesting part (ADR 0037).
*/
const password = "sup3r-secret-relay-password";
const validConfig = {
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
security: "starttls",
};
describe("host mail configuration", () => {
it("treats an absent host as an installation that sends no email", () => {
// The whole platform must still start. Nothing sends mail yet, so this is a complete deployment.
expect(createMailProviderConfig(undefined)).toBeUndefined();
expect(createMailProviderConfig({})).toBeUndefined();
expect(createMailProviderConfig({ host: " " })).toBeUndefined();
});
it("accepts a relay with no credentials, which is how Google Workspace is usually deployed", () => {
const config = createMailProviderConfig(validConfig);
// Requiring a username and password — as most SMTP examples do — would make the intended
// production arrangement, a relay authorized by source IP, unconfigurable.
expect(config).toEqual({
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "smtp.example.org",
port: 587,
security: "starttls",
});
expect(config?.username).toBeUndefined();
expect(config?.password).toBeUndefined();
});
it("defaults the port per security mode, and defaults the mode to an encrypted one", () => {
expect(createMailProviderConfig({ ...validConfig, security: undefined })?.security).toBe(
"starttls",
);
expect(createMailProviderConfig({ ...validConfig, security: "implicit-tls" })?.port).toBe(465);
expect(createMailProviderConfig({ ...validConfig, security: "starttls" })?.port).toBe(587);
expect(createMailProviderConfig({ ...validConfig, port: "2525" })?.port).toBe(2525);
});
it("refuses half a credential pair in either direction", () => {
expect(() =>
createMailProviderConfig({ ...validConfig, username: "[email protected]" }),
).toThrow(expect.objectContaining({ code: "MAIL_USERNAME_WITHOUT_PASSWORD" }));
expect(() => createMailProviderConfig({ ...validConfig, password })).toThrow(
expect.objectContaining({ code: "MAIL_PASSWORD_WITHOUT_USERNAME" }),
);
// Both together is the authenticated case and is accepted.
expect(
createMailProviderConfig({ ...validConfig, password, username: "[email protected]" })
?.username,
).toBe("[email protected]");
});
it.each([
["a scheme", "smtp://smtp.example.org"],
["a port", "smtp.example.org:587"],
["credentials", "user:[email protected]"],
["a path", "smtp.example.org/relay"],
["a space", "smtp example org"],
["nothing but dots", "..."],
])("refuses an SMTP host containing %s", (_label, host) => {
// A host is a hostname, never a connection string — a connection string is how a password ends
// up in a log.
expect(() => createMailProviderConfig({ ...validConfig, host })).toThrow(
expect.objectContaining({ code: "MAIL_HOST_INVALID" }),
);
});
it.each([
["0"],
["65536"],
["-1"],
["587.5"],
["not-a-port"],
])("refuses the malformed port %s", (port) => {
expect(() => createMailProviderConfig({ ...validConfig, port })).toThrow(
expect.objectContaining({ code: "MAIL_PORT_INVALID" }),
);
});
it("treats an empty port as unset rather than malformed", () => {
expect(createMailProviderConfig({ ...validConfig, port: "" })?.port).toBe(587);
});
it("refuses an unknown security mode rather than guessing one", () => {
for (const security of ["tls", "ssl", "none", "STARTTLS", "true"]) {
expect(() => createMailProviderConfig({ ...validConfig, security }), security).toThrow(
expect.objectContaining({ code: "MAIL_SECURITY_INVALID" }),
);
}
});
it("refuses plaintext SMTP in production but allows it in development", () => {
expect(() =>
createMailProviderConfig(
{ ...validConfig, security: "plaintext" },
{ environment: "production" },
),
).toThrow(expect.objectContaining({ code: "MAIL_SECURITY_INSECURE_IN_PRODUCTION" }));
expect(
createMailProviderConfig(
{ ...validConfig, security: "plaintext" },
{ environment: "development" },
)?.security,
).toBe("plaintext");
});
it("requires a valid sender address and refuses an injectable display name", () => {
expect(() => createMailProviderConfig({ ...validConfig, fromAddress: undefined })).toThrow(
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_MISSING" }),
);
for (const fromAddress of [
"not-an-address",
"a@b",
"a@@b.org",
"a [email protected]",
"<[email protected]>",
]) {
expect(() => createMailProviderConfig({ ...validConfig, fromAddress }), fromAddress).toThrow(
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_INVALID" }),
);
}
for (const fromName of ['Ekklesia"', "Ekklesia\\", "Ekklesia\r\nBcc: [email protected]"]) {
expect(() => createMailProviderConfig({ ...validConfig, fromName })).toThrow(
expect.objectContaining({ code: "MAIL_FROM_NAME_INVALID" }),
);
}
});
it("never puts a credential into a configuration error or a description", () => {
const configured = createMailProviderConfig({
...validConfig,
password,
username: "[email protected]",
});
if (!configured) {
throw new Error("The mail configuration fixture produced no configuration.");
}
const described = describeMailProvider(configured);
// An operator sees where mail goes and whether it authenticates — never with what.
expect(described).toContain("smtp.example.org:587");
expect(described).toContain("starttls");
expect(described).toContain("[email protected]");
expect(described).toContain("authenticated");
expect(described).not.toContain(password);
expect(described).not.toContain("[email protected]");
const unauthenticated = createMailProviderConfig(validConfig);
if (!unauthenticated) {
throw new Error("The mail configuration fixture produced no configuration.");
}
expect(describeMailProvider(unauthenticated)).toContain("unauthenticated relay");
let caught: unknown;
try {
createMailProviderConfig({
...validConfig,
host: "smtp://bad",
password,
username: "[email protected]",
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailConfigError);
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain(password);
expect(rendered).not.toContain("smtp://bad");
});
});
describe("transactional message validation", () => {
const message = {
subject: "Verify your email address",
text: "Open the link to continue.",
to: "[email protected]",
};
it("accepts one recipient and normalizes it to a list", () => {
expect(validateMailMessage(message)).toEqual({
subject: "Verify your email address",
text: "Open the link to continue.",
to: ["[email protected]"],
});
});
it("has no sender field at all, so no caller can choose one", () => {
const validated = validateMailMessage({ ...message, html: "<p>Hi</p>", replyTo: "[email protected]" });
// The load-bearing structural fact: the sender comes from host configuration and there is
// nowhere in a message to put one.
expect(Object.keys(validated).sort()).toEqual(["html", "replyTo", "subject", "text", "to"]);
expect(validated).not.toHaveProperty("from");
});
it.each([
["a display name", "Person <[email protected]>"],
["a second address", "[email protected], [email protected]"],
["a semicolon list", "[email protected];[email protected]"],
["a line feed", "[email protected]\nBcc: [email protected]"],
["a carriage return", "[email protected]\r"],
["no domain dot", "person@localhost"],
["no local part", "@example.org"],
["nothing", ""],
])("refuses a recipient containing %s", (_label, to) => {
expect(() => validateMailMessage({ ...message, to })).toThrow(
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
);
});
it("bounds the recipient list and refuses duplicates", () => {
const many = Array.from(
{ length: MAIL_MAX_RECIPIENTS + 1 },
(_value, index) => `person${index}@example.org`,
);
expect(() => validateMailMessage({ ...message, to: many })).toThrow(
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
);
expect(() => validateMailMessage({ ...message, to: [] })).toThrow(
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
);
// Case-insensitively: the same mailbox twice would send one person two copies.
expect(() =>
validateMailMessage({ ...message, to: ["[email protected]", "[email protected]"] }),
).toThrow(expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }));
expect(
validateMailMessage({ ...message, to: many.slice(0, MAIL_MAX_RECIPIENTS) }).to,
).toHaveLength(MAIL_MAX_RECIPIENTS);
});
it("refuses a subject carrying a header break or no content", () => {
for (const subject of [
"Reset\r\nBcc: [email protected]",
"Reset\nX-Injected: 1",
" ",
"",
]) {
expect(() => validateMailMessage({ ...message, subject })).toThrow(
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
);
}
});
it("bounds the subject and both bodies", () => {
expect(() =>
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH + 1) }),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
expect(() =>
validateMailMessage({ ...message, text: "x".repeat(MAIL_MAX_TEXT_BYTES + 1) }),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
expect(() =>
validateMailMessage({
html: "x".repeat(MAIL_MAX_HTML_BYTES + 1),
subject: message.subject,
to: message.to,
}),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
// Exactly at the bound is accepted, so the limit is the limit.
expect(
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH) }).subject,
).toHaveLength(MAIL_MAX_SUBJECT_LENGTH);
});
it("requires at least one body, and accepts either alone", () => {
expect(() => validateMailMessage({ subject: message.subject, to: message.to })).toThrow(
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
);
expect(() => validateMailMessage({ ...message, text: "" })).toThrow(
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
);
expect(
validateMailMessage({ html: "<p>Hi</p>", subject: message.subject, to: message.to }).html,
).toBe("<p>Hi</p>");
});
it("refuses an injectable reply-to", () => {
expect(() =>
validateMailMessage({
...message,
replyTo: "[email protected]\r\nBcc: [email protected]",
}),
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
expect(validateMailMessage({ ...message, replyTo: "[email protected]" }).replyTo).toBe(
"[email protected]",
);
});
it("never echoes the message into the error", () => {
let caught: unknown;
try {
validateMailMessage({
...message,
subject: `secret-${password}` + "\r\nBcc: [email protected]",
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailError);
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain(password);
expect(rendered).not.toContain("[email protected]");
});
});
+357
View File
@@ -0,0 +1,357 @@
import type { AddressInfo } from "node:net";
import { createServer, type Server } from "node:net";
import { SMTPServer } from "smtp-server";
import { afterEach, describe, expect, it } from "vitest";
import { createMailProviderConfig, type MailProviderConfig } from "../src/mail/config.js";
import { MailError, validateMailMessage } from "../src/mail/message.js";
import type { MailProvider } from "../src/mail/provider.js";
import { createSmtpMailProvider } from "../src/mail/smtp-provider.js";
/**
* The SMTP provider against a **real SMTP server** speaking the real protocol.
*
* The unit suite proves the contract with no provider at all; this proves the things only a server
* can. Every claim here is a property of the agreement between Nodemailer and an SMTP server rather
* than of code in this repository: that the host's sender really is the one on the wire, that a
* refused recipient is distinguishable from an outage, that a silent server eventually gives up
* rather than hanging forever, and that a STARTTLS transport refuses to continue in the clear.
*
* `smtp-server` is used rather than a mock because it is the reference implementation from the same
* project as the client, it runs in-process, and it needs no container — so this adds no CI
* infrastructure and sends no real email. **Nothing here reaches the internet**, and no live
* credential exists anywhere in this suite.
*/
interface CapturedMessage {
readonly from: string;
readonly to: readonly string[];
readonly data: string;
}
interface Harness {
readonly port: number;
readonly captured: CapturedMessage[];
readonly close: () => Promise<void>;
}
const openHarnesses: Array<() => Promise<void>> = [];
const openProviders: MailProvider[] = [];
afterEach(async () => {
for (const provider of openProviders.splice(0)) {
await provider.close().catch(() => undefined);
}
for (const close of openHarnesses.splice(0)) {
await close().catch(() => undefined);
}
});
interface HarnessOptions {
/** When set, the server demands AUTH and accepts only this exact pair. */
readonly auth?: { readonly username: string; readonly password: string };
/** Rejects exactly these recipients at RCPT TO, as a real relay does for an unknown mailbox. */
readonly rejectRecipients?: readonly string[];
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
readonly offerStartTls?: boolean;
}
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
const captured: CapturedMessage[] = [];
const server = new SMTPServer({
authOptional: options.auth === undefined,
// Deliberately no TLS unless a scenario asks: proving the client's downgrade guard needs a
// server that does not offer the upgrade.
hideSTARTTLS: options.offerStartTls !== true,
onAuth(auth, _session, callback) {
if (
options.auth !== undefined &&
auth.username === options.auth.username &&
auth.password === options.auth.password
) {
callback(null, { user: auth.username });
return;
}
callback(new Error("Invalid credentials"));
},
onData(stream, session, callback) {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString("utf8");
});
stream.on("end", () => {
captured.push({
data,
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
to: session.envelope.rcptTo.map((entry) => entry.address),
});
callback();
});
},
onRcptTo(address, _session, callback) {
if (options.rejectRecipients?.includes(address.address)) {
// 550 is a permanent refusal of this mailbox, which is what a real relay returns.
const error = new Error("Mailbox unavailable") as Error & { responseCode?: number };
error.responseCode = 550;
callback(error);
return;
}
callback();
},
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.server.address() as AddressInfo;
const close = async (): Promise<void> => {
await new Promise<void>((resolve) => server.close(() => resolve()));
};
openHarnesses.push(close);
return { captured, close, port: address.port };
}
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
const built = createMailProviderConfig(
{
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "127.0.0.1",
port,
// The local test server speaks no TLS. Production configuration refuses this mode outright,
// which is exactly why the development environment has to be selected explicitly here.
security: "plaintext",
...overrides,
},
{ environment: "development" },
);
if (!built) {
throw new Error("The SMTP integration configuration is invalid.");
}
return built;
}
function provider(providerConfig: MailProviderConfig, bounds = {}): MailProvider {
const created = createSmtpMailProvider(providerConfig, bounds);
openProviders.push(created);
return created;
}
describe.sequential("live SMTP transport", () => {
it("delivers a transactional message with the host's sender, subject, and both bodies", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
const result = await mail.send(
validateMailMessage({
html: "<p>Open the link to continue.</p>",
subject: "Verify your email address",
text: "Open the link to continue.",
to: "[email protected]",
}),
);
expect(harness.captured).toHaveLength(1);
const message = harness.captured[0];
expect(message?.to).toEqual(["[email protected]"]);
// The envelope sender is the host's configured address — the caller never chose it and has no
// field in which to.
expect(message?.from).toBe("[email protected]");
expect(message?.data).toContain("From: Ekklesia <[email protected]>");
expect(message?.data).toContain("Subject: Verify your email address");
expect(message?.data).toContain("Open the link to continue.");
expect(message?.data).toContain("<p>Open the link to continue.</p>");
// A provider message id is the only thing that comes back, and it names no recipient.
expect(result.messageId).toMatch(/@/u);
expect(result.messageId ?? "").not.toContain("[email protected]");
});
it("sends to a small bounded list, and to exactly the addresses given", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await mail.send(
validateMailMessage({
subject: "Invitation",
text: "You are invited.",
to: ["[email protected]", "[email protected]"],
}),
);
expect(harness.captured[0]?.to).toEqual(["[email protected]", "[email protected]"]);
// No silent extra recipient: nothing in this platform adds a Bcc.
expect(harness.captured[0]?.data).not.toMatch(/^Bcc:/imu);
});
it("carries a reply-to without letting it become the sender", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await mail.send(
validateMailMessage({
replyTo: "[email protected]",
subject: "Notice",
text: "Body.",
to: "[email protected]",
}),
);
const data = harness.captured[0]?.data ?? "";
expect(data).toContain("Reply-To: [email protected]");
expect(data).toContain("From: Ekklesia <[email protected]>");
expect(harness.captured[0]?.from).toBe("[email protected]");
});
it("authenticates when the relay demands it, and reports a refusal as rejected", async () => {
const harness = await startSmtpServer({
auth: { password: "relay-password", username: "[email protected]" },
});
const authenticated = provider(
config(harness.port, { password: "relay-password", username: "[email protected]" }),
);
await expect(
authenticated.send(
validateMailMessage({ subject: "Authenticated", text: "Body.", to: "[email protected]" }),
),
).resolves.toMatchObject({});
expect(harness.captured).toHaveLength(1);
const wrong = provider(
config(harness.port, { password: "wrong-password", username: "[email protected]" }),
);
let caught: unknown;
try {
await wrong.send(
validateMailMessage({ subject: "Rejected", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailError);
expect((caught as MailError).code).toBe("MAIL_REJECTED");
// The server's response text — which can name the account — never reaches the caller.
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
expect(rendered).not.toContain("wrong-password");
expect(rendered).not.toContain("[email protected]");
expect(rendered).not.toContain("Invalid credentials");
// And nothing was delivered.
expect(harness.captured).toHaveLength(1);
});
it("distinguishes a refused mailbox from an outage", async () => {
const harness = await startSmtpServer({ rejectRecipients: ["[email protected]"] });
const mail = provider(config(harness.port));
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
// The one failure a human can usually act on keeps its own code.
expect((caught as MailError).code).toBe("MAIL_RECIPIENT_INVALID");
expect(harness.captured).toHaveLength(0);
});
it("reports a refused connection as provider-unavailable rather than hanging", async () => {
// A port nothing listens on: a real connection failure rather than a simulated one.
const mail = provider(config(1));
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(MailError);
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
});
it("gives up on a server that accepts the connection and then never greets", async () => {
// A refused connection fails fast on its own; a server that *answers* and then stalls is the
// failure with no natural end, and the one that has to be bounded deliberately. This is the
// lesson the object-storage pass learned the hard way.
const sockets = new Set<import("node:net").Socket>();
const blackHole: Server = createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve) => blackHole.listen(0, "127.0.0.1", resolve));
const port = (blackHole.address() as AddressInfo).port;
const mail = provider(config(port), {
// The production defaults are deliberately patient; this proves the mechanism, not the number.
connectionTimeoutMs: 300,
greetingTimeoutMs: 300,
socketTimeoutMs: 300,
});
const startedAt = Date.now();
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
const elapsed = Date.now() - startedAt;
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
// The point of the assertion is that it *ended*. Without a deadline Nodemailer waits minutes,
// and a person waiting on a password-reset email waits with it.
expect(elapsed).toBeLessThan(5_000);
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => blackHole.close(() => resolve()));
});
it("refuses to continue in the clear when STARTTLS was required but is not offered", async () => {
const harness = await startSmtpServer({ offerStartTls: false });
const mail = provider(config(harness.port, { security: "starttls" }));
let caught: unknown;
try {
await mail.send(
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
);
} catch (error) {
caught = error;
}
// The downgrade guard. A server that stops advertising STARTTLS — or an attacker who strips it
// — produces a failed send, never a plaintext session carrying a password.
expect(caught).toBeInstanceOf(MailError);
expect(harness.captured).toHaveLength(0);
});
it("verifies connectivity without sending anything", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await expect(mail.verify()).resolves.toBeUndefined();
// The assertion Doctor depends on: a diagnostic that emailed somebody every time an operator ran
// it would be its own incident.
expect(harness.captured).toHaveLength(0);
const unreachable = provider(config(1));
await expect(unreachable.verify()).rejects.toMatchObject({
code: "MAIL_PROVIDER_UNAVAILABLE",
});
});
it("sends exactly once, with no retry behind the caller's back", async () => {
const harness = await startSmtpServer();
const mail = provider(config(harness.port));
await mail.send(
validateMailMessage({ subject: "Once", text: "Body.", to: "[email protected]" }),
);
// One call, one message. Retrying an ambiguous send is how one password-reset request becomes
// three emails, so nothing here retries (ADR 0037).
expect(harness.captured).toHaveLength(1);
});
});