diff --git a/.env.example b/.env.example index 92080a3..a803d81 100644 --- a/.env.example +++ b/.env.example @@ -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= +# EKKLESIA_SMTP_PORT=587 +# EKKLESIA_SMTP_SECURITY=starttls +# EKKLESIA_SMTP_FROM_ADDRESS=noreply@example.org +# 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= +# EKKLESIA_SMTP_FROM_ADDRESS=noreply@example.org +# EKKLESIA_SMTP_FROM_NAME=Ekklesia diff --git a/README.md b/README.md index 9c89de0..2a58011 100644 --- a/README.md +++ b/README.md @@ -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///display`, serves a stable permission-gated page at `/display/`, and exposes that page's permission-gated descriptor at diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 49a99e2..5b99862 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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, }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 76f5bb4..27126f1 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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 { } } +/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */ +async function safeCloseMail(provider: MailProvider | undefined): Promise { + 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 { @@ -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__` 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, diff --git a/core/package.json b/core/package.json index 4f7a9ea..0bc84be 100644 --- a/core/package.json +++ b/core/package.json @@ -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" }, diff --git a/core/src/cli/doctor.ts b/core/src/cli/doctor.ts index 7dbf938..c1deef4 100644 --- a/core/src/cli/doctor.ts +++ b/core/src/cli/doctor.ts @@ -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"), diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 94024af..aa89d58 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -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; + /** 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; } const doctorPostgresTimeoutMs = 10_000; @@ -220,6 +241,22 @@ async function probeStorageForRuntime(config: StorageProviderConfig): Promise { + 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, ]); } diff --git a/core/src/mail/config.ts b/core/src/mail/config.ts new file mode 100644 index 0000000..3262866 --- /dev/null +++ b/core/src/mail/config.ts @@ -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> = 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> = 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" + }`; +} diff --git a/core/src/mail/index.ts b/core/src/mail/index.ts new file mode 100644 index 0000000..1f3f3bc --- /dev/null +++ b/core/src/mail/index.ts @@ -0,0 +1,4 @@ +export * from "./config.js"; +export * from "./message.js"; +export * from "./provider.js"; +export * from "./smtp-provider.js"; diff --git a/core/src/mail/message.ts b/core/src/mail/message.ts new file mode 100644 index 0000000..9204752 --- /dev/null +++ b/core/src/mail/message.ts @@ -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> = 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() }), + }); +} diff --git a/core/src/mail/provider.ts b/core/src/mail/provider.ts new file mode 100644 index 0000000..162e337 --- /dev/null +++ b/core/src/mail/provider.ts @@ -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; + /** + * 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; + /** Releases provider connections. Called once, during API shutdown. */ + close(): Promise; +} + +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; +} diff --git a/core/src/mail/smtp-provider.ts b/core/src/mail/smtp-provider.ts new file mode 100644 index 0000000..323f966 --- /dev/null +++ b/core/src/mail/smtp-provider.ts @@ -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 = {}, +): MailProvider { + const transporter: Transporter = 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 { + transporter.close(); + }, + + async send(message): Promise { + 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 { + try { + await transporter.verify(); + } catch (error) { + throw translateMailError(error); + } + }, + }; + return Object.freeze(provider); +} diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index caf6b90..d6271b5 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -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"); }, diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 1c506a3..fd6b04f 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -138,6 +138,7 @@ function dependencies(overrides: Partial = {}): 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: "noreply@example.org", + fromName: "Ekklesia", + host: "smtp.example.org", + password: "sup3r-secret-relay-password", + security: "starttls", + username: "relay@example.org", + }; + + 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("relay@example.org"); + // 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"]); diff --git a/core/tests/mail-boundaries.test.ts b/core/tests/mail-boundaries.test.ts new file mode 100644 index 0000000..42fb61a --- /dev/null +++ b/core/tests/mail-boundaries.test.ts @@ -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> { + return JSON.parse(await readFile(path.join(repositoryRoot, relativePath), "utf8")) as Record< + string, + unknown + >; +} + +async function readTextFiles(directory: string): Promise> { + const { readdir } = await import("node:fs/promises"); + const results: Array<{ path: string; text: string }> = []; + const walk = async (current: string): Promise => { + 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), + ...((parsed.devDependencies ?? {}) as Record), + }; + 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"); + } + }); +}); diff --git a/core/tests/mail-contract.test.ts b/core/tests/mail-contract.test.ts new file mode 100644 index 0000000..cb0d7dc --- /dev/null +++ b/core/tests/mail-contract.test.ts @@ -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: "noreply@example.org", + 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: "noreply@example.org", + 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: "relay@example.org" }), + ).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: "relay@example.org" }) + ?.username, + ).toBe("relay@example.org"); + }); + + it.each([ + ["a scheme", "smtp://smtp.example.org"], + ["a port", "smtp.example.org:587"], + ["credentials", "user:pass@smtp.example.org"], + ["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 b@example.org", + "", + ]) { + expect(() => createMailProviderConfig({ ...validConfig, fromAddress }), fromAddress).toThrow( + expect.objectContaining({ code: "MAIL_FROM_ADDRESS_INVALID" }), + ); + } + for (const fromName of ['Ekklesia"', "Ekklesia\\", "Ekklesia\r\nBcc: attacker@example.org"]) { + 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: "relay@example.org", + }); + 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("noreply@example.org"); + expect(described).toContain("authenticated"); + expect(described).not.toContain(password); + expect(described).not.toContain("relay@example.org"); + + 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: "u@e.org", + }); + } 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: "person@example.org", + }; + + 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: ["person@example.org"], + }); + }); + + it("has no sender field at all, so no caller can choose one", () => { + const validated = validateMailMessage({ ...message, html: "

Hi

", replyTo: "a@b.org" }); + + // 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 "], + ["a second address", "a@example.org, b@example.org"], + ["a semicolon list", "a@example.org;b@example.org"], + ["a line feed", "person@example.org\nBcc: attacker@example.org"], + ["a carriage return", "person@example.org\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: ["a@example.org", "A@Example.org"] }), + ).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: attacker@example.org", + "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: "

Hi

", subject: message.subject, to: message.to }).html, + ).toBe("

Hi

"); + }); + + it("refuses an injectable reply-to", () => { + expect(() => + validateMailMessage({ + ...message, + replyTo: "office@example.org\r\nBcc: a@b.org", + }), + ).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" })); + expect(validateMailMessage({ ...message, replyTo: "office@example.org" }).replyTo).toBe( + "office@example.org", + ); + }); + + it("never echoes the message into the error", () => { + let caught: unknown; + try { + validateMailMessage({ + ...message, + subject: `secret-${password}` + "\r\nBcc: a@b.org", + }); + } 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("person@example.org"); + }); +}); diff --git a/core/tests/mail-smtp.integration.test.ts b/core/tests/mail-smtp.integration.test.ts new file mode 100644 index 0000000..2b56112 --- /dev/null +++ b/core/tests/mail-smtp.integration.test.ts @@ -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; +} + +const openHarnesses: Array<() => Promise> = []; +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 { + 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((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.server.address() as AddressInfo; + const close = async (): Promise => { + await new Promise((resolve) => server.close(() => resolve())); + }; + openHarnesses.push(close); + return { captured, close, port: address.port }; +} + +function config(port: number, overrides: Record = {}): MailProviderConfig { + const built = createMailProviderConfig( + { + fromAddress: "noreply@example.org", + 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: "

Open the link to continue.

", + subject: "Verify your email address", + text: "Open the link to continue.", + to: "person@example.org", + }), + ); + + expect(harness.captured).toHaveLength(1); + const message = harness.captured[0]; + expect(message?.to).toEqual(["person@example.org"]); + // 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("noreply@example.org"); + expect(message?.data).toContain("From: Ekklesia "); + expect(message?.data).toContain("Subject: Verify your email address"); + expect(message?.data).toContain("Open the link to continue."); + expect(message?.data).toContain("

Open the link to continue.

"); + // 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("person@example.org"); + }); + + 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: ["one@example.org", "two@example.org"], + }), + ); + + expect(harness.captured[0]?.to).toEqual(["one@example.org", "two@example.org"]); + // 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: "office@example.org", + subject: "Notice", + text: "Body.", + to: "person@example.org", + }), + ); + + const data = harness.captured[0]?.data ?? ""; + expect(data).toContain("Reply-To: office@example.org"); + expect(data).toContain("From: Ekklesia "); + expect(harness.captured[0]?.from).toBe("noreply@example.org"); + }); + + it("authenticates when the relay demands it, and reports a refusal as rejected", async () => { + const harness = await startSmtpServer({ + auth: { password: "relay-password", username: "relay@example.org" }, + }); + + const authenticated = provider( + config(harness.port, { password: "relay-password", username: "relay@example.org" }), + ); + await expect( + authenticated.send( + validateMailMessage({ subject: "Authenticated", text: "Body.", to: "person@example.org" }), + ), + ).resolves.toMatchObject({}); + expect(harness.captured).toHaveLength(1); + + const wrong = provider( + config(harness.port, { password: "wrong-password", username: "relay@example.org" }), + ); + let caught: unknown; + try { + await wrong.send( + validateMailMessage({ subject: "Rejected", text: "Body.", to: "person@example.org" }), + ); + } 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("relay@example.org"); + 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: ["absent@example.org"] }); + const mail = provider(config(harness.port)); + + let caught: unknown; + try { + await mail.send( + validateMailMessage({ subject: "Notice", text: "Body.", to: "absent@example.org" }), + ); + } 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: "person@example.org" }), + ); + } 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(); + const blackHole: Server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((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: "person@example.org" }), + ); + } 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((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: "person@example.org" }), + ); + } 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: "person@example.org" }), + ); + + // 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); + }); +}); diff --git a/docs/architecture/decisions/0037-host-provided-outbound-email.md b/docs/architecture/decisions/0037-host-provided-outbound-email.md new file mode 100644 index 0000000..3cd4e13 --- /dev/null +++ b/docs/architecture/decisions/0037-host-provided-outbound-email.md @@ -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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index e22246a..a8f49bc 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 49d936e..ec033a0 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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 diff --git a/docs/development/api.md b/docs/development/api.md index 9f560f0..74ce8b6 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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/` 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 diff --git a/docs/development/email.md b/docs/development/email.md new file mode 100644 index 0000000..a1481ef --- /dev/null +++ b/docs/development/email.md @@ -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= +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). diff --git a/docs/development/identity.md b/docs/development/identity.md index 06d5bf9..f682783 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -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 diff --git a/docs/development/security.md b/docs/development/security.md index 19e3b23..c548961 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -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 diff --git a/docs/development/testing.md b/docs/development/testing.md index ffc0aa3..8af95e9 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7a48cd..35396ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: drizzle-orm: specifier: 0.45.2 version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + 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/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) 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/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -1286,6 +1301,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/smtp-server@3.5.13': + resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -1769,6 +1787,9 @@ packages: resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} engines: {node: '>=16.9.0'} + ipv6-normalize@1.0.1: + resolution: {integrity: sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==} + is-docker@3.0.0: 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'} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + obug@2.1.3: 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'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} @@ -2059,6 +2088,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smtp-server@3.19.3: + resolution: {integrity: sha512-1WkMlusxxy0Pdx3Bj4+nxzXLakON3EvV8+e0ZD/u6weNkHiX/ih2KGRV2A6Hg54Z30CQjE1OvNs79EZ0r0fBkw==} + engines: {node: '>=18.18.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3114,6 +3147,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 24.13.3 + '@types/pg@8.20.0': dependencies: '@types/node': 24.13.3 @@ -3130,6 +3167,11 @@ snapshots: '@types/semver@7.7.1': {} + '@types/smtp-server@3.5.13': + dependencies: + '@types/node': 24.13.3 + '@types/nodemailer': 8.0.1 + '@types/whatwg-mimetype@3.0.2': {} '@typescript/typescript-aix-ppc64@7.0.2': @@ -3515,6 +3557,8 @@ snapshots: hono@4.12.31: {} + ipv6-normalize@1.0.1: {} + is-docker@3.0.0: {} is-inside-container@1.0.0: @@ -3608,6 +3652,8 @@ snapshots: node-releases@2.0.51: {} + nodemailer@9.0.5: {} + obug@2.1.3: {} ohash@2.0.11: {} @@ -3691,6 +3737,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + punycode.js@2.3.1: {} + rc9@3.0.1: dependencies: defu: 6.1.7 @@ -3746,6 +3794,12 @@ snapshots: sisteransi@1.0.5: {} + smtp-server@3.19.3: + dependencies: + ipv6-normalize: 1.0.1 + nodemailer: 9.0.5 + punycode.js: 2.3.1 + source-map-js@1.2.1: {} source-map-support@0.5.21: