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
+25
-1
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user