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