feat: add a host-provided outbound email transport
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s

Ekklesia could authenticate people and pair devices but could not send a
single message. Better Auth has been configured with emailAndPassword
since ADR 0011 and neither of its mail hooks — sendResetPassword and
emailVerification.sendVerificationEmail — was ever implemented, so email
verification, password recovery, and invitations were all blocked on the
same missing thing.

That missing thing is a transport, not a Google Workspace integration.
Email is now a host-provided infrastructure service with a provider seam,
shaped exactly like object storage: feature code says "send this
transactional message" and the provider owns the host, port, TLS,
credentials, connection behaviour, and every transport error. No
provider's hostname appears anywhere in Core, and a test asserts it.
Google Workspace SMTP Relay is the expected first deployment and a
compatibility target — the same variables address Microsoft 365, a
transactional provider, or a local Postfix.

The contract is deliberately tiny: one recipient or a small bounded list,
a subject, text and/or HTML, an optional reply-to, and nothing else. There
is no `from` field at all — the sender is host configuration, so no
feature and no future module can choose who mail appears to come from,
which is how a platform otherwise acquires an impersonation primitive.
Control characters are refused rather than stripped in addresses, display
names, subjects, and reply-to values; writing that rule down was not
enough, because the first implementation trimmed an address before
checking it and silently accepted a trailing carriage return until a test
caught it.

Security modes are named rather than inferred from a boolean and a port,
which is genuinely ambiguous: `secure: false` on 587 means "connect in the
clear and hope STARTTLS is offered". `starttls` sets requireTLS so a
stripped upgrade fails the send instead of continuing in plaintext,
production refuses `plaintext` outright, and certificate verification has
no off switch. Credentials are optional as a pair, because a relay
authorized by source IP has neither and requiring them would make the
intended production arrangement unconfigurable; half a pair fails loudly.

Connection, greeting, and socket timeouts are all explicit — the
object-storage pass already showed what a network client on library
defaults does to a request. Nothing retries: a failure after the message
data has gone out is ambiguous, and retrying it turns one password-reset
request into several emails, so this is a single bounded attempt and
deliberately not an exactly-once promise.

Email stays optional. An installation with no SMTP host is a complete
deployment, nothing sends at startup, and constructing the transport opens
no socket. There is no POST /api/v1/send-email and no route that touches
the provider — an authenticated send endpoint is an open relay with extra
steps. Modules get nothing either; services are added one at a time when
something real needs one, and nothing does yet.

Proven against a real in-process SMTP server rather than a mock: delivery
with the host's sender on the wire, authentication and its sanitized
refusal, a rejected mailbox distinguished from an outage, a refused
connection, a silent server hitting its deadline, and a starttls transport
refusing to continue when the upgrade is not offered. No test sends real
email and no live credential exists in this repository or in CI.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-24 18:37:16 -05:00
co-authored by Claude Opus 5
parent ba32499456
commit fa3021e49b
26 changed files with 2435 additions and 20 deletions
+120 -18
View File
@@ -4,25 +4,26 @@ import {
EMPTY_MODULE_SECRET_SOURCE,
isVersionCompatible,
logicalBucketIdSchema,
type ModuleSecretSource,
moduleConfigurationKeySchema,
moduleIdSchema,
type ModuleSecretSource,
permissionIdSchema,
semanticVersionSchema,
} from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import type { PostgresPoolOptions } from "../database/pool.js";
import {
assertProductionDatabaseTransport,
type DatabaseTransportEnvironment,
type DatabaseTransportPolicyInput,
DatabaseTransportError,
createDatabaseTransportPolicy,
type DatabaseTransportEnvironment,
DatabaseTransportError,
type DatabaseTransportPolicyInput,
describeDatabaseTransport,
} from "../database/transport.js";
import {
type ClientAddressConfigInput,
ClientAddressConfigError,
type ClientAddressConfigInput,
createClientAddressConfig,
} from "../identity/client-address.js";
import {
@@ -32,7 +33,14 @@ import {
} from "../identity/config.js";
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
import { identityAdapterSchema } from "../identity/schema.js";
import type { PostgresPoolOptions } from "../database/pool.js";
import {
createMailProviderConfig,
createSmtpMailProvider,
describeMailProvider,
MailConfigError,
type MailProviderConfig,
type MailProviderConfigInput,
} from "../mail/index.js";
import {
createPostgresMigrationDatabase,
discoverAllMigrations,
@@ -43,16 +51,8 @@ import {
type PostgresMigrationDatabaseOptions,
planModuleMigrations,
} from "../migrations/index.js";
import {
createS3StorageProvider,
describeStorageProvider,
physicalBucketName,
type StorageProviderConfig,
} from "../storage/index.js";
import {
composePlatformPermissionRegistry,
isConfigurationBlockedDetail,
isStorageBlockedDetail,
createModuleConfigurationDatabase,
createModuleConfigurationStore,
createModuleLifecycleDatabase,
@@ -60,15 +60,23 @@ import {
type DiscoveredModule,
discoverModuleSchemas,
discoverModules,
type ModuleEligibility,
ModulePermissionCompositionError,
type ModuleDiscoveryOptions,
isConfigurationBlockedDetail,
isStorageBlockedDetail,
type ModuleConfigurationSettings,
type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration,
type ModuleMigrationAggregate,
ModulePermissionCompositionError,
type ModuleSchemaAggregate,
resolveModuleEligibility,
} from "../modules/index.js";
import {
createS3StorageProvider,
describeStorageProvider,
physicalBucketName,
type StorageProviderConfig,
} from "../storage/index.js";
export type DoctorCheckId =
| "database.transport"
@@ -85,7 +93,8 @@ export type DoctorCheckId =
| "identity.configuration"
| "identity.client-address"
| "object-storage.integration"
| "object-storage.bindings";
| "object-storage.bindings"
| "outbound-email.configuration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
@@ -126,6 +135,15 @@ export interface DoctorOptions {
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
* never receives raw environment values and never prints a credential. */
readonly storage?: StorageProviderConfig | undefined;
/**
* The host's outbound mail settings as the executable read them, validated here.
*
* Deliberately **raw input** rather than a validated config, exactly like `identityConfiguration`:
* a malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has
* to be the one that validates it and reports the sanitized code. A pre-validated option would
* mean a bad value crashed the command instead of appearing in its report.
*/
readonly mailConfiguration?: MailProviderConfigInput | undefined;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
@@ -166,6 +184,9 @@ export interface DoctorDependencies {
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
}
const doctorPostgresTimeoutMs = 10_000;
@@ -220,6 +241,22 @@ async function probeStorageForRuntime(config: StorageProviderConfig): Promise<vo
}
}
/**
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
*
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
* time an operator ran it would be its own incident.
*/
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const provider = createSmtpMailProvider(config);
try {
await provider.verify();
} finally {
await provider.close().catch(() => undefined);
}
}
const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase,
@@ -227,6 +264,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
probeMail: probeMailForRuntime,
probeStorage: probeStorageForRuntime,
readModuleConfiguration: readModuleConfigurationForRuntime,
readModuleLifecycle: readModuleLifecycleForRuntime,
@@ -1081,6 +1119,69 @@ export async function diagnoseEkklesia(
"Complete module discovery is required.",
]);
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
// (ADR 0037).
let mailCheck: DoctorCheck;
let mailConfig: MailProviderConfig | undefined;
let mailConfigurationError: string | undefined;
try {
mailConfig = createMailProviderConfig(options.mailConfiguration, {
environment: options.databaseEnvironment === "development" ? "development" : "production",
});
} catch (error) {
// The sanitized code only — never the host, the username, or the password.
mailConfigurationError =
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
}
if (mailConfigurationError !== undefined) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"failure",
"Outbound email configuration invalid",
[
`The SMTP settings were rejected: ${mailConfigurationError}.`,
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
],
);
} else if (!mailConfig) {
mailCheck = freezeCheck(
"outbound-email.configuration",
"skipped",
"Outbound email not configured",
[
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.",
],
);
} else {
try {
await dependencies.probeMail(mailConfig);
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
describeMailProvider(mailConfig),
// Said plainly: this connected and, where credentials are set, authenticated. It did not
// send anything, so it proves the relay accepts this deployment — not that a message would
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
// policy and cannot be established from here.
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
]);
} catch {
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
// declines a bare connectivity check — must not make the deployment read as broken.
mailCheck = freezeCheck(
"outbound-email.configuration",
"warning",
"Outbound email unreachable",
[
describeMailProvider(mailConfig),
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
],
);
}
}
return publishReport([
runtimeCheck,
moduleCheck,
@@ -1097,5 +1198,6 @@ export async function diagnoseEkklesia(
clientAddressDoctorCheck,
storageCheck,
storageBindings,
mailCheck,
]);
}