feat: add a host-provided outbound email transport #36

Merged
Zayin_Admin merged 1 commits from phase-7c/outbound-email into main 2026-08-24 23:46:22 +00:00
26 changed files with 2435 additions and 20 deletions
+33
View File
@@ -44,3 +44,36 @@ S3_BUCKET_PREFIX=ekklesia
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
# physical buckets are derived from a module's manifest declaration, not configured here.
S3_BUCKET=ekklesia-development
# Ekklesia's outbound email transport (ADR 0037). Absent EKKLESIA_SMTP_HOST means this installation
# sends no email, which is a complete deployment: nothing in the platform sends any yet, and a
# future feature that needs to is told so rather than crashing.
#
# These are host infrastructure. No module ever sees them, and no feature chooses its own sender:
# the From address below is the only one this platform can send as.
#
# EKKLESIA_SMTP_HOST is a bare hostname — never a URL, a port, or a connection string.
# EKKLESIA_SMTP_SECURITY is exactly one of:
# implicit-tls TLS from the first byte, conventionally port 465
# starttls plaintext connect then a *required* upgrade, conventionally 587 (the default)
# plaintext no TLS at all; development only, and production refuses it outright
# EKKLESIA_SMTP_PORT is optional and defaults to the conventional port for the mode.
#
# Credentials are optional **as a pair**, because a relay authorized by source IP has neither.
# Setting one without the other is refused rather than silently connecting anonymously. Never commit
# a real password: this file is in every clone.
#
# A Google Workspace SMTP Relay deployment typically looks like:
# EKKLESIA_SMTP_HOST=<the relay host from Google Workspace Admin>
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# [email protected]
# Whether it also needs a username and password depends on the relay's authorization setting in
# Google Workspace Admin, which varies per deployment. See docs/development/email.md.
# EKKLESIA_SMTP_HOST=
# EKKLESIA_SMTP_PORT=587
# EKKLESIA_SMTP_SECURITY=starttls
# EKKLESIA_SMTP_USERNAME=
# EKKLESIA_SMTP_PASSWORD=
# [email protected]
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
+35
View File
@@ -691,6 +691,41 @@ targeting, device groups, and mobile dashboards are deliberately deferred** —
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
identity at all.
Phase 7C pass 1 adds the platform's first **outbound email transport**
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
authenticate people and pair devices but could not send a single message, which is what blocked
email verification, password recovery, and invitations alike. Email is now a host-provided
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
this transactional message* and the provider owns the host, port, TLS, credentials, 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, not a platform dependency.
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
mail appears to come from. Control characters in addresses, subjects, and display names are refused
rather than stripped, which is what closes header injection.
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
relay authorized by source IP has neither — and the password is a deployment secret that never
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
single bounded attempt and deliberately not an exactly-once promise. There is **no
`POST /api/v1/send-email`** and no route that touches the provider.
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
sending anything**, while stating plainly that a successful connection does not establish
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
the seam they will use. See [Outbound email](docs/development/email.md).
Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
+25 -1
View File
@@ -1,8 +1,8 @@
import {
assertProductionDatabaseTransport,
createDatabaseTransportPolicy,
type DatabaseTransportPolicy,
type DatabaseTransportPolicyInput,
createDatabaseTransportPolicy,
} from "@ekklesia/core/database";
import {
type CreateIdentityConfigOptions,
@@ -10,6 +10,11 @@ import {
type IdentityConfig,
type IdentityConfigInput,
} from "@ekklesia/core/identity";
import {
createMailProviderConfig,
type MailProviderConfig,
type MailProviderConfigInput,
} from "@ekklesia/core/mail";
import {
createStorageProviderConfig,
type StorageProviderConfig,
@@ -70,6 +75,16 @@ export interface ApiRuntimeConfig {
* bundle, diagnostic, or API response ever sees the endpoint or the credentials.
*/
readonly storage: StorageProviderConfig | undefined;
/**
* The validated host outbound-email provider, or `undefined` when this installation has none
* (ADR 0037).
*
* `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a
* future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing.
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
* API response ever sees the host or the password.
*/
readonly mail: MailProviderConfig | undefined;
}
export interface ApiRuntimeConfigInput {
@@ -85,6 +100,8 @@ export interface ApiRuntimeConfigInput {
readonly port?: unknown;
/** Optional. Omitted, or with no endpoint, means this installation has no object storage. */
readonly storage?: StorageProviderConfigInput;
/** Optional. Omitted, or with no host, means this installation sends no outbound email. */
readonly mail?: MailProviderConfigInput;
}
export interface CreateApiRuntimeConfigOptions {
@@ -164,12 +181,19 @@ export function createApiRuntimeConfig(
// fails startup with a sanitized code rather than surfacing later as a confusing storage error.
const storage = createStorageProviderConfig(input.storage, { environment });
// Validated in the same pass as everything else, so a malformed port, a half-supplied credential
// pair, or a production deployment that asked for plaintext SMTP fails startup with a sanitized
// code — rather than surfacing later as a confusing failure the first time something tries to
// send a password-reset email.
const mail = createMailProviderConfig(input.mail, { environment });
return Object.freeze({
clientAddress,
databaseTransport,
databaseUrl,
host,
identity,
mail,
port,
storage,
});
+41
View File
@@ -13,6 +13,7 @@ import {
import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import {
createEnvironmentModuleSecretSource,
createModuleConfigurationStore,
@@ -179,6 +180,18 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
if (!provider) {
return;
}
try {
await provider.close();
} catch {
// See safeCloseDatabase: never mask a failure that is already propagating.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
* connection pool. */
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
@@ -455,6 +468,14 @@ export async function startApi(
// is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
// The host's outbound mail provider, constructed from validated configuration and — like the
// storage client above — **without touching the network**. Building a Nodemailer transport opens
// no socket and sends nothing, so an unreachable relay can never stop the platform from booting;
// the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this
// exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with
// no SMTP configured gets `undefined`, which is a complete, valid deployment.
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
const moduleConfigurationStore = createModuleConfigurationStore(
databaseHandle.moduleConfiguration,
@@ -482,6 +503,7 @@ export async function startApi(
);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -508,6 +530,7 @@ export async function startApi(
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -531,6 +554,7 @@ export async function startApi(
});
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -595,6 +619,7 @@ export async function startApi(
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -611,6 +636,7 @@ export async function startApi(
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -649,6 +675,7 @@ export async function startApi(
// After the module runtime has stopped, so a `stop` hook may still use its storage
// facade, and before the pool closes, matching the existing ordering discipline exactly.
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
// module-stop failure can never leave the pool open. Both failures are reported together,
@@ -746,6 +773,20 @@ function readConfigFromEnvironment(): {
// The one place this process turns its environment into storage configuration. The names are
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
// validator never reads `process.env` itself.
// The one place this process turns its environment into mail configuration. Names follow the
// platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a provider's, and Core's
// validator never reads `process.env` itself. Absent host means this installation sends no
// outbound email, which is a complete deployment (ADR 0037).
mail: {
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
host: process.env.EKKLESIA_SMTP_HOST,
// Optional as a pair: a relay authorized by source IP has neither.
password: process.env.EKKLESIA_SMTP_PASSWORD,
port: process.env.EKKLESIA_SMTP_PORT,
security: process.env.EKKLESIA_SMTP_SECURITY,
username: process.env.EKKLESIA_SMTP_USERNAME,
},
storage: {
accessKey: process.env.S3_ACCESS_KEY,
bucketPrefix: process.env.S3_BUCKET_PREFIX,
+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);
});
});
@@ -0,0 +1,207 @@
# 0037: Outbound email is a host-provided transport, and SMTP is its first provider
- Status: Accepted
- Date: 2026-08-24
- Extends: [0011](0011-identity-http-transport-and-public-enrollment.md),
[0029](0029-module-configuration-and-secrets.md),
[0030](0030-host-provided-module-object-storage.md),
[0034](0034-host-owned-browser-authentication.md),
[0035](0035-initial-platform-owner-bootstrap.md)
## Context
Ekklesia can authenticate people and pair devices, but it cannot send a single email. Better Auth
has been configured since [ADR 0011](0011-identity-http-transport-and-public-enrollment.md) with
`emailAndPassword` and nothing else: its `sendResetPassword` and `emailVerification.sendVerificationEmail`
hooks are both unimplemented, so email verification, password recovery, and invitations are all
blocked on the same missing thing.
That missing thing is not "a Google Workspace integration". It is a transport — and the shape of the
transport decides whether the features built on it stay portable. The first real deployment is
expected to relay through Google Workspace, but a church that later moves to Microsoft 365, a
transactional provider, or its own Postfix must not be rewriting feature code to do it.
## Decision
Outbound email is a **host-provided infrastructure service** with a provider seam, exactly as object
storage is under [ADR 0030](0030-host-provided-module-object-storage.md):
```text
trusted server-side feature
↓ "send this transactional message"
MailProvider seam
↓
SMTP → whatever the deployment relays through
```
Feature code owns the recipient, subject, and body. 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.
### The contract is deliberately tiny
One recipient or a small bounded list, a subject, a text body, an HTML body, and an optional
reply-to. There are no attachments, no custom headers, no CC, no BCC, no templates, and no mailing
lists. This is transactional infrastructure: every field beyond that is surface a future caller
could abuse, and it stays out until something real needs it.
At least one of `text` or `html` is required. A message with neither is not a message, and sending
an empty one silently would be worse than refusing it.
### There is no `from` field, anywhere
The sender is host configuration. A message has nowhere to put a sender, so no feature — and no
future module — can choose who mail 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. If several approved sender identities are ever genuinely needed, that is a deliberate design
with its own authorization, not a field.
### Header injection is refused, never repaired
Carriage returns, line feeds, and every other control character are rejected in addresses, display
names, subjects, and reply-to values. A mail header is line-oriented, so a `\r\n` in a subject lets a
caller append headers of its own — a `Bcc:` elsewhere, a replaced `From:`, or a second body.
They are **refused rather than stripped** because silently rewriting input means the value that was
validated is not the value that gets sent. Writing that rule down was not enough: the first
implementation trimmed an address before checking it, which quietly accepted a trailing carriage
return, and a test caught it. The check now runs on the raw value.
### Explicit TLS modes, never a boolean
`implicit-tls` (conventionally 465), `starttls` (587/25), or `plaintext`.
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 stops advertising it — or an attacker who strips it —
turns that into a plaintext session carrying a password, with nothing in the configuration having
changed. Naming the mode makes the downgrade unreachable by accident: `starttls` sets Nodemailer's
`requireTLS`, so a missing upgrade is a failed send. An omitted mode defaults to `starttls`, never to
the one that sends a password in the clear, and production refuses `plaintext` outright.
Certificate verification is left at the library's verifying default, and there is deliberately **no
configuration path that could switch it off** — a setting that disables verification is one someone
eventually disables in production.
### Credentials are optional as a pair
Most SMTP examples assume a username and password. Google Workspace SMTP Relay is commonly
authorized by **source IP** and has neither, so requiring them would make the intended production
arrangement unconfigurable. Half a pair is always a mistake, though — someone set one variable and
expected authentication — so it fails loudly rather than silently connecting anonymously.
The password is a deployment secret. It is never persisted in PostgreSQL, never routed through
module configuration, never returned by any route, never printed by Doctor, and never placed in an
error. It follows the discipline [ADR 0029](0029-module-configuration-and-secrets.md) established.
### Bounded on every axis, and no retry
`connectionTimeout`, `greetingTimeout`, and `socketTimeout` are all set explicitly. Nodemailer's own
defaults are two minutes and ten minutes, which is not a deadline anyone would choose for a
password-reset email someone is waiting on — and the object-storage pass already learned what an
unbounded network client does to a request under load.
**Nothing retries.** A failure after the message data has gone out is genuinely ambiguous: the
server may have accepted it and only the acknowledgement was lost. Retrying an ambiguous send is how
one password-reset request becomes three emails. A single bounded attempt is the whole behaviour,
and `send` resolving means the next hop took responsibility — not that anyone received anything.
This is deliberately **not** an exactly-once promise. A durable outbox with deliberate retry
semantics can be added later if product requirements justify it; guessing at one now would be
inventing delivery guarantees this transport does not have.
### Errors are structured and say nothing
`MAIL_NOT_CONFIGURED`, `MAIL_RECIPIENT_INVALID`, `MAIL_MESSAGE_INVALID`, `MAIL_PROVIDER_UNAVAILABLE`,
`MAIL_REJECTED`, `MAIL_SEND_FAILED`. A 4xx SMTP reply maps to unavailable and a 5xx to rejected, so a
caller can tell a transient outage from something that will never be accepted, and a refused mailbox
keeps its own code because it is the one failure a human can usually act on.
Nothing from the underlying error survives: not the server's response text, not the greeting, not
the auth challenge, not the envelope. SMTP servers routinely echo back what was sent to them, and an
authentication failure's reply can name the account.
### Email is optional, and the platform still runs without it
An installation with no SMTP host configured is a **complete deployment**. Nothing in the platform
sends email yet, so Doctor reports it as `skipped` rather than a warning, and a future feature that
needs to send discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing. Whether email
becomes *required* for particular identity flows is Pass 2's decision to make when those flows exist.
### Nothing sends at startup, and no route sends at all
Constructing the transport opens no socket. The first connection happens when something actually
sends, so an unreachable relay can never stop the platform from booting, and a temporary SMTP outage
is an operational condition rather than a module lifecycle event.
There is deliberately **no `POST /api/v1/send-email`**, and no HTTP route anywhere touches the mail
provider. An authenticated relay endpoint is an open relay with extra steps; a test asserts that no
route file so much as imports the mail module.
### Core only, for now
`ModuleRuntimeServices` carries `database` and nothing else, with a standing note that services are
added **one at a time, when something real needs one**. No module needs email in this pass —
Announcements migration is out of scope — so modules get none, and a test asserts the module SDKs
have no way to send.
This is not a permanent decision. When a module genuinely needs transactional mail, the module-facing
contract goes in `@ekklesia/contracts` and Core layers a scoped facade over this same
`MailProvider` seam — exactly the path object storage took. The seam is already the right shape for
it: a scoped facade would add the module's identity and leave the sender, the bounds, and the
validation exactly where they are.
## Consequences
### The seam Pass 2 wires into
Better Auth's two hooks take a `{ user, url, token }` and return a promise:
```ts
emailAndPassword: { sendResetPassword: async ({ user, url }) => { /* mail.send(...) */ } }
emailVerification: { sendVerificationEmail: async ({ user, url }) => { /* mail.send(...) */ } }
```
`createIdentityRuntime` will take the provider as an injected option and call `mail.send` from
inside them. `startApi` already constructs the provider and closes it on every shutdown path, so
Pass 2 adds the two functions and the message bodies and nothing else. **The `url` and `token` those
hooks receive are credentials** — the whole point of the email is that only the recipient sees them
— so they must never be logged, and the transport already refuses to log a message body.
### What is safe to log
Send attempted, the provider's message id, and an error category. Never a body, never a subject,
never a recipient beyond what an operator genuinely needs, and never a verification or reset link.
The transport itself logs nothing at all.
### Doctor reports configuration, and probes without sending
`outbound-email.configuration` reports absent, valid-and-reachable, valid-but-unreachable, or
structurally invalid. The probe performs the SMTP handshake and, where credentials are set, an
authentication exchange — and **sends no message**: a diagnostic that emailed somebody every time an
operator ran it would be its own incident.
An unreachable relay is a warning rather than a failure, because nothing depends on mail yet and
some relays decline a connection that sends nothing. The report says so explicitly instead of
claiming a health it cannot prove, and it states that a successful connection does not establish
deliverability — that depends on the relay's authorization policy and the domain's SPF, DKIM, and
DMARC records, none of which is knowable from here.
### Google Workspace is a target, not a dependency
It is the expected first deployment and appears nowhere in Core. Whether a relay needs credentials,
and which source addresses it accepts, is configured in Google Workspace Admin and varies per
deployment — so this repository documents the shape and refuses to claim that a relay works
anonymously from an arbitrary host.
### Ekklesia Lite
Lite is a minimal trusted-network module runtime. It may one day supply this same contract if an
operator configures SMTP, and it must never *require* mail to run modules that do not need it —
which is already true, because a provider is optional and its absence is a complete deployment.
Nothing here is implemented in Lite and no module code branches on which host is running it.
### Deferred
Email verification, password reset, and invitations (Pass 2). Templates, which belong to the
features that render them rather than to a transport. Bulk or marketing mail, mailing lists,
attachments, DKIM key management, domain-verification automation, provider-specific HTTP API
adapters, and a durable outbox with retry workers.
+1
View File
@@ -45,3 +45,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
@@ -495,6 +495,19 @@ re-pairing mints a new identity rather than reviving a revoked one. Ekklesia Lit
runtime primitives and deliberately no human authentication, permission enforcement, or managed
device identity, so pairing is a full-Ekklesia concern only.
Phase 7C pass 1 adds outbound email as a host-provided infrastructure service
([ADR 0037](decisions/0037-host-provided-outbound-email.md)), with the same shape object storage
uses: a narrow provider-neutral contract, a `MailProvider` seam, and one concrete SMTP
implementation. Feature code owns the recipient, subject, and body; the provider owns the host,
port, TLS, credentials, and transport errors, and no provider hostname appears in Core. The sender
is host configuration and a message has no `from` field, so nothing above the seam can choose an
identity to send as. Security modes are explicit rather than a boolean plus a port, `starttls`
requires the upgrade, production refuses plaintext, and every timeout is bounded. There is no HTTP
route that sends and no module-facing mail service yet — services are added one at a time when
something real needs one, and nothing does. Email is optional: an installation without SMTP is a
complete deployment, and Better Auth's verification and reset hooks remain unimplemented until
Pass 2.
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only
+14
View File
@@ -863,6 +863,20 @@ instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "de
`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's
own `/display/<module-id>` route is.
## Outbound email
There is **no HTTP API for it**, deliberately
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). Email is a runtime
capability trusted server-side features call, not a resource the platform exposes: an authenticated
send endpoint is an open relay with extra steps, so no route exists and no route file imports the
mail module. A test asserts both.
Host configuration is `EKKLESIA_SMTP_HOST`, `EKKLESIA_SMTP_PORT`, `EKKLESIA_SMTP_SECURITY`,
`EKKLESIA_SMTP_USERNAME`/`EKKLESIA_SMTP_PASSWORD` (optional as a pair), `EKKLESIA_SMTP_FROM_ADDRESS`,
and `EKKLESIA_SMTP_FROM_NAME`. Absent `EKKLESIA_SMTP_HOST` means this installation sends no email,
which is a complete deployment. None of these values ever reaches a module, a browser bundle, a
diagnostic, or an API response. See [Outbound email](email.md).
## Module HTTP routes
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
+124
View File
@@ -0,0 +1,124 @@
# Outbound email
Ekklesia can send transactional email through a host-configured SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
**Nothing in the platform sends email yet.** This pass added the transport; email verification,
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a
complete, valid deployment — the platform starts and every existing feature works.
## Configuring a relay
| variable | meaning |
| --- | --- |
| `EKKLESIA_SMTP_HOST` | A bare hostname or IP. **Absent means this installation sends no email.** Never a URL, port, or connection string. |
| `EKKLESIA_SMTP_PORT` | Optional; defaults to the conventional port for the security mode. |
| `EKKLESIA_SMTP_SECURITY` | `implicit-tls`, `starttls` (default), or `plaintext`. |
| `EKKLESIA_SMTP_USERNAME` / `EKKLESIA_SMTP_PASSWORD` | Optional **as a pair**. Half a pair is refused. |
| `EKKLESIA_SMTP_FROM_ADDRESS` | Required once a host is set. The only address this platform can send as. |
| `EKKLESIA_SMTP_FROM_NAME` | Optional display name. |
### Security modes are explicit
A boolean plus a port is ambiguous — "connect in the clear and hope STARTTLS is offered" is a
plaintext session waiting to happen. Naming the mode removes the ambiguity:
- **`implicit-tls`** — TLS from the first byte, conventionally port 465.
- **`starttls`** — plaintext connect then a **required** upgrade, conventionally 587 (or 25 on a
relay). If the server does not offer STARTTLS the send **fails** rather than continuing in the
clear. This is the default and what most relays expect.
- **`plaintext`** — no TLS. Development and trusted local relays only; **production refuses it.**
Certificate verification is always on and there is no setting that turns it off.
### Google Workspace SMTP Relay
The expected first production deployment, and a compatibility target rather than a dependency — no
provider hostname appears anywhere in Ekklesia's code.
```bash
EKKLESIA_SMTP_HOST=<the relay host shown in Google Workspace Admin>
EKKLESIA_SMTP_PORT=587
EKKLESIA_SMTP_SECURITY=starttls
EKKLESIA_SMTP_FROM_ADDRESS=noreply@example.org
EKKLESIA_SMTP_FROM_NAME=Ekklesia
```
Whether that relay also needs `EKKLESIA_SMTP_USERNAME` and `EKKLESIA_SMTP_PASSWORD` depends entirely
on how the relay is authorized in **Google Workspace Admin**, which varies per deployment. Google
supports authorizing by source IP range, by SMTP authentication, or both, and the setting also
controls which envelope senders are accepted.
**A Google relay does not accept mail anonymously from an arbitrary internet host.** If the
deployment relies on IP authorization, the server's egress address has to be registered, and a
change of hosting provider or NAT address will break sending until it is updated.
The same variables address Microsoft 365, a transactional provider's SMTP endpoint, a local Postfix,
or anything else that speaks the protocol. Switching between them is configuration, not code.
## The sender is host-owned
There is deliberately **no way for feature code to choose a sender.** A message has no `from` field
at all; `EKKLESIA_SMTP_FROM_ADDRESS` is the only address this platform can send as. Letting features
pick a sender is how a platform acquires an impersonation primitive and ruins its own domain
reputation.
Deliverability is a deployment concern this repository cannot verify for you: publish SPF, DKIM, and
DMARC records for the sending domain, and make sure the relay is authorized to send as that address.
## Secrets
`EKKLESIA_SMTP_PASSWORD` is a deployment secret, handled like every other one
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): never stored in
PostgreSQL, never routed through module configuration, never returned by any route, never printed by
Doctor, and never included in an error. `.env.example` documents the variable and deliberately
carries no value.
## What the transport does and does not promise
- **One bounded attempt, no retry.** A failure after the message data has gone out is ambiguous —
the server may have accepted it and only the acknowledgement was lost — so retrying would turn one
password-reset request into several emails. `send` resolving means the next hop accepted
responsibility, **not** that anyone received anything. There is no outbox and no exactly-once
guarantee.
- **Bounded connection, greeting, and socket timeouts.** A silent server returns a structured
`MAIL_PROVIDER_UNAVAILABLE` rather than hanging.
- **A tiny contract.** One recipient or a small bounded list, a subject, text and/or HTML, and an
optional reply-to. No attachments, headers, CC, BCC, templates, or bulk sending.
- **Header injection is refused.** Carriage returns, line feeds, and other control characters in
addresses, display names, subjects, and reply-to values are rejected rather than stripped.
## Diagnostics
`pnpm doctor` reports `outbound-email.configuration` as one of:
- **skipped** — no host configured. A complete deployment.
- **pass** — connected, and authenticated where credentials are set. It **sent nothing**.
- **warning** — configured but did not answer. A warning rather than a failure, because nothing
depends on mail yet and some relays decline a connection that sends no message.
- **failure** — the settings are structurally invalid, reported as a sanitized code with no
credential.
A successful probe proves the relay accepts this deployment. It does **not** prove a message would
reach an inbox — that depends on the relay's authorization policy and the domain's DNS records.
## Logging
Transactional email carries verification links, reset links, and personal data. The transport logs
nothing. Features built on it should log at most that a send was attempted, the provider's message
id, and an error category — **never** a body, a subject, or a verification or reset link.
## Testing
`core/tests/mail-smtp.integration.test.ts` runs a real in-process SMTP server (`smtp-server`) and
proves message delivery, the host-owned sender on the wire, authentication and its refusal, a
rejected mailbox, 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.**
## Next
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail`
hooks — both currently unimplemented — to this transport. See
[Identity](identity.md#outbound-email-and-the-verification-seam).
+26
View File
@@ -279,6 +279,32 @@ wins, so a browser holding both remains a person.
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
to decide whether a person is signed in, and a device is not one.
## Outbound email and the verification seam
Ekklesia has a host-provided SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md), see
[Outbound email](email.md)) — but **no identity flow uses it yet.**
Better Auth exposes exactly two hooks for the flows that need mail, and this repository configures
neither:
| hook | flow |
| --- | --- |
| `emailAndPassword.sendResetPassword({ user, url, token })` | Password reset |
| `emailVerification.sendVerificationEmail({ user, url, token })` | Email verification |
Phase 7C Pass 2 implements them over the mail transport. `startApi` already constructs the provider
and closes it on every shutdown path, so the remaining work is to inject it into
`createIdentityRuntime` and write the two message bodies.
**The `url` and `token` those hooks receive are credentials.** The entire security of both flows is
that only the recipient sees them, so they must never be logged, never appear in an error, and never
reach a diagnostic. The transport already logs nothing and refuses to echo a message body.
Until that pass lands: email verification, password recovery, and invitations do not exist, and
`requireEmailVerification` stays off — turning it on without a working sender would lock every
account out.
## Creating the first platform owner
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
+38
View File
@@ -582,6 +582,44 @@ connection immediately, but an already-open SSE stream is not torn down at that
five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving
a channel. That window is stated rather than described as instant.
### Outbound email is host-owned, and cannot be turned into a relay
Ekklesia sends transactional email through a host-configured SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). What keeps it from
becoming a liability:
- **No feature can choose a sender.** A message has no `from` field at all; the sender is host
configuration. Feature-chosen senders are an impersonation primitive and a deliverability
catastrophe at the same time.
- **No HTTP route can send.** There is deliberately no `POST /api/v1/send-email` and no route that
touches the mail provider — an authenticated relay endpoint is an open relay with extra steps. A
test asserts no route file even imports the mail module.
- **No module can send.** `ModuleRuntimeServices` carries no mail service, and a test asserts the
module SDKs have no way to. Browser code has none either: the SMTP dependency exists only in Core,
which a surface cannot import.
- **Header injection is refused, not repaired.** Control characters in addresses, display names,
subjects, and reply-to values are rejected. The first implementation trimmed before checking and
silently accepted a trailing carriage return; a test caught it and the check now runs on the raw
value.
- **Bounded everything.** Recipients (10), subject (200), text (64 KiB), HTML (256 KiB), and
connection, greeting, and socket timeouts are all explicit. A silent server returns a structured
error rather than hanging a request.
- **No downgrade.** Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than
inferred from a boolean and a port. `starttls` **requires** the upgrade, so a server that stops
offering it — or an attacker who strips it — fails the send instead of continuing in the clear.
Production refuses `plaintext`. Certificate verification is always on and there is no setting that
disables it.
- **The password is a deployment secret.** Never in PostgreSQL, never in module configuration, never
in an API response, never in Doctor output, never in an error, and no value in `.env.example`.
- **Nothing is logged.** The transport logs nothing at all. Transactional mail carries verification
and reset links, which are exactly the credentials those flows depend on.
- **No blind retry.** One bounded attempt. A failure after the message data has been sent is
ambiguous, and retrying it would send someone several copies of the same reset email.
Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy — is a deployment
responsibility this repository cannot verify, and Doctor says so rather than implying a successful
connection means mail arrives.
### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
+25
View File
@@ -375,6 +375,31 @@ an `authenticated` baseline, from platform ownership, or from a user grant. Its
answer "yes" to every human question, so any accidental fall-through into a human path would flip
those assertions.
Outbound email is proven against a **real SMTP server** rather than a mock
(`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
`smtp-server` — the reference implementation from the same project as the client — runs in-process,
so this needs no container and adds no CI infrastructure. **No test sends real email, and no live
credential exists in this repository or in CI.**
It pins the things only a server can settle: a delivered message whose envelope sender and `From:`
header are the *host's* configured address rather than anything a caller chose; both bodies and a
reply-to arriving intact; authentication succeeding and its refusal mapping to a sanitized
`MAIL_REJECTED` that echoes neither the password nor the server's response text; a mailbox refused at
`RCPT TO` mapping to `MAIL_RECIPIENT_INVALID` rather than a generic failure; a refused connection
mapping to `MAIL_PROVIDER_UNAVAILABLE`; a server that accepts a connection and never greets hitting
its deadline instead of hanging; a `starttls` transport refusing to continue when the upgrade is not
offered; `verify()` connecting without sending anything; and one `send` producing exactly one
message.
`core/tests/mail-contract.test.ts` covers the boundary with no network at all, and is mostly
negative — the contract's job is to make an impossible message impossible to construct. It also pins
that a validated message has no `from` key, which is the structural reason no feature can spoof a
sender. `core/tests/mail-boundaries.test.ts` asserts the properties no runtime test can: the SMTP
dependency exists in Core alone, no browser-facing package imports it, module SDKs have no way to
send, no HTTP route references the mail module, Core's mail code names no provider hostname and
never mentions disabling certificate verification, and `.env.example` documents the variables with
no password value.
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
+54
View File
@@ -97,6 +97,9 @@ importers:
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
nodemailer:
specifier: 9.0.5
version: 9.0.5
pg:
specifier: 8.22.0
version: 8.22.0
@@ -107,15 +110,24 @@ importers:
'@types/node':
specifier: 24.13.3
version: 24.13.3
'@types/nodemailer':
specifier: 8.0.1
version: 8.0.1
'@types/pg':
specifier: 8.20.0
version: 8.20.0
'@types/smtp-server':
specifier: 3.5.13
version: 3.5.13
auth:
specifier: 1.6.23
version: 1.6.23(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
drizzle-kit:
specifier: 0.31.10
version: 0.31.10
smtp-server:
specifier: 3.19.3
version: 3.19.3
typescript:
specifier: 7.0.2
version: 7.0.2
@@ -1272,6 +1284,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/[email protected]':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/[email protected]':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
@@ -1286,6 +1301,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/[email protected]':
resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==}
'@types/[email protected]':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@@ -1769,6 +1787,9 @@ packages:
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
engines: {node: '>=16.9.0'}
[email protected]:
resolution: {integrity: sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==}
[email protected]:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -1914,6 +1935,10 @@ packages:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==}
engines: {node: '>=6.0.0'}
[email protected]:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
@@ -2004,6 +2029,10 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
@@ -2059,6 +2088,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
[email protected]:
resolution: {integrity: sha512-1WkMlusxxy0Pdx3Bj4+nxzXLakON3EvV8+e0ZD/u6weNkHiX/ih2KGRV2A6Hg54Z30CQjE1OvNs79EZ0r0fBkw==}
engines: {node: '>=18.18.0'}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3114,6 +3147,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/[email protected]':
dependencies:
'@types/node': 24.13.3
'@types/[email protected]':
dependencies:
'@types/node': 24.13.3
@@ -3130,6 +3167,11 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 24.13.3
'@types/nodemailer': 8.0.1
'@types/[email protected]': {}
'@typescript/[email protected]':
@@ -3515,6 +3557,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -3608,6 +3652,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -3691,6 +3737,8 @@ snapshots:
kleur: 3.0.3
sisteransi: 1.0.5
[email protected]: {}
[email protected]:
dependencies:
defu: 6.1.7
@@ -3746,6 +3794,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
ipv6-normalize: 1.0.1
nodemailer: 9.0.5
punycode.js: 2.3.1
[email protected]: {}
[email protected]: