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
+41
View File
@@ -13,6 +13,7 @@ import {
import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import {
createEnvironmentModuleSecretSource,
createModuleConfigurationStore,
@@ -179,6 +180,18 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the mail provider's transport. */
async function safeCloseMail(provider: MailProvider | undefined): Promise<void> {
if (!provider) {
return;
}
try {
await provider.close();
} catch {
// See safeCloseDatabase: never mask a failure that is already propagating.
}
}
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
* connection pool. */
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
@@ -455,6 +468,14 @@ export async function startApi(
// is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
// The host's outbound mail provider, constructed from validated configuration and — like the
// storage client above — **without touching the network**. Building a Nodemailer transport opens
// no socket and sends nothing, so an unreachable relay can never stop the platform from booting;
// the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this
// exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with
// no SMTP configured gets `undefined`, which is a complete, valid deployment.
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
const moduleConfigurationStore = createModuleConfigurationStore(
databaseHandle.moduleConfiguration,
@@ -482,6 +503,7 @@ export async function startApi(
);
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -508,6 +530,7 @@ export async function startApi(
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -531,6 +554,7 @@ export async function startApi(
});
} catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -595,6 +619,7 @@ export async function startApi(
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -611,6 +636,7 @@ export async function startApi(
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
throw error;
}
@@ -649,6 +675,7 @@ export async function startApi(
// After the module runtime has stopped, so a `stop` hook may still use its storage
// facade, and before the pool closes, matching the existing ordering discipline exactly.
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle);
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
// module-stop failure can never leave the pool open. Both failures are reported together,
@@ -746,6 +773,20 @@ function readConfigFromEnvironment(): {
// The one place this process turns its environment into storage configuration. The names are
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
// validator never reads `process.env` itself.
// The one place this process turns its environment into mail configuration. Names follow the
// platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a provider's, and Core's
// validator never reads `process.env` itself. Absent host means this installation sends no
// outbound email, which is a complete deployment (ADR 0037).
mail: {
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
host: process.env.EKKLESIA_SMTP_HOST,
// Optional as a pair: a relay authorized by source IP has neither.
password: process.env.EKKLESIA_SMTP_PASSWORD,
port: process.env.EKKLESIA_SMTP_PORT,
security: process.env.EKKLESIA_SMTP_SECURITY,
username: process.env.EKKLESIA_SMTP_USERNAME,
},
storage: {
accessKey: process.env.S3_ACCESS_KEY,
bucketPrefix: process.env.S3_BUCKET_PREFIX,