feat: add a host-provided outbound email transport
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:
co-authored by
Claude Opus 5
parent
ba32499456
commit
fa3021e49b
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user