Merge pull request 'feat: add a host-provided outbound email transport' (#36) from phase-7c/outbound-email into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/36
This commit was merged in pull request #36.
This commit is contained in:
@@ -44,3 +44,36 @@ S3_BUCKET_PREFIX=ekklesia
|
|||||||
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
|
# Consumed only by the development Compose service's own bootstrap, never by Ekklesia's provider —
|
||||||
# physical buckets are derived from a module's manifest declaration, not configured here.
|
# physical buckets are derived from a module's manifest declaration, not configured here.
|
||||||
S3_BUCKET=ekklesia-development
|
S3_BUCKET=ekklesia-development
|
||||||
|
|
||||||
|
# Ekklesia's outbound email transport (ADR 0037). Absent EKKLESIA_SMTP_HOST means this installation
|
||||||
|
# sends no email, which is a complete deployment: nothing in the platform sends any yet, and a
|
||||||
|
# future feature that needs to is told so rather than crashing.
|
||||||
|
#
|
||||||
|
# These are host infrastructure. No module ever sees them, and no feature chooses its own sender:
|
||||||
|
# the From address below is the only one this platform can send as.
|
||||||
|
#
|
||||||
|
# EKKLESIA_SMTP_HOST is a bare hostname — never a URL, a port, or a connection string.
|
||||||
|
# EKKLESIA_SMTP_SECURITY is exactly one of:
|
||||||
|
# implicit-tls TLS from the first byte, conventionally port 465
|
||||||
|
# starttls plaintext connect then a *required* upgrade, conventionally 587 (the default)
|
||||||
|
# plaintext no TLS at all; development only, and production refuses it outright
|
||||||
|
# EKKLESIA_SMTP_PORT is optional and defaults to the conventional port for the mode.
|
||||||
|
#
|
||||||
|
# Credentials are optional **as a pair**, because a relay authorized by source IP has neither.
|
||||||
|
# Setting one without the other is refused rather than silently connecting anonymously. Never commit
|
||||||
|
# a real password: this file is in every clone.
|
||||||
|
#
|
||||||
|
# A Google Workspace SMTP Relay deployment typically looks like:
|
||||||
|
# EKKLESIA_SMTP_HOST=<the relay host from Google Workspace Admin>
|
||||||
|
# EKKLESIA_SMTP_PORT=587
|
||||||
|
# EKKLESIA_SMTP_SECURITY=starttls
|
||||||
|
# [email protected]
|
||||||
|
# Whether it also needs a username and password depends on the relay's authorization setting in
|
||||||
|
# Google Workspace Admin, which varies per deployment. See docs/development/email.md.
|
||||||
|
# EKKLESIA_SMTP_HOST=
|
||||||
|
# EKKLESIA_SMTP_PORT=587
|
||||||
|
# EKKLESIA_SMTP_SECURITY=starttls
|
||||||
|
# EKKLESIA_SMTP_USERNAME=
|
||||||
|
# EKKLESIA_SMTP_PASSWORD=
|
||||||
|
# [email protected]
|
||||||
|
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||||
|
|||||||
@@ -691,6 +691,41 @@ targeting, device groups, and mobile dashboards are deliberately deferred** —
|
|||||||
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
|
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
|
||||||
identity at all.
|
identity at all.
|
||||||
|
|
||||||
|
Phase 7C pass 1 adds the platform's first **outbound email transport**
|
||||||
|
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
|
||||||
|
authenticate people and pair devices but could not send a single message, which is what blocked
|
||||||
|
email verification, password recovery, and invitations alike. Email is now a host-provided
|
||||||
|
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
|
||||||
|
this transactional message* and the provider owns the host, port, TLS, credentials, 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, not a platform dependency.
|
||||||
|
|
||||||
|
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
|
||||||
|
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
|
||||||
|
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
|
||||||
|
mail appears to come from. Control characters in addresses, subjects, and display names are refused
|
||||||
|
rather than stripped, which is what closes header injection.
|
||||||
|
|
||||||
|
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
|
||||||
|
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
|
||||||
|
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
|
||||||
|
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
|
||||||
|
relay authorized by source IP has neither — and the password is a deployment secret that never
|
||||||
|
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
|
||||||
|
|
||||||
|
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
|
||||||
|
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
|
||||||
|
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
|
||||||
|
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
||||||
|
`POST /api/v1/send-email`** and no route that touches the provider.
|
||||||
|
|
||||||
|
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since
|
||||||
|
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
|
||||||
|
sending anything**, while stating plainly that a successful connection does not establish
|
||||||
|
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
|
||||||
|
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
|
||||||
|
the seam they will use. See [Outbound email](docs/development/email.md).
|
||||||
|
|
||||||
Ekklesia publishes the display bundle at
|
Ekklesia publishes the display bundle at
|
||||||
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||||||
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
||||||
|
|||||||
+25
-1
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
assertProductionDatabaseTransport,
|
assertProductionDatabaseTransport,
|
||||||
|
createDatabaseTransportPolicy,
|
||||||
type DatabaseTransportPolicy,
|
type DatabaseTransportPolicy,
|
||||||
type DatabaseTransportPolicyInput,
|
type DatabaseTransportPolicyInput,
|
||||||
createDatabaseTransportPolicy,
|
|
||||||
} from "@ekklesia/core/database";
|
} from "@ekklesia/core/database";
|
||||||
import {
|
import {
|
||||||
type CreateIdentityConfigOptions,
|
type CreateIdentityConfigOptions,
|
||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
type IdentityConfig,
|
type IdentityConfig,
|
||||||
type IdentityConfigInput,
|
type IdentityConfigInput,
|
||||||
} from "@ekklesia/core/identity";
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createMailProviderConfig,
|
||||||
|
type MailProviderConfig,
|
||||||
|
type MailProviderConfigInput,
|
||||||
|
} from "@ekklesia/core/mail";
|
||||||
import {
|
import {
|
||||||
createStorageProviderConfig,
|
createStorageProviderConfig,
|
||||||
type StorageProviderConfig,
|
type StorageProviderConfig,
|
||||||
@@ -70,6 +75,16 @@ export interface ApiRuntimeConfig {
|
|||||||
* bundle, diagnostic, or API response ever sees the endpoint or the credentials.
|
* bundle, diagnostic, or API response ever sees the endpoint or the credentials.
|
||||||
*/
|
*/
|
||||||
readonly storage: StorageProviderConfig | undefined;
|
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 {
|
export interface ApiRuntimeConfigInput {
|
||||||
@@ -85,6 +100,8 @@ export interface ApiRuntimeConfigInput {
|
|||||||
readonly port?: unknown;
|
readonly port?: unknown;
|
||||||
/** Optional. Omitted, or with no endpoint, means this installation has no object storage. */
|
/** Optional. Omitted, or with no endpoint, means this installation has no object storage. */
|
||||||
readonly storage?: StorageProviderConfigInput;
|
readonly storage?: StorageProviderConfigInput;
|
||||||
|
/** Optional. Omitted, or with no host, means this installation sends no outbound email. */
|
||||||
|
readonly mail?: MailProviderConfigInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateApiRuntimeConfigOptions {
|
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.
|
// fails startup with a sanitized code rather than surfacing later as a confusing storage error.
|
||||||
const storage = createStorageProviderConfig(input.storage, { environment });
|
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({
|
return Object.freeze({
|
||||||
clientAddress,
|
clientAddress,
|
||||||
databaseTransport,
|
databaseTransport,
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
host,
|
host,
|
||||||
identity,
|
identity,
|
||||||
|
mail,
|
||||||
port,
|
port,
|
||||||
storage,
|
storage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||||
|
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||||
import {
|
import {
|
||||||
createEnvironmentModuleSecretSource,
|
createEnvironmentModuleSecretSource,
|
||||||
createModuleConfigurationStore,
|
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
|
/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, for the storage provider's own
|
||||||
* connection pool. */
|
* connection pool. */
|
||||||
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
|
async function safeCloseStorage(provider: StorageProvider | undefined): Promise<void> {
|
||||||
@@ -455,6 +468,14 @@ export async function startApi(
|
|||||||
// is a complete, valid deployment.
|
// is a complete, valid deployment.
|
||||||
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
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 moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||||
databaseHandle.moduleConfiguration,
|
databaseHandle.moduleConfiguration,
|
||||||
@@ -482,6 +503,7 @@ export async function startApi(
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -508,6 +530,7 @@ export async function startApi(
|
|||||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -531,6 +554,7 @@ export async function startApi(
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -595,6 +619,7 @@ export async function startApi(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeStopModuleRuntime(moduleRuntime);
|
await safeStopModuleRuntime(moduleRuntime);
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -611,6 +636,7 @@ export async function startApi(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeStopModuleRuntime(moduleRuntime);
|
await safeStopModuleRuntime(moduleRuntime);
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
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
|
// 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.
|
// facade, and before the pool closes, matching the existing ordering discipline exactly.
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
// Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
|
// 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,
|
// 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 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
|
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
|
||||||
// validator never reads `process.env` itself.
|
// 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: {
|
storage: {
|
||||||
accessKey: process.env.S3_ACCESS_KEY,
|
accessKey: process.env.S3_ACCESS_KEY,
|
||||||
bucketPrefix: process.env.S3_BUCKET_PREFIX,
|
bucketPrefix: process.env.S3_BUCKET_PREFIX,
|
||||||
|
|||||||
@@ -46,6 +46,10 @@
|
|||||||
"./devices": {
|
"./devices": {
|
||||||
"types": "./dist/devices/index.d.ts",
|
"types": "./dist/devices/index.d.ts",
|
||||||
"import": "./dist/devices/index.js"
|
"import": "./dist/devices/index.js"
|
||||||
|
},
|
||||||
|
"./mail": {
|
||||||
|
"types": "./dist/mail/index.d.ts",
|
||||||
|
"import": "./dist/mail/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -62,14 +66,18 @@
|
|||||||
"@ekklesia/contracts": "workspace:*",
|
"@ekklesia/contracts": "workspace:*",
|
||||||
"better-auth": "1.6.23",
|
"better-auth": "1.6.23",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
|
"nodemailer": "9.0.5",
|
||||||
"pg": "8.22.0"
|
"pg": "8.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"@types/nodemailer": "8.0.1",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
|
"@types/smtp-server": "3.5.13",
|
||||||
"auth": "1.6.23",
|
"auth": "1.6.23",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
|
"smtp-server": "3.19.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
+12
-1
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
diagnoseEkklesia,
|
|
||||||
type DoctorOptions,
|
type DoctorOptions,
|
||||||
|
diagnoseEkklesia,
|
||||||
doctorExitCode,
|
doctorExitCode,
|
||||||
formatDoctorReport,
|
formatDoctorReport,
|
||||||
} from "../diagnostics/index.js";
|
} from "../diagnostics/index.js";
|
||||||
@@ -96,6 +96,17 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
|||||||
},
|
},
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
||||||
|
// Raw, exactly like the identity settings above: Doctor validates them and reports a sanitized
|
||||||
|
// code, so a malformed SMTP value appears in the report instead of crashing the command.
|
||||||
|
mailConfiguration: {
|
||||||
|
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
|
||||||
|
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
|
||||||
|
host: process.env.EKKLESIA_SMTP_HOST,
|
||||||
|
password: process.env.EKKLESIA_SMTP_PASSWORD,
|
||||||
|
port: process.env.EKKLESIA_SMTP_PORT,
|
||||||
|
security: process.env.EKKLESIA_SMTP_SECURITY,
|
||||||
|
username: process.env.EKKLESIA_SMTP_USERNAME,
|
||||||
|
},
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
|
|||||||
+120
-18
@@ -4,25 +4,26 @@ import {
|
|||||||
EMPTY_MODULE_SECRET_SOURCE,
|
EMPTY_MODULE_SECRET_SOURCE,
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
logicalBucketIdSchema,
|
logicalBucketIdSchema,
|
||||||
|
type ModuleSecretSource,
|
||||||
moduleConfigurationKeySchema,
|
moduleConfigurationKeySchema,
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
type ModuleSecretSource,
|
|
||||||
permissionIdSchema,
|
permissionIdSchema,
|
||||||
semanticVersionSchema,
|
semanticVersionSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import { getTableName } from "drizzle-orm";
|
import { getTableName } from "drizzle-orm";
|
||||||
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||||
|
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||||
import {
|
import {
|
||||||
assertProductionDatabaseTransport,
|
assertProductionDatabaseTransport,
|
||||||
type DatabaseTransportEnvironment,
|
|
||||||
type DatabaseTransportPolicyInput,
|
|
||||||
DatabaseTransportError,
|
|
||||||
createDatabaseTransportPolicy,
|
createDatabaseTransportPolicy,
|
||||||
|
type DatabaseTransportEnvironment,
|
||||||
|
DatabaseTransportError,
|
||||||
|
type DatabaseTransportPolicyInput,
|
||||||
describeDatabaseTransport,
|
describeDatabaseTransport,
|
||||||
} from "../database/transport.js";
|
} from "../database/transport.js";
|
||||||
import {
|
import {
|
||||||
type ClientAddressConfigInput,
|
|
||||||
ClientAddressConfigError,
|
ClientAddressConfigError,
|
||||||
|
type ClientAddressConfigInput,
|
||||||
createClientAddressConfig,
|
createClientAddressConfig,
|
||||||
} from "../identity/client-address.js";
|
} from "../identity/client-address.js";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,14 @@ import {
|
|||||||
} from "../identity/config.js";
|
} from "../identity/config.js";
|
||||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||||
import { identityAdapterSchema } from "../identity/schema.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 {
|
import {
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
discoverAllMigrations,
|
discoverAllMigrations,
|
||||||
@@ -43,16 +51,8 @@ import {
|
|||||||
type PostgresMigrationDatabaseOptions,
|
type PostgresMigrationDatabaseOptions,
|
||||||
planModuleMigrations,
|
planModuleMigrations,
|
||||||
} from "../migrations/index.js";
|
} from "../migrations/index.js";
|
||||||
import {
|
|
||||||
createS3StorageProvider,
|
|
||||||
describeStorageProvider,
|
|
||||||
physicalBucketName,
|
|
||||||
type StorageProviderConfig,
|
|
||||||
} from "../storage/index.js";
|
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
isConfigurationBlockedDetail,
|
|
||||||
isStorageBlockedDetail,
|
|
||||||
createModuleConfigurationDatabase,
|
createModuleConfigurationDatabase,
|
||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleDatabase,
|
createModuleLifecycleDatabase,
|
||||||
@@ -60,15 +60,23 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
type ModuleEligibility,
|
isConfigurationBlockedDetail,
|
||||||
ModulePermissionCompositionError,
|
isStorageBlockedDetail,
|
||||||
type ModuleDiscoveryOptions,
|
|
||||||
type ModuleConfigurationSettings,
|
type ModuleConfigurationSettings,
|
||||||
|
type ModuleDiscoveryOptions,
|
||||||
|
type ModuleEligibility,
|
||||||
type ModuleLifecycleConfiguration,
|
type ModuleLifecycleConfiguration,
|
||||||
type ModuleMigrationAggregate,
|
type ModuleMigrationAggregate,
|
||||||
|
ModulePermissionCompositionError,
|
||||||
type ModuleSchemaAggregate,
|
type ModuleSchemaAggregate,
|
||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
|
import {
|
||||||
|
createS3StorageProvider,
|
||||||
|
describeStorageProvider,
|
||||||
|
physicalBucketName,
|
||||||
|
type StorageProviderConfig,
|
||||||
|
} from "../storage/index.js";
|
||||||
|
|
||||||
export type DoctorCheckId =
|
export type DoctorCheckId =
|
||||||
| "database.transport"
|
| "database.transport"
|
||||||
@@ -85,7 +93,8 @@ export type DoctorCheckId =
|
|||||||
| "identity.configuration"
|
| "identity.configuration"
|
||||||
| "identity.client-address"
|
| "identity.client-address"
|
||||||
| "object-storage.integration"
|
| "object-storage.integration"
|
||||||
| "object-storage.bindings";
|
| "object-storage.bindings"
|
||||||
|
| "outbound-email.configuration";
|
||||||
|
|
||||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||||
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
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
|
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
|
||||||
* never receives raw environment values and never prints a credential. */
|
* never receives raw environment values and never prints a credential. */
|
||||||
readonly storage?: StorageProviderConfig | undefined;
|
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 runtimeRange: string;
|
||||||
readonly runtimeVersion: string;
|
readonly runtimeVersion: string;
|
||||||
}
|
}
|
||||||
@@ -166,6 +184,9 @@ export interface DoctorDependencies {
|
|||||||
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
|
/** 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. */
|
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
|
||||||
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
|
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;
|
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({
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
composePermissions: composePlatformPermissionRegistry,
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
@@ -227,6 +264,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
|||||||
discoverModules,
|
discoverModules,
|
||||||
discoverSchemas: discoverModuleSchemas,
|
discoverSchemas: discoverModuleSchemas,
|
||||||
planMigrations: planModuleMigrations,
|
planMigrations: planModuleMigrations,
|
||||||
|
probeMail: probeMailForRuntime,
|
||||||
probeStorage: probeStorageForRuntime,
|
probeStorage: probeStorageForRuntime,
|
||||||
readModuleConfiguration: readModuleConfigurationForRuntime,
|
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||||
@@ -1081,6 +1119,69 @@ export async function diagnoseEkklesia(
|
|||||||
"Complete module discovery is required.",
|
"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([
|
return publishReport([
|
||||||
runtimeCheck,
|
runtimeCheck,
|
||||||
moduleCheck,
|
moduleCheck,
|
||||||
@@ -1097,5 +1198,6 @@ export async function diagnoseEkklesia(
|
|||||||
clientAddressDoctorCheck,
|
clientAddressDoctorCheck,
|
||||||
storageCheck,
|
storageCheck,
|
||||||
storageBindings,
|
storageBindings,
|
||||||
|
mailCheck,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import {
|
||||||
|
isValidMailAddress,
|
||||||
|
isValidMailDisplayName,
|
||||||
|
MAIL_MAX_DISPLAY_NAME_LENGTH,
|
||||||
|
} from "./message.js";
|
||||||
|
|
||||||
|
export type MailConfigErrorCode =
|
||||||
|
| "MAIL_FROM_ADDRESS_INVALID"
|
||||||
|
| "MAIL_FROM_ADDRESS_MISSING"
|
||||||
|
| "MAIL_FROM_NAME_INVALID"
|
||||||
|
| "MAIL_HOST_INVALID"
|
||||||
|
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
||||||
|
| "MAIL_PORT_INVALID"
|
||||||
|
| "MAIL_SECURITY_INVALID"
|
||||||
|
| "MAIL_SECURITY_INSECURE_IN_PRODUCTION"
|
||||||
|
| "MAIL_USERNAME_WITHOUT_PASSWORD";
|
||||||
|
|
||||||
|
const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object.freeze({
|
||||||
|
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
|
||||||
|
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
|
||||||
|
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
|
||||||
|
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
|
||||||
|
MAIL_PASSWORD_WITHOUT_USERNAME:
|
||||||
|
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
||||||
|
MAIL_PORT_INVALID: "The SMTP port must be an integer between 1 and 65535.",
|
||||||
|
MAIL_SECURITY_INSECURE_IN_PRODUCTION:
|
||||||
|
"A production SMTP transport must use implicit TLS or STARTTLS; plaintext is development-only.",
|
||||||
|
MAIL_SECURITY_INVALID:
|
||||||
|
'The SMTP security mode must be exactly "implicit-tls", "starttls", or "plaintext".',
|
||||||
|
MAIL_USERNAME_WITHOUT_PASSWORD:
|
||||||
|
"An SMTP username requires a password; supply both or neither for an unauthenticated relay.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never carries the host, port, username, password, or sender — only a fixed code and message.
|
||||||
|
* Matching `StorageConfigError` exactly, and for the same sharper reason: one of the values this
|
||||||
|
* validates is a credential.
|
||||||
|
*/
|
||||||
|
export class MailConfigError extends Error {
|
||||||
|
readonly code: MailConfigErrorCode;
|
||||||
|
|
||||||
|
constructor(code: MailConfigErrorCode) {
|
||||||
|
super(mailConfigMessages[code]);
|
||||||
|
this.name = "MailConfigError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MailEnvironment = "development" | "production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the connection is protected, stated explicitly rather than inferred.
|
||||||
|
*
|
||||||
|
* A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear
|
||||||
|
* and hope STARTTLS is offered", and a server that quietly stops advertising STARTTLS — or an
|
||||||
|
* attacker who strips it — turns that into a plaintext session carrying a password, with nothing in
|
||||||
|
* the configuration that changed. Naming the mode makes the downgrade impossible to reach by
|
||||||
|
* accident: `starttls` **requires** the upgrade and fails the send if it is unavailable.
|
||||||
|
*
|
||||||
|
* - `implicit-tls` — TLS from the first byte, conventionally port 465.
|
||||||
|
* - `starttls` — plaintext connect, then a *mandatory* upgrade, conventionally 587 (or 25 on a
|
||||||
|
* relay). This is what Google Workspace SMTP Relay and most modern providers expect.
|
||||||
|
* - `plaintext` — no TLS at all. Development and a trusted local relay only; production refuses it.
|
||||||
|
*/
|
||||||
|
export const MAIL_SECURITY_MODES = ["implicit-tls", "starttls", "plaintext"] as const;
|
||||||
|
|
||||||
|
export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
|
||||||
|
|
||||||
|
export interface MailProviderConfigInput {
|
||||||
|
readonly host?: string | undefined;
|
||||||
|
readonly port?: string | number | undefined;
|
||||||
|
readonly security?: string | undefined;
|
||||||
|
readonly username?: string | undefined;
|
||||||
|
readonly password?: string | undefined;
|
||||||
|
readonly fromAddress?: string | undefined;
|
||||||
|
readonly fromName?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The validated host mail configuration.
|
||||||
|
*
|
||||||
|
* **No feature or module ever sees this.** It reaches Core's provider and nothing else; the
|
||||||
|
* `MailProvider` seam speaks only in validated messages, and there is no accessor anywhere that
|
||||||
|
* could return the host, the port, or the password (ADR 0037).
|
||||||
|
*/
|
||||||
|
export interface MailProviderConfig {
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly security: MailSecurityMode;
|
||||||
|
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
||||||
|
* Workspace arrangement — has neither. */
|
||||||
|
readonly username?: string;
|
||||||
|
readonly password?: string;
|
||||||
|
readonly fromAddress: string;
|
||||||
|
readonly fromName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conventional defaults per mode, so an operator who names a mode does not also have to remember a
|
||||||
|
* port number. Any port may still be set explicitly. */
|
||||||
|
const DEFAULT_PORTS: Readonly<Record<MailSecurityMode, number>> = Object.freeze({
|
||||||
|
"implicit-tls": 465,
|
||||||
|
plaintext: 25,
|
||||||
|
starttls: 587,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A bare hostname or IP literal: no scheme, no port, no path, no credentials. Anything richer is a
|
||||||
|
* connection string, and a connection string is where a password ends up in a log. */
|
||||||
|
const HOST_PATTERN =
|
||||||
|
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/iu;
|
||||||
|
const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/u;
|
||||||
|
|
||||||
|
function validateHost(host: string): string {
|
||||||
|
const trimmed = host.trim();
|
||||||
|
if (trimmed.length === 0 || (!HOST_PATTERN.test(trimmed) && !IPV4_PATTERN.test(trimmed))) {
|
||||||
|
throw new MailConfigError("MAIL_HOST_INVALID");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
|
||||||
|
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
||||||
|
return DEFAULT_PORTS[security];
|
||||||
|
}
|
||||||
|
const parsed = typeof port === "number" ? port : Number(port.trim());
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
|
throw new MailConfigError("MAIL_PORT_INVALID");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSecurity(
|
||||||
|
security: string | undefined,
|
||||||
|
environment: MailEnvironment,
|
||||||
|
): MailSecurityMode {
|
||||||
|
// Defaulting to STARTTLS rather than to whatever the port implies: an omitted mode must never be
|
||||||
|
// the one that sends a password in the clear.
|
||||||
|
const mode = security === undefined || security.trim() === "" ? "starttls" : security.trim();
|
||||||
|
if (!(MAIL_SECURITY_MODES as readonly string[]).includes(mode)) {
|
||||||
|
throw new MailConfigError("MAIL_SECURITY_INVALID");
|
||||||
|
}
|
||||||
|
if (environment === "production" && mode === "plaintext") {
|
||||||
|
throw new MailConfigError("MAIL_SECURITY_INSECURE_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
return mode as MailSecurityMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the host's outbound email configuration, or reports that this installation has none.
|
||||||
|
*
|
||||||
|
* **Outbound email is optional.** An installation with no `host` returns `undefined`, and that is a
|
||||||
|
* complete, valid deployment — the platform starts, every existing feature works, and only a future
|
||||||
|
* feature that actually needs to send is affected, which it discovers as a structured
|
||||||
|
* `MAIL_NOT_CONFIGURED` rather than a crash (ADR 0037).
|
||||||
|
*
|
||||||
|
* Credentials are validated as a **pair**. Many SMTP examples assume a username and password, but a
|
||||||
|
* relay authorized by source IP — which is how Google Workspace SMTP Relay is commonly deployed —
|
||||||
|
* has neither, so requiring them would make the intended production arrangement unconfigurable.
|
||||||
|
* Half a pair is always a mistake, though: it means someone set one variable and expected
|
||||||
|
* authentication, so it fails loudly instead of silently connecting anonymously.
|
||||||
|
*/
|
||||||
|
export function createMailProviderConfig(
|
||||||
|
input: MailProviderConfigInput | undefined,
|
||||||
|
options: { readonly environment?: MailEnvironment } = {},
|
||||||
|
): MailProviderConfig | undefined {
|
||||||
|
const host = input?.host?.trim();
|
||||||
|
if (host === undefined || host === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = options.environment ?? "production";
|
||||||
|
const security = validateSecurity(input?.security, environment);
|
||||||
|
const validatedHost = validateHost(host);
|
||||||
|
const port = validatePort(input?.port, security);
|
||||||
|
|
||||||
|
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
|
||||||
|
const password = input?.password === "" ? undefined : input?.password;
|
||||||
|
if (username !== undefined && password === undefined) {
|
||||||
|
throw new MailConfigError("MAIL_USERNAME_WITHOUT_PASSWORD");
|
||||||
|
}
|
||||||
|
if (password !== undefined && username === undefined) {
|
||||||
|
throw new MailConfigError("MAIL_PASSWORD_WITHOUT_USERNAME");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromAddress = input?.fromAddress?.trim();
|
||||||
|
if (fromAddress === undefined || fromAddress === "") {
|
||||||
|
throw new MailConfigError("MAIL_FROM_ADDRESS_MISSING");
|
||||||
|
}
|
||||||
|
if (!isValidMailAddress(fromAddress)) {
|
||||||
|
throw new MailConfigError("MAIL_FROM_ADDRESS_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromName = input?.fromName?.trim() === "" ? undefined : input?.fromName?.trim();
|
||||||
|
if (fromName !== undefined && !isValidMailDisplayName(fromName)) {
|
||||||
|
throw new MailConfigError("MAIL_FROM_NAME_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
fromAddress,
|
||||||
|
host: validatedHost,
|
||||||
|
port,
|
||||||
|
security,
|
||||||
|
...(fromName === undefined ? {} : { fromName }),
|
||||||
|
...(username === undefined || password === undefined ? {} : { password, username }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-line operator-facing description that is safe to print anywhere — Doctor output, a log line,
|
||||||
|
* a support transcript.
|
||||||
|
*
|
||||||
|
* It names the host, port, security mode, and sender, and states **whether** authentication is
|
||||||
|
* configured without ever naming the username or password. `describeStorageProvider` sets the same
|
||||||
|
* precedent.
|
||||||
|
*/
|
||||||
|
export function describeMailProvider(config: MailProviderConfig): string {
|
||||||
|
const sender =
|
||||||
|
config.fromName === undefined
|
||||||
|
? config.fromAddress
|
||||||
|
: `${config.fromName} <${config.fromAddress}>`;
|
||||||
|
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
|
||||||
|
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
||||||
|
}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./config.js";
|
||||||
|
export * from "./message.js";
|
||||||
|
export * from "./provider.js";
|
||||||
|
export * from "./smtp-provider.js";
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* The one transactional message shape Ekklesia sends, and the boundary that validates it.
|
||||||
|
*
|
||||||
|
* **There is deliberately no `from` field.** The sender is host configuration
|
||||||
|
* (`MailProviderConfig.fromAddress`), so no caller — not Core, not a future module — can choose who
|
||||||
|
* a message appears to come from. Letting feature code pick a sender is how a platform acquires an
|
||||||
|
* impersonation primitive and destroys its own domain reputation at the same time (ADR 0037).
|
||||||
|
*
|
||||||
|
* Also deliberately absent: CC, BCC, attachments, custom headers, and templates. Identity
|
||||||
|
* verification, password reset, and invitations each need exactly one recipient and a short body.
|
||||||
|
* Every field beyond that is surface a future caller could abuse, so it stays out until something
|
||||||
|
* real needs it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MailErrorCode =
|
||||||
|
| "MAIL_MESSAGE_INVALID"
|
||||||
|
| "MAIL_NOT_CONFIGURED"
|
||||||
|
| "MAIL_PROVIDER_UNAVAILABLE"
|
||||||
|
| "MAIL_RECIPIENT_INVALID"
|
||||||
|
| "MAIL_REJECTED"
|
||||||
|
| "MAIL_SEND_FAILED";
|
||||||
|
|
||||||
|
const mailErrorMessages: Readonly<Record<MailErrorCode, string>> = Object.freeze({
|
||||||
|
MAIL_MESSAGE_INVALID: "The message is not one this contract accepts.",
|
||||||
|
MAIL_NOT_CONFIGURED: "This installation has no outbound email provider configured.",
|
||||||
|
MAIL_PROVIDER_UNAVAILABLE: "The outbound email service could not be reached.",
|
||||||
|
MAIL_RECIPIENT_INVALID: "The recipient address is not a valid email address.",
|
||||||
|
MAIL_REJECTED: "The outbound email service refused the message.",
|
||||||
|
MAIL_SEND_FAILED: "The message could not be sent.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sanitized mail failure.
|
||||||
|
*
|
||||||
|
* Carries a code and a fixed message and **nothing else** — no SMTP host, no port, no username, no
|
||||||
|
* password, no server greeting, no auth challenge, no provider response text, and no caught cause.
|
||||||
|
* SMTP servers routinely echo the envelope and sometimes the credential state back in their reply
|
||||||
|
* text, so forwarding a provider error verbatim is how a password reaches a log. It follows the same
|
||||||
|
* discipline as `ModuleStorageError` and `StorageConfigError`.
|
||||||
|
*/
|
||||||
|
export class MailError extends Error {
|
||||||
|
readonly code: MailErrorCode;
|
||||||
|
|
||||||
|
constructor(code: MailErrorCode) {
|
||||||
|
super(mailErrorMessages[code]);
|
||||||
|
this.name = "MailError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounds, chosen to be generous for a transactional message and far too small for a newsletter.
|
||||||
|
*
|
||||||
|
* A verification email is a couple of kilobytes. These leave two orders of magnitude of headroom
|
||||||
|
* while making it structurally impossible to hand the provider a megabyte, or to turn one call into
|
||||||
|
* a mailing list.
|
||||||
|
*/
|
||||||
|
export const MAIL_MAX_RECIPIENTS = 10;
|
||||||
|
export const MAIL_MAX_SUBJECT_LENGTH = 200;
|
||||||
|
export const MAIL_MAX_TEXT_BYTES = 64 * 1024;
|
||||||
|
export const MAIL_MAX_HTML_BYTES = 256 * 1024;
|
||||||
|
/** RFC 5321 caps a path at 256 octets; the local part and domain limits sit inside that. */
|
||||||
|
export const MAIL_MAX_ADDRESS_LENGTH = 254;
|
||||||
|
export const MAIL_MAX_DISPLAY_NAME_LENGTH = 128;
|
||||||
|
|
||||||
|
export interface MailMessage {
|
||||||
|
/** One address, or a small bounded list. */
|
||||||
|
readonly to: string | readonly string[];
|
||||||
|
readonly subject: string;
|
||||||
|
/** At least one of `text` or `html` is required — see `validateMailMessage`. */
|
||||||
|
readonly text?: string;
|
||||||
|
readonly html?: string;
|
||||||
|
/** Where a human reply should go, when that is not the host's unattended sender. */
|
||||||
|
readonly replyTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message after validation: recipients normalized to a list, and every field proven safe to
|
||||||
|
* hand a provider. A provider receives only this, never the caller's raw input. */
|
||||||
|
export interface ValidatedMailMessage {
|
||||||
|
readonly to: readonly string[];
|
||||||
|
readonly subject: string;
|
||||||
|
readonly text?: string;
|
||||||
|
readonly html?: string;
|
||||||
|
readonly replyTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a string is free of everything that could break out of the field it is placed in.
|
||||||
|
*
|
||||||
|
* A mail header is line-oriented, so a carriage return or line feed in a subject, an address, or a
|
||||||
|
* display name lets a caller append headers of its own — a `Bcc:` to somewhere else, a replaced
|
||||||
|
* `From:`, or an entire second message body. This is the classic email header injection, and it is
|
||||||
|
* why these characters are **refused** rather than stripped: silently rewriting a caller's input
|
||||||
|
* means the value that was validated is not the value that gets sent, and the two can drift.
|
||||||
|
*
|
||||||
|
* Every other C0 control, DEL, and the C1 range go with them. None has a legitimate place in a
|
||||||
|
* transactional subject line, and several are interpreted by intermediaries.
|
||||||
|
*/
|
||||||
|
function hasUnsafeCharacters(value: string): boolean {
|
||||||
|
for (const character of value) {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
if (codePoint === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately conservative address check.
|
||||||
|
*
|
||||||
|
* This is not an RFC 5322 parser and does not try to be: the point is to reject what is dangerous or
|
||||||
|
* obviously wrong before it reaches the provider, and to let the provider be authoritative about
|
||||||
|
* exotic-but-legal mailboxes. What it does guarantee is that the value is a single, bare
|
||||||
|
* `local@domain` with no display name, no angle brackets, no comma, no semicolon, and no control
|
||||||
|
* character — so it cannot smuggle a second recipient or a header break through a field the caller
|
||||||
|
* believes holds one address.
|
||||||
|
*/
|
||||||
|
export function isValidMailAddress(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Checked **before** trimming, deliberately. Trimming first would silently strip a trailing
|
||||||
|
// carriage return and accept the address, which contradicts the rule above: a control character in
|
||||||
|
// an address is refused, never repaired. Surrounding ordinary whitespace is still tolerated,
|
||||||
|
// because that is a copy-paste artefact rather than an injection attempt.
|
||||||
|
if (hasUnsafeCharacters(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const address = value.trim();
|
||||||
|
if (address.length === 0 || address.length > MAIL_MAX_ADDRESS_LENGTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// No grouping or list punctuation: one address means one address.
|
||||||
|
if (/[,;<>"\\()[\]\s]/u.test(address)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const at = address.lastIndexOf("@");
|
||||||
|
if (at <= 0 || at === address.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const local = address.slice(0, at);
|
||||||
|
const domain = address.slice(at + 1);
|
||||||
|
if (local.length > 64 || local.includes("@")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A domain of at least two dot-separated labels, each alphanumeric with internal hyphens. This
|
||||||
|
// rejects a bare hostname, which is legal SMTP but never a real transactional recipient and is a
|
||||||
|
// common shape for a typo.
|
||||||
|
return /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/iu.test(
|
||||||
|
domain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A display name that can be placed in a `From:` without escaping surprises. Quotes and backslashes
|
||||||
|
* are refused rather than escaped, for the same reason control characters are. */
|
||||||
|
export function isValidMailDisplayName(value: unknown): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
value.trim().length > 0 &&
|
||||||
|
value.length <= MAIL_MAX_DISPLAY_NAME_LENGTH &&
|
||||||
|
!hasUnsafeCharacters(value) &&
|
||||||
|
!/["\\]/u.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteLength(value: string): number {
|
||||||
|
return new TextEncoder().encode(value).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates one message and normalizes its recipients.
|
||||||
|
*
|
||||||
|
* Recipient problems get their own code because they are the one failure an operator or a user can
|
||||||
|
* usually act on — a mistyped address — while everything else here is a programming error in the
|
||||||
|
* calling feature.
|
||||||
|
*/
|
||||||
|
export function validateMailMessage(message: MailMessage): ValidatedMailMessage {
|
||||||
|
const recipients = (Array.isArray(message.to) ? message.to : [message.to]) as readonly unknown[];
|
||||||
|
if (recipients.length === 0 || recipients.length > MAIL_MAX_RECIPIENTS) {
|
||||||
|
throw new MailError("MAIL_RECIPIENT_INVALID");
|
||||||
|
}
|
||||||
|
const to: string[] = [];
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
if (!isValidMailAddress(recipient)) {
|
||||||
|
throw new MailError("MAIL_RECIPIENT_INVALID");
|
||||||
|
}
|
||||||
|
to.push(recipient.trim());
|
||||||
|
}
|
||||||
|
if (new Set(to.map((address) => address.toLowerCase())).size !== to.length) {
|
||||||
|
throw new MailError("MAIL_RECIPIENT_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = message.subject;
|
||||||
|
if (
|
||||||
|
typeof subject !== "string" ||
|
||||||
|
subject.trim().length === 0 ||
|
||||||
|
subject.length > MAIL_MAX_SUBJECT_LENGTH ||
|
||||||
|
hasUnsafeCharacters(subject)
|
||||||
|
) {
|
||||||
|
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one body. A message with neither is not a message, and silently sending an empty one
|
||||||
|
// would be worse than refusing it.
|
||||||
|
const hasText = typeof message.text === "string" && message.text.length > 0;
|
||||||
|
const hasHtml = typeof message.html === "string" && message.html.length > 0;
|
||||||
|
if (!hasText && !hasHtml) {
|
||||||
|
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||||
|
}
|
||||||
|
if (hasText && byteLength(message.text as string) > MAIL_MAX_TEXT_BYTES) {
|
||||||
|
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||||
|
}
|
||||||
|
if (hasHtml && byteLength(message.html as string) > MAIL_MAX_HTML_BYTES) {
|
||||||
|
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.replyTo !== undefined && !isValidMailAddress(message.replyTo)) {
|
||||||
|
throw new MailError("MAIL_MESSAGE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
to: Object.freeze(to),
|
||||||
|
subject,
|
||||||
|
...(hasText ? { text: message.text as string } : {}),
|
||||||
|
...(hasHtml ? { html: message.html as string } : {}),
|
||||||
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo.trim() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ValidatedMailMessage } from "./message.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seam between Ekklesia's mail contract and whatever actually delivers a message.
|
||||||
|
*
|
||||||
|
* This is the **portability boundary made executable**, exactly as `StorageProvider` is for object
|
||||||
|
* storage. Everything above it — the message contract, validation, the sender policy, diagnostics —
|
||||||
|
* is provider-agnostic and would be identical under a future standalone host. Everything below it is
|
||||||
|
* one implementation (`createSmtpMailProvider`) that could be replaced by a provider HTTP API
|
||||||
|
* without any calling feature changing a line.
|
||||||
|
*
|
||||||
|
* It speaks only in **already-validated** messages: recipients, subject, and bodies have been
|
||||||
|
* checked and normalized by `validateMailMessage` before anything reaches here, so a provider never
|
||||||
|
* re-implements those rules and two providers can never disagree about what a caller may send.
|
||||||
|
*
|
||||||
|
* Deliberately absent: any way to ask for the sender, the host, the port, or a credential. A
|
||||||
|
* provider is given configuration at construction and exposes none of it afterwards.
|
||||||
|
*/
|
||||||
|
export interface MailProvider {
|
||||||
|
/**
|
||||||
|
* Sends one message. Resolves with a provider message id when the service accepted it.
|
||||||
|
*
|
||||||
|
* **This is a single bounded attempt, not a delivery guarantee.** Acceptance means the next hop
|
||||||
|
* took responsibility, not that anyone received anything — and a failure after the message data
|
||||||
|
* has been transmitted is genuinely ambiguous, because the server may have accepted it and only
|
||||||
|
* the acknowledgement was lost. Nothing here retries; see ADR 0037.
|
||||||
|
*/
|
||||||
|
send(message: ValidatedMailMessage): Promise<MailSendResult>;
|
||||||
|
/**
|
||||||
|
* Opens a connection and, when credentials are configured, authenticates — then closes, **without
|
||||||
|
* sending anything**. For diagnostics only.
|
||||||
|
*
|
||||||
|
* Its result is never persisted and never influences module lifecycle state: a mail service being
|
||||||
|
* briefly unreachable is an operational condition, not an administrative decision.
|
||||||
|
*/
|
||||||
|
verify(): Promise<void>;
|
||||||
|
/** Releases provider connections. Called once, during API shutdown. */
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailSendResult {
|
||||||
|
/**
|
||||||
|
* The provider's own identifier for the accepted message, when it supplies one.
|
||||||
|
*
|
||||||
|
* Safe to log: it identifies a message in the provider's records and contains no recipient, no
|
||||||
|
* subject, and no body. It is deliberately the *only* thing about a sent message this contract
|
||||||
|
* hands back, because a verification or reset email's contents are exactly what must not end up
|
||||||
|
* in an operator's log (ADR 0037).
|
||||||
|
*/
|
||||||
|
readonly messageId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
|
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
|
||||||
|
import type { MailProviderConfig } from "./config.js";
|
||||||
|
import { MailError } from "./message.js";
|
||||||
|
import type { MailProvider, MailSendResult } from "./provider.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ekklesia's first real `MailProvider`, speaking SMTP.
|
||||||
|
*
|
||||||
|
* **It is not named after a service, and it must not become one.** No provider's hostname appears
|
||||||
|
* anywhere in this file, and a test asserts that. Everything specific to a deployment arrives as
|
||||||
|
* validated configuration, so the same code addresses a Google Workspace relay, Microsoft 365, a
|
||||||
|
* transactional provider's SMTP endpoint, a local Postfix, or anything else that speaks the
|
||||||
|
* protocol (ADR 0037).
|
||||||
|
*
|
||||||
|
* Server-only, and structurally so: this module imports Nodemailer and reaches the network, and no
|
||||||
|
* browser package can import it — a surface cannot import Core at all, which the boundary checker
|
||||||
|
* enforces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a single SMTP exchange may take before it is abandoned.
|
||||||
|
*
|
||||||
|
* These exist because the object-storage pass learned the lesson the hard way: a network client left
|
||||||
|
* on its library's defaults will wait indefinitely, and a provider that accepts a connection and
|
||||||
|
* then goes silent holds the calling request open forever. Nodemailer's own defaults are two
|
||||||
|
* minutes for a connection and ten for a socket, which is not a deadline anyone would choose for a
|
||||||
|
* password-reset email a person is waiting on.
|
||||||
|
*
|
||||||
|
* `socketTimeout` is the most generous of the three because it also covers transmitting the message
|
||||||
|
* body, and it is inactivity-based rather than total elapsed time, so a large message is never cut
|
||||||
|
* off for being large — only for going quiet. A wholly unresponsive server therefore surfaces as a
|
||||||
|
* structured `MAIL_PROVIDER_UNAVAILABLE` in well under a minute rather than never.
|
||||||
|
*/
|
||||||
|
export interface SmtpTransportBounds {
|
||||||
|
/** Milliseconds allowed to establish the TCP connection. */
|
||||||
|
readonly connectionTimeoutMs: number;
|
||||||
|
/** Milliseconds allowed for the server's opening greeting. */
|
||||||
|
readonly greetingTimeoutMs: number;
|
||||||
|
/** Milliseconds the socket may sit idle mid-exchange; resets whenever bytes move. */
|
||||||
|
readonly socketTimeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TRANSPORT_BOUNDS: SmtpTransportBounds = Object.freeze({
|
||||||
|
connectionTimeoutMs: 10_000,
|
||||||
|
greetingTimeoutMs: 10_000,
|
||||||
|
socketTimeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SMTP_CONNECTION_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.connectionTimeoutMs;
|
||||||
|
export const SMTP_GREETING_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.greetingTimeoutMs;
|
||||||
|
export const SMTP_SOCKET_TIMEOUT_MS = DEFAULT_TRANSPORT_BOUNDS.socketTimeoutMs;
|
||||||
|
|
||||||
|
interface SmtpLikeError {
|
||||||
|
readonly code?: unknown;
|
||||||
|
readonly responseCode?: unknown;
|
||||||
|
readonly rejected?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCode(error: unknown): string {
|
||||||
|
const candidate = error as SmtpLikeError | null;
|
||||||
|
return typeof candidate?.code === "string" ? candidate.code : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseCode(error: unknown): number | undefined {
|
||||||
|
const candidate = error as SmtpLikeError | null;
|
||||||
|
return typeof candidate?.responseCode === "number" ? candidate.responseCode : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces any Nodemailer or socket failure to one of the contract's closed codes.
|
||||||
|
*
|
||||||
|
* **Nothing from the caught error survives this function** — not its message, not the server's
|
||||||
|
* response text, not the greeting, not the auth challenge, and not the envelope. SMTP servers
|
||||||
|
* routinely echo back what was sent to them, and an authentication failure's response can name the
|
||||||
|
* account; forwarding any of that verbatim is how a credential or a recipient reaches a log.
|
||||||
|
*/
|
||||||
|
export function translateMailError(error: unknown): MailError {
|
||||||
|
const code = errorCode(error);
|
||||||
|
const status = responseCode(error);
|
||||||
|
|
||||||
|
// A named recipient the server refused: the one failure a human can usually act on, so it keeps
|
||||||
|
// its own code rather than collapsing into a generic send failure.
|
||||||
|
if (code === "EENVELOPE") {
|
||||||
|
const rejected = (error as SmtpLikeError).rejected;
|
||||||
|
if (Array.isArray(rejected) && rejected.length > 0) {
|
||||||
|
return new MailError("MAIL_RECIPIENT_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection-level failures, including both timeouts configured above.
|
||||||
|
if (
|
||||||
|
code === "ECONNECTION" ||
|
||||||
|
code === "ESOCKET" ||
|
||||||
|
code === "ETIMEDOUT" ||
|
||||||
|
code === "EDNS" ||
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "EHOSTUNREACH" ||
|
||||||
|
code === "ENOTFOUND"
|
||||||
|
) {
|
||||||
|
return new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== undefined) {
|
||||||
|
// 4xx is SMTP's "try later" and 5xx its "no". Splitting on that is what lets a caller tell a
|
||||||
|
// transient outage from a message that will never be accepted.
|
||||||
|
return status >= 500
|
||||||
|
? new MailError("MAIL_REJECTED")
|
||||||
|
: new MailError("MAIL_PROVIDER_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MailError("MAIL_SEND_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportOptions(
|
||||||
|
config: MailProviderConfig,
|
||||||
|
bounds: SmtpTransportBounds,
|
||||||
|
): SMTPTransport.Options {
|
||||||
|
return {
|
||||||
|
// Bounded on every axis. See the constants above.
|
||||||
|
connectionTimeout: bounds.connectionTimeoutMs,
|
||||||
|
// Defence in depth: this platform never sends attachments, so Nodemailer must never be able to
|
||||||
|
// read a local file or fetch a URL on the strength of something that reached a message body.
|
||||||
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true,
|
||||||
|
greetingTimeout: bounds.greetingTimeoutMs,
|
||||||
|
host: config.host,
|
||||||
|
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
|
||||||
|
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
|
||||||
|
// platform sending a handful of transactional messages buys nothing and makes the failure modes
|
||||||
|
// harder to reason about.
|
||||||
|
port: config.port,
|
||||||
|
// `secure` means implicit TLS from the first byte. For STARTTLS it must be false and the upgrade
|
||||||
|
// demanded separately, below.
|
||||||
|
secure: config.security === "implicit-tls",
|
||||||
|
socketTimeout: bounds.socketTimeoutMs,
|
||||||
|
// The downgrade guard. With `requireTLS`, a STARTTLS transport refuses to continue in the clear
|
||||||
|
// if the server does not offer the upgrade — so a server that stops advertising it, or an
|
||||||
|
// attacker who strips it from the EHLO response, produces a failed send rather than a plaintext
|
||||||
|
// session carrying a password. `ignoreTLS` is the opposite instruction and is set only for the
|
||||||
|
// mode that has explicitly asked for no TLS at all, which production configuration refuses.
|
||||||
|
...(config.security === "starttls" ? { requireTLS: true } : {}),
|
||||||
|
...(config.security === "plaintext" ? { ignoreTLS: true } : {}),
|
||||||
|
// Certificate verification is left at Nodemailer's default, which verifies. There is
|
||||||
|
// deliberately no configuration path that could switch it off, and a test asserts this file
|
||||||
|
// never names the option: a setting that disables verification is one someone eventually
|
||||||
|
// disables in production.
|
||||||
|
...(config.username === undefined || config.password === undefined
|
||||||
|
? {}
|
||||||
|
: { auth: { pass: config.password, user: config.username } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSmtpMailProvider(
|
||||||
|
config: MailProviderConfig,
|
||||||
|
/** Tightened only by the live provider suite, so it can prove a deadline actually fires without
|
||||||
|
* spending the production budget waiting for it. Hosts always take the defaults. */
|
||||||
|
bounds: Partial<SmtpTransportBounds> = {},
|
||||||
|
): MailProvider {
|
||||||
|
const transporter: Transporter<SMTPTransport.SentMessageInfo> = createTransport(
|
||||||
|
transportOptions(config, { ...DEFAULT_TRANSPORT_BOUNDS, ...bounds }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Built once, from host configuration, and applied to every message. A caller cannot reach it and
|
||||||
|
// there is no per-message override, so no feature can send as anyone else.
|
||||||
|
const from =
|
||||||
|
config.fromName === undefined
|
||||||
|
? { address: config.fromAddress, name: "" }
|
||||||
|
: { address: config.fromAddress, name: config.fromName };
|
||||||
|
|
||||||
|
const provider: MailProvider = {
|
||||||
|
async close(): Promise<void> {
|
||||||
|
transporter.close();
|
||||||
|
},
|
||||||
|
|
||||||
|
async send(message): Promise<MailSendResult> {
|
||||||
|
try {
|
||||||
|
const accepted = await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
subject: message.subject,
|
||||||
|
to: [...message.to],
|
||||||
|
...(message.text === undefined ? {} : { text: message.text }),
|
||||||
|
...(message.html === undefined ? {} : { html: message.html }),
|
||||||
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
||||||
|
});
|
||||||
|
// Exactly one attempt. Nodemailer does not retry a `sendMail` on its own, and nothing here
|
||||||
|
// adds one: a failure after the message data has gone out is ambiguous, and retrying an
|
||||||
|
// ambiguous send is how one password-reset request becomes three emails (ADR 0037).
|
||||||
|
const messageId = (accepted as { messageId?: unknown }).messageId;
|
||||||
|
return Object.freeze(typeof messageId === "string" ? { messageId } : {});
|
||||||
|
} catch (error) {
|
||||||
|
throw translateMailError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async verify(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await transporter.verify();
|
||||||
|
} catch (error) {
|
||||||
|
throw translateMailError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze(provider);
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
|||||||
planMigrations: async () => {
|
planMigrations: async () => {
|
||||||
throw new Error("database must not be planned");
|
throw new Error("database must not be planned");
|
||||||
},
|
},
|
||||||
|
probeMail: async () => {
|
||||||
|
throw new Error("mail must not be probed");
|
||||||
|
},
|
||||||
probeStorage: async () => {
|
probeStorage: async () => {
|
||||||
throw new Error("storage must not be probed");
|
throw new Error("storage must not be probed");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
|||||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||||
planMigrations: vi.fn(async () => plan()),
|
planMigrations: vi.fn(async () => plan()),
|
||||||
|
probeMail: vi.fn(async () => undefined),
|
||||||
probeStorage: vi.fn(async () => undefined),
|
probeStorage: vi.fn(async () => undefined),
|
||||||
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||||
@@ -187,6 +188,99 @@ function planEntry(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("Ekklesia Doctor outbound email reporting", () => {
|
||||||
|
const mailConfiguration = {
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "smtp.example.org",
|
||||||
|
password: "sup3r-secret-relay-password",
|
||||||
|
security: "starttls",
|
||||||
|
username: "[email protected]",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reports an installation with no SMTP host as complete rather than degraded", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const report = await diagnoseEkklesia(options(), services);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("skipped");
|
||||||
|
expect(mailCheck.summary).toBe("Outbound email not configured");
|
||||||
|
// Nothing sends email yet, so an absent provider must not make a deployment read as broken.
|
||||||
|
expect(report.result).not.toBe("not-ready");
|
||||||
|
expect(services.probeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a reachable provider without ever naming a credential", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("pass");
|
||||||
|
const rendered = mailCheck.details.join(" ");
|
||||||
|
expect(rendered).toContain("smtp.example.org:587");
|
||||||
|
expect(rendered).toContain("starttls");
|
||||||
|
expect(rendered).toContain("authenticated");
|
||||||
|
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
||||||
|
expect(rendered).not.toContain("[email protected]");
|
||||||
|
// Honest about what a connection proves: it did not send anything, and deliverability is not
|
||||||
|
// something this check can establish.
|
||||||
|
expect(rendered).toMatch(/did not|without sending/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns rather than failing when a configured relay does not answer", async () => {
|
||||||
|
const services = dependencies({
|
||||||
|
probeMail: vi.fn(async () => {
|
||||||
|
throw new Error("connection refused to smtp.example.org with sup3r-secret-relay-password");
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await diagnoseEkklesia({ ...options(), mailConfiguration }, services);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
// A warning, never a failure: the configuration is valid and nothing depends on mail yet.
|
||||||
|
expect(mailCheck.status).toBe("warning");
|
||||||
|
expect(report.result).not.toBe("not-ready");
|
||||||
|
// The thrown value carried a credential; none of it reaches the report.
|
||||||
|
expect(mailCheck.details.join(" ")).not.toContain("sup3r-secret-relay-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a malformed configuration with a sanitized code and probes nothing", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{ ...options(), mailConfiguration: { ...mailConfiguration, port: "not-a-port" } },
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("failure");
|
||||||
|
const rendered = mailCheck.details.join(" ");
|
||||||
|
expect(rendered).toContain("MAIL_PORT_INVALID");
|
||||||
|
expect(rendered).not.toContain("sup3r-secret-relay-password");
|
||||||
|
// A configuration this broken is never connected to.
|
||||||
|
expect(services.probeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses plaintext SMTP when the deployment validates as production", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{
|
||||||
|
...options(),
|
||||||
|
databaseEnvironment: "production",
|
||||||
|
mailConfiguration: { ...mailConfiguration, security: "plaintext" },
|
||||||
|
},
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(check(report, "outbound-email.configuration").details.join(" ")).toContain(
|
||||||
|
"MAIL_SECURITY_INSECURE_IN_PRODUCTION",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Ekklesia Doctor diagnostics", () => {
|
describe("Ekklesia Doctor diagnostics", () => {
|
||||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||||
const people = discoveredModule("people");
|
const people = discoveredModule("people");
|
||||||
@@ -219,6 +313,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
"identity.client-address",
|
"identity.client-address",
|
||||||
"object-storage.integration",
|
"object-storage.integration",
|
||||||
"object-storage.bindings",
|
"object-storage.bindings",
|
||||||
|
"outbound-email.configuration",
|
||||||
]);
|
]);
|
||||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural guarantees about outbound email that no runtime assertion can make (ADR 0037).
|
||||||
|
*
|
||||||
|
* These read the repository itself, because the properties that matter here are about what code
|
||||||
|
* *exists* and what depends on what — a mail credential that never reaches a browser bundle, and a
|
||||||
|
* platform that offers no route anyone could use as an open relay.
|
||||||
|
*/
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
|
async function readJson(relativePath: string): Promise<Record<string, unknown>> {
|
||||||
|
return JSON.parse(await readFile(path.join(repositoryRoot, relativePath), "utf8")) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTextFiles(directory: string): Promise<Array<{ path: string; text: string }>> {
|
||||||
|
const { readdir } = await import("node:fs/promises");
|
||||||
|
const results: Array<{ path: string; text: string }> = [];
|
||||||
|
const walk = async (current: string): Promise<void> => {
|
||||||
|
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||||
|
const full = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await walk(full);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
|
||||||
|
results.push({ path: full, text: await readFile(full, "utf8") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await walk(path.join(repositoryRoot, directory));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("outbound email stays server-only", () => {
|
||||||
|
it("declares the SMTP dependency in Core alone", async () => {
|
||||||
|
const core = await readJson("core/package.json");
|
||||||
|
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
|
||||||
|
|
||||||
|
// Every package a browser can end up executing, plus the contracts barrel a surface must not
|
||||||
|
// reach. None may carry a mail transport.
|
||||||
|
for (const manifest of [
|
||||||
|
"packages/contracts/package.json",
|
||||||
|
"packages/module-sdk/package.json",
|
||||||
|
"packages/module-ui-sdk/package.json",
|
||||||
|
"packages/module-ui-host/package.json",
|
||||||
|
"surfaces/shell/package.json",
|
||||||
|
"apps/api/package.json",
|
||||||
|
]) {
|
||||||
|
const parsed = await readJson(manifest);
|
||||||
|
const dependencies = {
|
||||||
|
...((parsed.dependencies ?? {}) as Record<string, string>),
|
||||||
|
...((parsed.devDependencies ?? {}) as Record<string, string>),
|
||||||
|
};
|
||||||
|
const mailDependencies = Object.keys(dependencies).filter((name) =>
|
||||||
|
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name),
|
||||||
|
);
|
||||||
|
expect(mailDependencies, manifest).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never imports the transport outside Core's own mail module", async () => {
|
||||||
|
for (const directory of [
|
||||||
|
"packages/contracts/src",
|
||||||
|
"packages/module-sdk/src",
|
||||||
|
"packages/module-ui-sdk/src",
|
||||||
|
"packages/module-ui-host/src",
|
||||||
|
"surfaces/shell/src",
|
||||||
|
]) {
|
||||||
|
for (const file of await readTextFiles(directory)) {
|
||||||
|
expect(file.text, file.path).not.toContain("nodemailer");
|
||||||
|
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives module code no way to send email at all", async () => {
|
||||||
|
// Deliberate scope: Core sends, modules do not (yet). `ModuleRuntimeServices` is the one place a
|
||||||
|
// module capability would appear, and it is added one service at a time when something real
|
||||||
|
// needs it — nothing does here (ADR 0037).
|
||||||
|
const services = await readFile(
|
||||||
|
path.join(repositoryRoot, "packages/contracts/src/module-database.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(services).not.toMatch(/mail|email/iu);
|
||||||
|
|
||||||
|
for (const directory of ["packages/module-sdk/src", "packages/module-ui-sdk/src"]) {
|
||||||
|
for (const file of await readTextFiles(directory)) {
|
||||||
|
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes no HTTP route that could send mail", async () => {
|
||||||
|
// An authenticated relay endpoint is an open relay with extra steps. Nothing in the transport
|
||||||
|
// layer may reference the mail provider at all.
|
||||||
|
for (const file of await readTextFiles("apps/api/src")) {
|
||||||
|
if (file.path.endsWith("server.ts") || file.path.endsWith("config.ts")) {
|
||||||
|
// The composition root legitimately constructs and closes the provider.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
||||||
|
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of await readTextFiles("apps/api/src")) {
|
||||||
|
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*send-email[^"'`]*["'`]/u);
|
||||||
|
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*\/api\/v1\/mail[^"'`]*["'`]/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads SMTP environment only in the executable that owns configuration", async () => {
|
||||||
|
// Reusable Core code never touches `process.env`; the composition root turns environment into
|
||||||
|
// validated configuration and injects it.
|
||||||
|
for (const file of await readTextFiles("core/src/mail")) {
|
||||||
|
expect(file.text, file.path).not.toContain("process.env");
|
||||||
|
}
|
||||||
|
const server = await readFile(path.join(repositoryRoot, "apps/api/src/server.ts"), "utf8");
|
||||||
|
expect(server).toContain("EKKLESIA_SMTP_HOST");
|
||||||
|
expect(server).toContain("EKKLESIA_SMTP_PASSWORD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps no SMTP password out of the committed environment example", async () => {
|
||||||
|
const example = await readFile(path.join(repositoryRoot, ".env.example"), "utf8");
|
||||||
|
// Documented, but never with a value: a bootstrap credential in a committed file is a
|
||||||
|
// credential in every clone.
|
||||||
|
expect(example).toContain("EKKLESIA_SMTP_HOST");
|
||||||
|
expect(example).not.toMatch(/^EKKLESIA_SMTP_PASSWORD=.+$/mu);
|
||||||
|
expect(example).not.toContain("smtp-relay.gmail.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hardcodes no provider hostname in Core", async () => {
|
||||||
|
// Google Workspace is a deployment target, not the architecture.
|
||||||
|
for (const file of await readTextFiles("core/src/mail")) {
|
||||||
|
expect(file.text.toLowerCase(), file.path).not.toContain("gmail");
|
||||||
|
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay");
|
||||||
|
expect(file.text.toLowerCase(), file.path).not.toContain("resend");
|
||||||
|
expect(file.text.toLowerCase(), file.path).not.toContain("brevo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never disables TLS certificate verification", async () => {
|
||||||
|
for (const file of await readTextFiles("core/src/mail")) {
|
||||||
|
expect(file.text, file.path).not.toContain("rejectUnauthorized");
|
||||||
|
expect(file.text, file.path).not.toContain("NODE_TLS_REJECT_UNAUTHORIZED");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createMailProviderConfig,
|
||||||
|
describeMailProvider,
|
||||||
|
MailConfigError,
|
||||||
|
} from "../src/mail/config.js";
|
||||||
|
import {
|
||||||
|
MAIL_MAX_HTML_BYTES,
|
||||||
|
MAIL_MAX_RECIPIENTS,
|
||||||
|
MAIL_MAX_SUBJECT_LENGTH,
|
||||||
|
MAIL_MAX_TEXT_BYTES,
|
||||||
|
MailError,
|
||||||
|
validateMailMessage,
|
||||||
|
} from "../src/mail/message.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mail contract's boundary, with no provider and no network.
|
||||||
|
*
|
||||||
|
* Most of these are negative: the contract's job is to make an impossible message impossible to
|
||||||
|
* construct, so what it *refuses* is the interesting part (ADR 0037).
|
||||||
|
*/
|
||||||
|
const password = "sup3r-secret-relay-password";
|
||||||
|
const validConfig = {
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "smtp.example.org",
|
||||||
|
security: "starttls",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("host mail configuration", () => {
|
||||||
|
it("treats an absent host as an installation that sends no email", () => {
|
||||||
|
// The whole platform must still start. Nothing sends mail yet, so this is a complete deployment.
|
||||||
|
expect(createMailProviderConfig(undefined)).toBeUndefined();
|
||||||
|
expect(createMailProviderConfig({})).toBeUndefined();
|
||||||
|
expect(createMailProviderConfig({ host: " " })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a relay with no credentials, which is how Google Workspace is usually deployed", () => {
|
||||||
|
const config = createMailProviderConfig(validConfig);
|
||||||
|
|
||||||
|
// Requiring a username and password — as most SMTP examples do — would make the intended
|
||||||
|
// production arrangement, a relay authorized by source IP, unconfigurable.
|
||||||
|
expect(config).toEqual({
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "smtp.example.org",
|
||||||
|
port: 587,
|
||||||
|
security: "starttls",
|
||||||
|
});
|
||||||
|
expect(config?.username).toBeUndefined();
|
||||||
|
expect(config?.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the port per security mode, and defaults the mode to an encrypted one", () => {
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, security: undefined })?.security).toBe(
|
||||||
|
"starttls",
|
||||||
|
);
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, security: "implicit-tls" })?.port).toBe(465);
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, security: "starttls" })?.port).toBe(587);
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, port: "2525" })?.port).toBe(2525);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses half a credential pair in either direction", () => {
|
||||||
|
expect(() =>
|
||||||
|
createMailProviderConfig({ ...validConfig, username: "[email protected]" }),
|
||||||
|
).toThrow(expect.objectContaining({ code: "MAIL_USERNAME_WITHOUT_PASSWORD" }));
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, password })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_PASSWORD_WITHOUT_USERNAME" }),
|
||||||
|
);
|
||||||
|
// Both together is the authenticated case and is accepted.
|
||||||
|
expect(
|
||||||
|
createMailProviderConfig({ ...validConfig, password, username: "[email protected]" })
|
||||||
|
?.username,
|
||||||
|
).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a scheme", "smtp://smtp.example.org"],
|
||||||
|
["a port", "smtp.example.org:587"],
|
||||||
|
["credentials", "user:[email protected]"],
|
||||||
|
["a path", "smtp.example.org/relay"],
|
||||||
|
["a space", "smtp example org"],
|
||||||
|
["nothing but dots", "..."],
|
||||||
|
])("refuses an SMTP host containing %s", (_label, host) => {
|
||||||
|
// A host is a hostname, never a connection string — a connection string is how a password ends
|
||||||
|
// up in a log.
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, host })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_HOST_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["0"],
|
||||||
|
["65536"],
|
||||||
|
["-1"],
|
||||||
|
["587.5"],
|
||||||
|
["not-a-port"],
|
||||||
|
])("refuses the malformed port %s", (port) => {
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, port })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_PORT_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an empty port as unset rather than malformed", () => {
|
||||||
|
expect(createMailProviderConfig({ ...validConfig, port: "" })?.port).toBe(587);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unknown security mode rather than guessing one", () => {
|
||||||
|
for (const security of ["tls", "ssl", "none", "STARTTLS", "true"]) {
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, security }), security).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_SECURITY_INVALID" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses plaintext SMTP in production but allows it in development", () => {
|
||||||
|
expect(() =>
|
||||||
|
createMailProviderConfig(
|
||||||
|
{ ...validConfig, security: "plaintext" },
|
||||||
|
{ environment: "production" },
|
||||||
|
),
|
||||||
|
).toThrow(expect.objectContaining({ code: "MAIL_SECURITY_INSECURE_IN_PRODUCTION" }));
|
||||||
|
expect(
|
||||||
|
createMailProviderConfig(
|
||||||
|
{ ...validConfig, security: "plaintext" },
|
||||||
|
{ environment: "development" },
|
||||||
|
)?.security,
|
||||||
|
).toBe("plaintext");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a valid sender address and refuses an injectable display name", () => {
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, fromAddress: undefined })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_MISSING" }),
|
||||||
|
);
|
||||||
|
for (const fromAddress of [
|
||||||
|
"not-an-address",
|
||||||
|
"a@b",
|
||||||
|
"a@@b.org",
|
||||||
|
"a [email protected]",
|
||||||
|
"<[email protected]>",
|
||||||
|
]) {
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, fromAddress }), fromAddress).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_FROM_ADDRESS_INVALID" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const fromName of ['Ekklesia"', "Ekklesia\\", "Ekklesia\r\nBcc: [email protected]"]) {
|
||||||
|
expect(() => createMailProviderConfig({ ...validConfig, fromName })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_FROM_NAME_INVALID" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts a credential into a configuration error or a description", () => {
|
||||||
|
const configured = createMailProviderConfig({
|
||||||
|
...validConfig,
|
||||||
|
password,
|
||||||
|
username: "[email protected]",
|
||||||
|
});
|
||||||
|
if (!configured) {
|
||||||
|
throw new Error("The mail configuration fixture produced no configuration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const described = describeMailProvider(configured);
|
||||||
|
// An operator sees where mail goes and whether it authenticates — never with what.
|
||||||
|
expect(described).toContain("smtp.example.org:587");
|
||||||
|
expect(described).toContain("starttls");
|
||||||
|
expect(described).toContain("[email protected]");
|
||||||
|
expect(described).toContain("authenticated");
|
||||||
|
expect(described).not.toContain(password);
|
||||||
|
expect(described).not.toContain("[email protected]");
|
||||||
|
|
||||||
|
const unauthenticated = createMailProviderConfig(validConfig);
|
||||||
|
if (!unauthenticated) {
|
||||||
|
throw new Error("The mail configuration fixture produced no configuration.");
|
||||||
|
}
|
||||||
|
expect(describeMailProvider(unauthenticated)).toContain("unauthenticated relay");
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
createMailProviderConfig({
|
||||||
|
...validConfig,
|
||||||
|
host: "smtp://bad",
|
||||||
|
password,
|
||||||
|
username: "[email protected]",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(MailConfigError);
|
||||||
|
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||||
|
expect(rendered).not.toContain(password);
|
||||||
|
expect(rendered).not.toContain("smtp://bad");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transactional message validation", () => {
|
||||||
|
const message = {
|
||||||
|
subject: "Verify your email address",
|
||||||
|
text: "Open the link to continue.",
|
||||||
|
to: "[email protected]",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts one recipient and normalizes it to a list", () => {
|
||||||
|
expect(validateMailMessage(message)).toEqual({
|
||||||
|
subject: "Verify your email address",
|
||||||
|
text: "Open the link to continue.",
|
||||||
|
to: ["[email protected]"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no sender field at all, so no caller can choose one", () => {
|
||||||
|
const validated = validateMailMessage({ ...message, html: "<p>Hi</p>", replyTo: "[email protected]" });
|
||||||
|
|
||||||
|
// The load-bearing structural fact: the sender comes from host configuration and there is
|
||||||
|
// nowhere in a message to put one.
|
||||||
|
expect(Object.keys(validated).sort()).toEqual(["html", "replyTo", "subject", "text", "to"]);
|
||||||
|
expect(validated).not.toHaveProperty("from");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a display name", "Person <[email protected]>"],
|
||||||
|
["a second address", "[email protected], [email protected]"],
|
||||||
|
["a semicolon list", "[email protected];[email protected]"],
|
||||||
|
["a line feed", "[email protected]\nBcc: [email protected]"],
|
||||||
|
["a carriage return", "[email protected]\r"],
|
||||||
|
["no domain dot", "person@localhost"],
|
||||||
|
["no local part", "@example.org"],
|
||||||
|
["nothing", ""],
|
||||||
|
])("refuses a recipient containing %s", (_label, to) => {
|
||||||
|
expect(() => validateMailMessage({ ...message, to })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the recipient list and refuses duplicates", () => {
|
||||||
|
const many = Array.from(
|
||||||
|
{ length: MAIL_MAX_RECIPIENTS + 1 },
|
||||||
|
(_value, index) => `person${index}@example.org`,
|
||||||
|
);
|
||||||
|
expect(() => validateMailMessage({ ...message, to: many })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
|
||||||
|
);
|
||||||
|
expect(() => validateMailMessage({ ...message, to: [] })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }),
|
||||||
|
);
|
||||||
|
// Case-insensitively: the same mailbox twice would send one person two copies.
|
||||||
|
expect(() =>
|
||||||
|
validateMailMessage({ ...message, to: ["[email protected]", "[email protected]"] }),
|
||||||
|
).toThrow(expect.objectContaining({ code: "MAIL_RECIPIENT_INVALID" }));
|
||||||
|
expect(
|
||||||
|
validateMailMessage({ ...message, to: many.slice(0, MAIL_MAX_RECIPIENTS) }).to,
|
||||||
|
).toHaveLength(MAIL_MAX_RECIPIENTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a subject carrying a header break or no content", () => {
|
||||||
|
for (const subject of [
|
||||||
|
"Reset\r\nBcc: [email protected]",
|
||||||
|
"Reset\nX-Injected: 1",
|
||||||
|
" ",
|
||||||
|
"",
|
||||||
|
]) {
|
||||||
|
expect(() => validateMailMessage({ ...message, subject })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds the subject and both bodies", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH + 1) }),
|
||||||
|
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||||
|
expect(() =>
|
||||||
|
validateMailMessage({ ...message, text: "x".repeat(MAIL_MAX_TEXT_BYTES + 1) }),
|
||||||
|
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||||
|
expect(() =>
|
||||||
|
validateMailMessage({
|
||||||
|
html: "x".repeat(MAIL_MAX_HTML_BYTES + 1),
|
||||||
|
subject: message.subject,
|
||||||
|
to: message.to,
|
||||||
|
}),
|
||||||
|
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||||
|
// Exactly at the bound is accepted, so the limit is the limit.
|
||||||
|
expect(
|
||||||
|
validateMailMessage({ ...message, subject: "x".repeat(MAIL_MAX_SUBJECT_LENGTH) }).subject,
|
||||||
|
).toHaveLength(MAIL_MAX_SUBJECT_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires at least one body, and accepts either alone", () => {
|
||||||
|
expect(() => validateMailMessage({ subject: message.subject, to: message.to })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
|
||||||
|
);
|
||||||
|
expect(() => validateMailMessage({ ...message, text: "" })).toThrow(
|
||||||
|
expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateMailMessage({ html: "<p>Hi</p>", subject: message.subject, to: message.to }).html,
|
||||||
|
).toBe("<p>Hi</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an injectable reply-to", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateMailMessage({
|
||||||
|
...message,
|
||||||
|
replyTo: "[email protected]\r\nBcc: [email protected]",
|
||||||
|
}),
|
||||||
|
).toThrow(expect.objectContaining({ code: "MAIL_MESSAGE_INVALID" }));
|
||||||
|
expect(validateMailMessage({ ...message, replyTo: "[email protected]" }).replyTo).toBe(
|
||||||
|
"[email protected]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never echoes the message into the error", () => {
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
validateMailMessage({
|
||||||
|
...message,
|
||||||
|
subject: `secret-${password}` + "\r\nBcc: [email protected]",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(MailError);
|
||||||
|
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||||
|
expect(rendered).not.toContain(password);
|
||||||
|
expect(rendered).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { createServer, type Server } from "node:net";
|
||||||
|
import { SMTPServer } from "smtp-server";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createMailProviderConfig, type MailProviderConfig } from "../src/mail/config.js";
|
||||||
|
import { MailError, validateMailMessage } from "../src/mail/message.js";
|
||||||
|
import type { MailProvider } from "../src/mail/provider.js";
|
||||||
|
import { createSmtpMailProvider } from "../src/mail/smtp-provider.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SMTP provider against a **real SMTP server** speaking the real protocol.
|
||||||
|
*
|
||||||
|
* The unit suite proves the contract with no provider at all; this proves the things only a server
|
||||||
|
* can. Every claim here is a property of the agreement between Nodemailer and an SMTP server rather
|
||||||
|
* than of code in this repository: that the host's sender really is the one on the wire, that a
|
||||||
|
* refused recipient is distinguishable from an outage, that a silent server eventually gives up
|
||||||
|
* rather than hanging forever, and that a STARTTLS transport refuses to continue in the clear.
|
||||||
|
*
|
||||||
|
* `smtp-server` is used rather than a mock because it is the reference implementation from the same
|
||||||
|
* project as the client, it runs in-process, and it needs no container — so this adds no CI
|
||||||
|
* infrastructure and sends no real email. **Nothing here reaches the internet**, and no live
|
||||||
|
* credential exists anywhere in this suite.
|
||||||
|
*/
|
||||||
|
interface CapturedMessage {
|
||||||
|
readonly from: string;
|
||||||
|
readonly to: readonly string[];
|
||||||
|
readonly data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
readonly port: number;
|
||||||
|
readonly captured: CapturedMessage[];
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openHarnesses: Array<() => Promise<void>> = [];
|
||||||
|
const openProviders: MailProvider[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const provider of openProviders.splice(0)) {
|
||||||
|
await provider.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
for (const close of openHarnesses.splice(0)) {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface HarnessOptions {
|
||||||
|
/** When set, the server demands AUTH and accepts only this exact pair. */
|
||||||
|
readonly auth?: { readonly username: string; readonly password: string };
|
||||||
|
/** Rejects exactly these recipients at RCPT TO, as a real relay does for an unknown mailbox. */
|
||||||
|
readonly rejectRecipients?: readonly string[];
|
||||||
|
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
|
||||||
|
readonly offerStartTls?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||||
|
const captured: CapturedMessage[] = [];
|
||||||
|
const server = new SMTPServer({
|
||||||
|
authOptional: options.auth === undefined,
|
||||||
|
// Deliberately no TLS unless a scenario asks: proving the client's downgrade guard needs a
|
||||||
|
// server that does not offer the upgrade.
|
||||||
|
hideSTARTTLS: options.offerStartTls !== true,
|
||||||
|
onAuth(auth, _session, callback) {
|
||||||
|
if (
|
||||||
|
options.auth !== undefined &&
|
||||||
|
auth.username === options.auth.username &&
|
||||||
|
auth.password === options.auth.password
|
||||||
|
) {
|
||||||
|
callback(null, { user: auth.username });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(new Error("Invalid credentials"));
|
||||||
|
},
|
||||||
|
onData(stream, session, callback) {
|
||||||
|
let data = "";
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
captured.push({
|
||||||
|
data,
|
||||||
|
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
|
||||||
|
to: session.envelope.rcptTo.map((entry) => entry.address),
|
||||||
|
});
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRcptTo(address, _session, callback) {
|
||||||
|
if (options.rejectRecipients?.includes(address.address)) {
|
||||||
|
// 550 is a permanent refusal of this mailbox, which is what a real relay returns.
|
||||||
|
const error = new Error("Mailbox unavailable") as Error & { responseCode?: number };
|
||||||
|
error.responseCode = 550;
|
||||||
|
callback(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.server.address() as AddressInfo;
|
||||||
|
const close = async (): Promise<void> => {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
};
|
||||||
|
openHarnesses.push(close);
|
||||||
|
return { captured, close, port: address.port };
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
|
||||||
|
const built = createMailProviderConfig(
|
||||||
|
{
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
// The local test server speaks no TLS. Production configuration refuses this mode outright,
|
||||||
|
// which is exactly why the development environment has to be selected explicitly here.
|
||||||
|
security: "plaintext",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
if (!built) {
|
||||||
|
throw new Error("The SMTP integration configuration is invalid.");
|
||||||
|
}
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(providerConfig: MailProviderConfig, bounds = {}): MailProvider {
|
||||||
|
const created = createSmtpMailProvider(providerConfig, bounds);
|
||||||
|
openProviders.push(created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.sequential("live SMTP transport", () => {
|
||||||
|
it("delivers a transactional message with the host's sender, subject, and both bodies", async () => {
|
||||||
|
const harness = await startSmtpServer();
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
const result = await mail.send(
|
||||||
|
validateMailMessage({
|
||||||
|
html: "<p>Open the link to continue.</p>",
|
||||||
|
subject: "Verify your email address",
|
||||||
|
text: "Open the link to continue.",
|
||||||
|
to: "[email protected]",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(harness.captured).toHaveLength(1);
|
||||||
|
const message = harness.captured[0];
|
||||||
|
expect(message?.to).toEqual(["[email protected]"]);
|
||||||
|
// The envelope sender is the host's configured address — the caller never chose it and has no
|
||||||
|
// field in which to.
|
||||||
|
expect(message?.from).toBe("[email protected]");
|
||||||
|
expect(message?.data).toContain("From: Ekklesia <[email protected]>");
|
||||||
|
expect(message?.data).toContain("Subject: Verify your email address");
|
||||||
|
expect(message?.data).toContain("Open the link to continue.");
|
||||||
|
expect(message?.data).toContain("<p>Open the link to continue.</p>");
|
||||||
|
// A provider message id is the only thing that comes back, and it names no recipient.
|
||||||
|
expect(result.messageId).toMatch(/@/u);
|
||||||
|
expect(result.messageId ?? "").not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends to a small bounded list, and to exactly the addresses given", async () => {
|
||||||
|
const harness = await startSmtpServer();
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({
|
||||||
|
subject: "Invitation",
|
||||||
|
text: "You are invited.",
|
||||||
|
to: ["[email protected]", "[email protected]"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(harness.captured[0]?.to).toEqual(["[email protected]", "[email protected]"]);
|
||||||
|
// No silent extra recipient: nothing in this platform adds a Bcc.
|
||||||
|
expect(harness.captured[0]?.data).not.toMatch(/^Bcc:/imu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries a reply-to without letting it become the sender", async () => {
|
||||||
|
const harness = await startSmtpServer();
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({
|
||||||
|
replyTo: "[email protected]",
|
||||||
|
subject: "Notice",
|
||||||
|
text: "Body.",
|
||||||
|
to: "[email protected]",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = harness.captured[0]?.data ?? "";
|
||||||
|
expect(data).toContain("Reply-To: [email protected]");
|
||||||
|
expect(data).toContain("From: Ekklesia <[email protected]>");
|
||||||
|
expect(harness.captured[0]?.from).toBe("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authenticates when the relay demands it, and reports a refusal as rejected", async () => {
|
||||||
|
const harness = await startSmtpServer({
|
||||||
|
auth: { password: "relay-password", username: "[email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticated = provider(
|
||||||
|
config(harness.port, { password: "relay-password", username: "[email protected]" }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
authenticated.send(
|
||||||
|
validateMailMessage({ subject: "Authenticated", text: "Body.", to: "[email protected]" }),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({});
|
||||||
|
expect(harness.captured).toHaveLength(1);
|
||||||
|
|
||||||
|
const wrong = provider(
|
||||||
|
config(harness.port, { password: "wrong-password", username: "[email protected]" }),
|
||||||
|
);
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await wrong.send(
|
||||||
|
validateMailMessage({ subject: "Rejected", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(MailError);
|
||||||
|
expect((caught as MailError).code).toBe("MAIL_REJECTED");
|
||||||
|
// The server's response text — which can name the account — never reaches the caller.
|
||||||
|
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
||||||
|
expect(rendered).not.toContain("wrong-password");
|
||||||
|
expect(rendered).not.toContain("[email protected]");
|
||||||
|
expect(rendered).not.toContain("Invalid credentials");
|
||||||
|
// And nothing was delivered.
|
||||||
|
expect(harness.captured).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes a refused mailbox from an outage", async () => {
|
||||||
|
const harness = await startSmtpServer({ rejectRecipients: ["[email protected]"] });
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
// The one failure a human can usually act on keeps its own code.
|
||||||
|
expect((caught as MailError).code).toBe("MAIL_RECIPIENT_INVALID");
|
||||||
|
expect(harness.captured).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a refused connection as provider-unavailable rather than hanging", async () => {
|
||||||
|
// A port nothing listens on: a real connection failure rather than a simulated one.
|
||||||
|
const mail = provider(config(1));
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(MailError);
|
||||||
|
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up on a server that accepts the connection and then never greets", async () => {
|
||||||
|
// A refused connection fails fast on its own; a server that *answers* and then stalls is the
|
||||||
|
// failure with no natural end, and the one that has to be bounded deliberately. This is the
|
||||||
|
// lesson the object-storage pass learned the hard way.
|
||||||
|
const sockets = new Set<import("node:net").Socket>();
|
||||||
|
const blackHole: Server = createServer((socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.on("close", () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => blackHole.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = (blackHole.address() as AddressInfo).port;
|
||||||
|
|
||||||
|
const mail = provider(config(port), {
|
||||||
|
// The production defaults are deliberately patient; this proves the mechanism, not the number.
|
||||||
|
connectionTimeoutMs: 300,
|
||||||
|
greetingTimeoutMs: 300,
|
||||||
|
socketTimeoutMs: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - startedAt;
|
||||||
|
|
||||||
|
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
|
||||||
|
// The point of the assertion is that it *ended*. Without a deadline Nodemailer waits minutes,
|
||||||
|
// and a person waiting on a password-reset email waits with it.
|
||||||
|
expect(elapsed).toBeLessThan(5_000);
|
||||||
|
|
||||||
|
for (const socket of sockets) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => blackHole.close(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to continue in the clear when STARTTLS was required but is not offered", async () => {
|
||||||
|
const harness = await startSmtpServer({ offerStartTls: false });
|
||||||
|
const mail = provider(config(harness.port, { security: "starttls" }));
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
caught = error;
|
||||||
|
}
|
||||||
|
// The downgrade guard. A server that stops advertising STARTTLS — or an attacker who strips it
|
||||||
|
// — produces a failed send, never a plaintext session carrying a password.
|
||||||
|
expect(caught).toBeInstanceOf(MailError);
|
||||||
|
expect(harness.captured).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies connectivity without sending anything", async () => {
|
||||||
|
const harness = await startSmtpServer();
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
await expect(mail.verify()).resolves.toBeUndefined();
|
||||||
|
// The assertion Doctor depends on: a diagnostic that emailed somebody every time an operator ran
|
||||||
|
// it would be its own incident.
|
||||||
|
expect(harness.captured).toHaveLength(0);
|
||||||
|
|
||||||
|
const unreachable = provider(config(1));
|
||||||
|
await expect(unreachable.verify()).rejects.toMatchObject({
|
||||||
|
code: "MAIL_PROVIDER_UNAVAILABLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends exactly once, with no retry behind the caller's back", async () => {
|
||||||
|
const harness = await startSmtpServer();
|
||||||
|
const mail = provider(config(harness.port));
|
||||||
|
|
||||||
|
await mail.send(
|
||||||
|
validateMailMessage({ subject: "Once", text: "Body.", to: "[email protected]" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One call, one message. Retrying an ambiguous send is how one password-reset request becomes
|
||||||
|
// three emails, so nothing here retries (ADR 0037).
|
||||||
|
expect(harness.captured).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 0037: Outbound email is a host-provided transport, and SMTP is its first provider
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-08-24
|
||||||
|
- Extends: [0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||||
|
[0029](0029-module-configuration-and-secrets.md),
|
||||||
|
[0030](0030-host-provided-module-object-storage.md),
|
||||||
|
[0034](0034-host-owned-browser-authentication.md),
|
||||||
|
[0035](0035-initial-platform-owner-bootstrap.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Ekklesia can authenticate people and pair devices, but it cannot send a single email. Better Auth
|
||||||
|
has been configured since [ADR 0011](0011-identity-http-transport-and-public-enrollment.md) with
|
||||||
|
`emailAndPassword` and nothing else: its `sendResetPassword` and `emailVerification.sendVerificationEmail`
|
||||||
|
hooks are both unimplemented, so email verification, password recovery, and invitations are all
|
||||||
|
blocked on the same missing thing.
|
||||||
|
|
||||||
|
That missing thing is not "a Google Workspace integration". It is a transport — and the shape of the
|
||||||
|
transport decides whether the features built on it stay portable. The first real deployment is
|
||||||
|
expected to relay through Google Workspace, but a church that later moves to Microsoft 365, a
|
||||||
|
transactional provider, or its own Postfix must not be rewriting feature code to do it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Outbound email is a **host-provided infrastructure service** with a provider seam, exactly as object
|
||||||
|
storage is under [ADR 0030](0030-host-provided-module-object-storage.md):
|
||||||
|
|
||||||
|
```text
|
||||||
|
trusted server-side feature
|
||||||
|
↓ "send this transactional message"
|
||||||
|
MailProvider seam
|
||||||
|
↓
|
||||||
|
SMTP → whatever the deployment relays through
|
||||||
|
```
|
||||||
|
|
||||||
|
Feature code owns the recipient, subject, and body. 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.
|
||||||
|
|
||||||
|
### The contract is deliberately tiny
|
||||||
|
|
||||||
|
One recipient or a small bounded list, a subject, a text body, an HTML body, and an optional
|
||||||
|
reply-to. There are no attachments, no custom headers, no CC, no BCC, no templates, and no mailing
|
||||||
|
lists. This is transactional infrastructure: every field beyond that is surface a future caller
|
||||||
|
could abuse, and it stays out until something real needs it.
|
||||||
|
|
||||||
|
At least one of `text` or `html` is required. A message with neither is not a message, and sending
|
||||||
|
an empty one silently would be worse than refusing it.
|
||||||
|
|
||||||
|
### There is no `from` field, anywhere
|
||||||
|
|
||||||
|
The sender is host configuration. A message has nowhere to put a sender, so no feature — and no
|
||||||
|
future module — can choose who mail appears to come from. Letting feature code pick a sender is how
|
||||||
|
a platform acquires an impersonation primitive and destroys its own domain reputation at the same
|
||||||
|
time. If several approved sender identities are ever genuinely needed, that is a deliberate design
|
||||||
|
with its own authorization, not a field.
|
||||||
|
|
||||||
|
### Header injection is refused, never repaired
|
||||||
|
|
||||||
|
Carriage returns, line feeds, and every other control character are rejected in addresses, display
|
||||||
|
names, subjects, and reply-to values. A mail header is line-oriented, so a `\r\n` in a subject lets a
|
||||||
|
caller append headers of its own — a `Bcc:` elsewhere, a replaced `From:`, or a second body.
|
||||||
|
|
||||||
|
They are **refused rather than stripped** because silently rewriting input means the value that was
|
||||||
|
validated is not the value that gets sent. Writing that rule down was not enough: the first
|
||||||
|
implementation trimmed an address before checking it, which quietly accepted a trailing carriage
|
||||||
|
return, and a test caught it. The check now runs on the raw value.
|
||||||
|
|
||||||
|
### Explicit TLS modes, never a boolean
|
||||||
|
|
||||||
|
`implicit-tls` (conventionally 465), `starttls` (587/25), or `plaintext`.
|
||||||
|
|
||||||
|
A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and
|
||||||
|
hope STARTTLS is offered", and a server that stops advertising it — or an attacker who strips it —
|
||||||
|
turns that into a plaintext session carrying a password, with nothing in the configuration having
|
||||||
|
changed. Naming the mode makes the downgrade unreachable by accident: `starttls` sets Nodemailer's
|
||||||
|
`requireTLS`, so a missing upgrade is a failed send. An omitted mode defaults to `starttls`, never to
|
||||||
|
the one that sends a password in the clear, and production refuses `plaintext` outright.
|
||||||
|
|
||||||
|
Certificate verification is left at the library's verifying default, and there is deliberately **no
|
||||||
|
configuration path that could switch it off** — a setting that disables verification is one someone
|
||||||
|
eventually disables in production.
|
||||||
|
|
||||||
|
### Credentials are optional as a pair
|
||||||
|
|
||||||
|
Most SMTP examples assume a username and password. Google Workspace SMTP Relay is commonly
|
||||||
|
authorized by **source IP** and has neither, so requiring them would make the intended production
|
||||||
|
arrangement unconfigurable. Half a pair is always a mistake, though — someone set one variable and
|
||||||
|
expected authentication — so it fails loudly rather than silently connecting anonymously.
|
||||||
|
|
||||||
|
The password is a deployment secret. It is never persisted in PostgreSQL, never routed through
|
||||||
|
module configuration, never returned by any route, never printed by Doctor, and never placed in an
|
||||||
|
error. It follows the discipline [ADR 0029](0029-module-configuration-and-secrets.md) established.
|
||||||
|
|
||||||
|
### Bounded on every axis, and no retry
|
||||||
|
|
||||||
|
`connectionTimeout`, `greetingTimeout`, and `socketTimeout` are all set explicitly. Nodemailer's own
|
||||||
|
defaults are two minutes and ten minutes, which is not a deadline anyone would choose for a
|
||||||
|
password-reset email someone is waiting on — and the object-storage pass already learned what an
|
||||||
|
unbounded network client does to a request under load.
|
||||||
|
|
||||||
|
**Nothing retries.** A failure after the message data has gone out is genuinely ambiguous: the
|
||||||
|
server may have accepted it and only the acknowledgement was lost. Retrying an ambiguous send is how
|
||||||
|
one password-reset request becomes three emails. A single bounded attempt is the whole behaviour,
|
||||||
|
and `send` resolving means the next hop took responsibility — not that anyone received anything.
|
||||||
|
This is deliberately **not** an exactly-once promise. A durable outbox with deliberate retry
|
||||||
|
semantics can be added later if product requirements justify it; guessing at one now would be
|
||||||
|
inventing delivery guarantees this transport does not have.
|
||||||
|
|
||||||
|
### Errors are structured and say nothing
|
||||||
|
|
||||||
|
`MAIL_NOT_CONFIGURED`, `MAIL_RECIPIENT_INVALID`, `MAIL_MESSAGE_INVALID`, `MAIL_PROVIDER_UNAVAILABLE`,
|
||||||
|
`MAIL_REJECTED`, `MAIL_SEND_FAILED`. A 4xx SMTP reply maps to unavailable and a 5xx to rejected, so a
|
||||||
|
caller can tell a transient outage from something that will never be accepted, and a refused mailbox
|
||||||
|
keeps its own code because it is the one failure a human can usually act on.
|
||||||
|
|
||||||
|
Nothing from the underlying error survives: not the server's response text, not the greeting, not
|
||||||
|
the auth challenge, not the envelope. SMTP servers routinely echo back what was sent to them, and an
|
||||||
|
authentication failure's reply can name the account.
|
||||||
|
|
||||||
|
### Email is optional, and the platform still runs without it
|
||||||
|
|
||||||
|
An installation with no SMTP host configured is a **complete deployment**. Nothing in the platform
|
||||||
|
sends email yet, so Doctor reports it as `skipped` rather than a warning, and a future feature that
|
||||||
|
needs to send discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing. Whether email
|
||||||
|
becomes *required* for particular identity flows is Pass 2's decision to make when those flows exist.
|
||||||
|
|
||||||
|
### Nothing sends at startup, and no route sends at all
|
||||||
|
|
||||||
|
Constructing the transport opens no socket. The first connection happens when something actually
|
||||||
|
sends, so an unreachable relay can never stop the platform from booting, and a temporary SMTP outage
|
||||||
|
is an operational condition rather than a module lifecycle event.
|
||||||
|
|
||||||
|
There is deliberately **no `POST /api/v1/send-email`**, and no HTTP route anywhere touches the mail
|
||||||
|
provider. An authenticated relay endpoint is an open relay with extra steps; a test asserts that no
|
||||||
|
route file so much as imports the mail module.
|
||||||
|
|
||||||
|
### Core only, for now
|
||||||
|
|
||||||
|
`ModuleRuntimeServices` carries `database` and nothing else, with a standing note that services are
|
||||||
|
added **one at a time, when something real needs one**. No module needs email in this pass —
|
||||||
|
Announcements migration is out of scope — so modules get none, and a test asserts the module SDKs
|
||||||
|
have no way to send.
|
||||||
|
|
||||||
|
This is not a permanent decision. When a module genuinely needs transactional mail, the module-facing
|
||||||
|
contract goes in `@ekklesia/contracts` and Core layers a scoped facade over this same
|
||||||
|
`MailProvider` seam — exactly the path object storage took. The seam is already the right shape for
|
||||||
|
it: a scoped facade would add the module's identity and leave the sender, the bounds, and the
|
||||||
|
validation exactly where they are.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### The seam Pass 2 wires into
|
||||||
|
|
||||||
|
Better Auth's two hooks take a `{ user, url, token }` and return a promise:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
emailAndPassword: { sendResetPassword: async ({ user, url }) => { /* mail.send(...) */ } }
|
||||||
|
emailVerification: { sendVerificationEmail: async ({ user, url }) => { /* mail.send(...) */ } }
|
||||||
|
```
|
||||||
|
|
||||||
|
`createIdentityRuntime` will take the provider as an injected option and call `mail.send` from
|
||||||
|
inside them. `startApi` already constructs the provider and closes it on every shutdown path, so
|
||||||
|
Pass 2 adds the two functions and the message bodies and nothing else. **The `url` and `token` those
|
||||||
|
hooks receive are credentials** — the whole point of the email is that only the recipient sees them
|
||||||
|
— so they must never be logged, and the transport already refuses to log a message body.
|
||||||
|
|
||||||
|
### What is safe to log
|
||||||
|
|
||||||
|
Send attempted, the provider's message id, and an error category. Never a body, never a subject,
|
||||||
|
never a recipient beyond what an operator genuinely needs, and never a verification or reset link.
|
||||||
|
The transport itself logs nothing at all.
|
||||||
|
|
||||||
|
### Doctor reports configuration, and probes without sending
|
||||||
|
|
||||||
|
`outbound-email.configuration` reports absent, valid-and-reachable, valid-but-unreachable, or
|
||||||
|
structurally invalid. The probe performs the SMTP handshake and, where credentials are set, an
|
||||||
|
authentication exchange — and **sends no message**: a diagnostic that emailed somebody every time an
|
||||||
|
operator ran it would be its own incident.
|
||||||
|
|
||||||
|
An unreachable relay is a warning rather than a failure, because nothing depends on mail yet and
|
||||||
|
some relays decline a connection that sends nothing. The report says so explicitly instead of
|
||||||
|
claiming a health it cannot prove, and it states that a successful connection does not establish
|
||||||
|
deliverability — that depends on the relay's authorization policy and the domain's SPF, DKIM, and
|
||||||
|
DMARC records, none of which is knowable from here.
|
||||||
|
|
||||||
|
### Google Workspace is a target, not a dependency
|
||||||
|
|
||||||
|
It is the expected first deployment and appears nowhere in Core. Whether a relay needs credentials,
|
||||||
|
and which source addresses it accepts, is configured in Google Workspace Admin and varies per
|
||||||
|
deployment — so this repository documents the shape and refuses to claim that a relay works
|
||||||
|
anonymously from an arbitrary host.
|
||||||
|
|
||||||
|
### Ekklesia Lite
|
||||||
|
|
||||||
|
Lite is a minimal trusted-network module runtime. It may one day supply this same contract if an
|
||||||
|
operator configures SMTP, and it must never *require* mail to run modules that do not need it —
|
||||||
|
which is already true, because a provider is optional and its absence is a complete deployment.
|
||||||
|
Nothing here is implemented in Lite and no module code branches on which host is running it.
|
||||||
|
|
||||||
|
### Deferred
|
||||||
|
|
||||||
|
Email verification, password reset, and invitations (Pass 2). Templates, which belong to the
|
||||||
|
features that render them rather than to a transport. Bulk or marketing mail, mailing lists,
|
||||||
|
attachments, DKIM key management, domain-verification automation, provider-specific HTTP API
|
||||||
|
adapters, and a durable outbox with retry workers.
|
||||||
@@ -45,3 +45,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||||
|
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||||
|
|||||||
@@ -495,6 +495,19 @@ re-pairing mints a new identity rather than reviving a revoked one. Ekklesia Lit
|
|||||||
runtime primitives and deliberately no human authentication, permission enforcement, or managed
|
runtime primitives and deliberately no human authentication, permission enforcement, or managed
|
||||||
device identity, so pairing is a full-Ekklesia concern only.
|
device identity, so pairing is a full-Ekklesia concern only.
|
||||||
|
|
||||||
|
Phase 7C pass 1 adds outbound email as a host-provided infrastructure service
|
||||||
|
([ADR 0037](decisions/0037-host-provided-outbound-email.md)), with the same shape object storage
|
||||||
|
uses: a narrow provider-neutral contract, a `MailProvider` seam, and one concrete SMTP
|
||||||
|
implementation. Feature code owns the recipient, subject, and body; the provider owns the host,
|
||||||
|
port, TLS, credentials, and transport errors, and no provider hostname appears in Core. The sender
|
||||||
|
is host configuration and a message has no `from` field, so nothing above the seam can choose an
|
||||||
|
identity to send as. Security modes are explicit rather than a boolean plus a port, `starttls`
|
||||||
|
requires the upgrade, production refuses plaintext, and every timeout is bounded. There is no HTTP
|
||||||
|
route that sends and no module-facing mail service yet — services are added one at a time when
|
||||||
|
something real needs one, and nothing does. Email is optional: an installation without SMTP is a
|
||||||
|
complete deployment, and Better Auth's verification and reset hooks remain unimplemented until
|
||||||
|
Pass 2.
|
||||||
|
|
||||||
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
|
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
|
||||||
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
|
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
|
||||||
`presentation.kind`, or host-identity branch. The canonical builder reads only
|
`presentation.kind`, or host-identity branch. The canonical builder reads only
|
||||||
|
|||||||
@@ -863,6 +863,20 @@ instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "de
|
|||||||
`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's
|
`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's
|
||||||
own `/display/<module-id>` route is.
|
own `/display/<module-id>` route is.
|
||||||
|
|
||||||
|
## Outbound email
|
||||||
|
|
||||||
|
There is **no HTTP API for it**, deliberately
|
||||||
|
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). Email is a runtime
|
||||||
|
capability trusted server-side features call, not a resource the platform exposes: an authenticated
|
||||||
|
send endpoint is an open relay with extra steps, so no route exists and no route file imports the
|
||||||
|
mail module. A test asserts both.
|
||||||
|
|
||||||
|
Host configuration is `EKKLESIA_SMTP_HOST`, `EKKLESIA_SMTP_PORT`, `EKKLESIA_SMTP_SECURITY`,
|
||||||
|
`EKKLESIA_SMTP_USERNAME`/`EKKLESIA_SMTP_PASSWORD` (optional as a pair), `EKKLESIA_SMTP_FROM_ADDRESS`,
|
||||||
|
and `EKKLESIA_SMTP_FROM_NAME`. Absent `EKKLESIA_SMTP_HOST` means this installation sends no email,
|
||||||
|
which is a complete deployment. None of these values ever reaches a module, a browser bundle, a
|
||||||
|
diagnostic, or an API response. See [Outbound email](email.md).
|
||||||
|
|
||||||
## Module HTTP routes
|
## Module HTTP routes
|
||||||
|
|
||||||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Outbound email
|
||||||
|
|
||||||
|
Ekklesia can send transactional email through a host-configured SMTP transport
|
||||||
|
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||||
|
|
||||||
|
**Nothing in the platform sends email yet.** This pass added the transport; email verification,
|
||||||
|
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a
|
||||||
|
complete, valid deployment — the platform starts and every existing feature works.
|
||||||
|
|
||||||
|
## Configuring a relay
|
||||||
|
|
||||||
|
| variable | meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `EKKLESIA_SMTP_HOST` | A bare hostname or IP. **Absent means this installation sends no email.** Never a URL, port, or connection string. |
|
||||||
|
| `EKKLESIA_SMTP_PORT` | Optional; defaults to the conventional port for the security mode. |
|
||||||
|
| `EKKLESIA_SMTP_SECURITY` | `implicit-tls`, `starttls` (default), or `plaintext`. |
|
||||||
|
| `EKKLESIA_SMTP_USERNAME` / `EKKLESIA_SMTP_PASSWORD` | Optional **as a pair**. Half a pair is refused. |
|
||||||
|
| `EKKLESIA_SMTP_FROM_ADDRESS` | Required once a host is set. The only address this platform can send as. |
|
||||||
|
| `EKKLESIA_SMTP_FROM_NAME` | Optional display name. |
|
||||||
|
|
||||||
|
### Security modes are explicit
|
||||||
|
|
||||||
|
A boolean plus a port is ambiguous — "connect in the clear and hope STARTTLS is offered" is a
|
||||||
|
plaintext session waiting to happen. Naming the mode removes the ambiguity:
|
||||||
|
|
||||||
|
- **`implicit-tls`** — TLS from the first byte, conventionally port 465.
|
||||||
|
- **`starttls`** — plaintext connect then a **required** upgrade, conventionally 587 (or 25 on a
|
||||||
|
relay). If the server does not offer STARTTLS the send **fails** rather than continuing in the
|
||||||
|
clear. This is the default and what most relays expect.
|
||||||
|
- **`plaintext`** — no TLS. Development and trusted local relays only; **production refuses it.**
|
||||||
|
|
||||||
|
Certificate verification is always on and there is no setting that turns it off.
|
||||||
|
|
||||||
|
### Google Workspace SMTP Relay
|
||||||
|
|
||||||
|
The expected first production deployment, and a compatibility target rather than a dependency — no
|
||||||
|
provider hostname appears anywhere in Ekklesia's code.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EKKLESIA_SMTP_HOST=<the relay host shown in Google Workspace Admin>
|
||||||
|
EKKLESIA_SMTP_PORT=587
|
||||||
|
EKKLESIA_SMTP_SECURITY=starttls
|
||||||
|
EKKLESIA_SMTP_FROM_ADDRESS=noreply@example.org
|
||||||
|
EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||||
|
```
|
||||||
|
|
||||||
|
Whether that relay also needs `EKKLESIA_SMTP_USERNAME` and `EKKLESIA_SMTP_PASSWORD` depends entirely
|
||||||
|
on how the relay is authorized in **Google Workspace Admin**, which varies per deployment. Google
|
||||||
|
supports authorizing by source IP range, by SMTP authentication, or both, and the setting also
|
||||||
|
controls which envelope senders are accepted.
|
||||||
|
|
||||||
|
**A Google relay does not accept mail anonymously from an arbitrary internet host.** If the
|
||||||
|
deployment relies on IP authorization, the server's egress address has to be registered, and a
|
||||||
|
change of hosting provider or NAT address will break sending until it is updated.
|
||||||
|
|
||||||
|
The same variables address Microsoft 365, a transactional provider's SMTP endpoint, a local Postfix,
|
||||||
|
or anything else that speaks the protocol. Switching between them is configuration, not code.
|
||||||
|
|
||||||
|
## The sender is host-owned
|
||||||
|
|
||||||
|
There is deliberately **no way for feature code to choose a sender.** A message has no `from` field
|
||||||
|
at all; `EKKLESIA_SMTP_FROM_ADDRESS` is the only address this platform can send as. Letting features
|
||||||
|
pick a sender is how a platform acquires an impersonation primitive and ruins its own domain
|
||||||
|
reputation.
|
||||||
|
|
||||||
|
Deliverability is a deployment concern this repository cannot verify for you: publish SPF, DKIM, and
|
||||||
|
DMARC records for the sending domain, and make sure the relay is authorized to send as that address.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
`EKKLESIA_SMTP_PASSWORD` is a deployment secret, handled like every other one
|
||||||
|
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): never stored in
|
||||||
|
PostgreSQL, never routed through module configuration, never returned by any route, never printed by
|
||||||
|
Doctor, and never included in an error. `.env.example` documents the variable and deliberately
|
||||||
|
carries no value.
|
||||||
|
|
||||||
|
## What the transport does and does not promise
|
||||||
|
|
||||||
|
- **One bounded attempt, no retry.** A failure after the message data has gone out is ambiguous —
|
||||||
|
the server may have accepted it and only the acknowledgement was lost — so retrying would turn one
|
||||||
|
password-reset request into several emails. `send` resolving means the next hop accepted
|
||||||
|
responsibility, **not** that anyone received anything. There is no outbox and no exactly-once
|
||||||
|
guarantee.
|
||||||
|
- **Bounded connection, greeting, and socket timeouts.** A silent server returns a structured
|
||||||
|
`MAIL_PROVIDER_UNAVAILABLE` rather than hanging.
|
||||||
|
- **A tiny contract.** One recipient or a small bounded list, a subject, text and/or HTML, and an
|
||||||
|
optional reply-to. No attachments, headers, CC, BCC, templates, or bulk sending.
|
||||||
|
- **Header injection is refused.** Carriage returns, line feeds, and other control characters in
|
||||||
|
addresses, display names, subjects, and reply-to values are rejected rather than stripped.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
`pnpm doctor` reports `outbound-email.configuration` as one of:
|
||||||
|
|
||||||
|
- **skipped** — no host configured. A complete deployment.
|
||||||
|
- **pass** — connected, and authenticated where credentials are set. It **sent nothing**.
|
||||||
|
- **warning** — configured but did not answer. A warning rather than a failure, because nothing
|
||||||
|
depends on mail yet and some relays decline a connection that sends no message.
|
||||||
|
- **failure** — the settings are structurally invalid, reported as a sanitized code with no
|
||||||
|
credential.
|
||||||
|
|
||||||
|
A successful probe proves the relay accepts this deployment. It does **not** prove a message would
|
||||||
|
reach an inbox — that depends on the relay's authorization policy and the domain's DNS records.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Transactional email carries verification links, reset links, and personal data. The transport logs
|
||||||
|
nothing. Features built on it should log at most that a send was attempted, the provider's message
|
||||||
|
id, and an error category — **never** a body, a subject, or a verification or reset link.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`core/tests/mail-smtp.integration.test.ts` runs a real in-process SMTP server (`smtp-server`) and
|
||||||
|
proves message delivery, the host-owned sender on the wire, authentication and its refusal, a
|
||||||
|
rejected mailbox, 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.**
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail`
|
||||||
|
hooks — both currently unimplemented — to this transport. See
|
||||||
|
[Identity](identity.md#outbound-email-and-the-verification-seam).
|
||||||
@@ -279,6 +279,32 @@ wins, so a browser holding both remains a person.
|
|||||||
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
|
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
|
||||||
to decide whether a person is signed in, and a device is not one.
|
to decide whether a person is signed in, and a device is not one.
|
||||||
|
|
||||||
|
## Outbound email and the verification seam
|
||||||
|
|
||||||
|
Ekklesia has a host-provided SMTP transport
|
||||||
|
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md), see
|
||||||
|
[Outbound email](email.md)) — but **no identity flow uses it yet.**
|
||||||
|
|
||||||
|
Better Auth exposes exactly two hooks for the flows that need mail, and this repository configures
|
||||||
|
neither:
|
||||||
|
|
||||||
|
| hook | flow |
|
||||||
|
| --- | --- |
|
||||||
|
| `emailAndPassword.sendResetPassword({ user, url, token })` | Password reset |
|
||||||
|
| `emailVerification.sendVerificationEmail({ user, url, token })` | Email verification |
|
||||||
|
|
||||||
|
Phase 7C Pass 2 implements them over the mail transport. `startApi` already constructs the provider
|
||||||
|
and closes it on every shutdown path, so the remaining work is to inject it into
|
||||||
|
`createIdentityRuntime` and write the two message bodies.
|
||||||
|
|
||||||
|
**The `url` and `token` those hooks receive are credentials.** The entire security of both flows is
|
||||||
|
that only the recipient sees them, so they must never be logged, never appear in an error, and never
|
||||||
|
reach a diagnostic. The transport already logs nothing and refuses to echo a message body.
|
||||||
|
|
||||||
|
Until that pass lands: email verification, password recovery, and invitations do not exist, and
|
||||||
|
`requireEmailVerification` stays off — turning it on without a working sender would lock every
|
||||||
|
account out.
|
||||||
|
|
||||||
## Creating the first platform owner
|
## Creating the first platform owner
|
||||||
|
|
||||||
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
|
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
|
||||||
|
|||||||
@@ -582,6 +582,44 @@ connection immediately, but an already-open SSE stream is not torn down at that
|
|||||||
five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving
|
five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving
|
||||||
a channel. That window is stated rather than described as instant.
|
a channel. That window is stated rather than described as instant.
|
||||||
|
|
||||||
|
### Outbound email is host-owned, and cannot be turned into a relay
|
||||||
|
|
||||||
|
Ekklesia sends transactional email through a host-configured SMTP transport
|
||||||
|
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). What keeps it from
|
||||||
|
becoming a liability:
|
||||||
|
|
||||||
|
- **No feature can choose a sender.** A message has no `from` field at all; the sender is host
|
||||||
|
configuration. Feature-chosen senders are an impersonation primitive and a deliverability
|
||||||
|
catastrophe at the same time.
|
||||||
|
- **No HTTP route can send.** There is deliberately no `POST /api/v1/send-email` and no route that
|
||||||
|
touches the mail provider — an authenticated relay endpoint is an open relay with extra steps. A
|
||||||
|
test asserts no route file even imports the mail module.
|
||||||
|
- **No module can send.** `ModuleRuntimeServices` carries no mail service, and a test asserts the
|
||||||
|
module SDKs have no way to. Browser code has none either: the SMTP dependency exists only in Core,
|
||||||
|
which a surface cannot import.
|
||||||
|
- **Header injection is refused, not repaired.** Control characters in addresses, display names,
|
||||||
|
subjects, and reply-to values are rejected. The first implementation trimmed before checking and
|
||||||
|
silently accepted a trailing carriage return; a test caught it and the check now runs on the raw
|
||||||
|
value.
|
||||||
|
- **Bounded everything.** Recipients (10), subject (200), text (64 KiB), HTML (256 KiB), and
|
||||||
|
connection, greeting, and socket timeouts are all explicit. A silent server returns a structured
|
||||||
|
error rather than hanging a request.
|
||||||
|
- **No downgrade.** Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than
|
||||||
|
inferred from a boolean and a port. `starttls` **requires** the upgrade, so a server that stops
|
||||||
|
offering it — or an attacker who strips it — fails the send instead of continuing in the clear.
|
||||||
|
Production refuses `plaintext`. Certificate verification is always on and there is no setting that
|
||||||
|
disables it.
|
||||||
|
- **The password is a deployment secret.** Never in PostgreSQL, never in module configuration, never
|
||||||
|
in an API response, never in Doctor output, never in an error, and no value in `.env.example`.
|
||||||
|
- **Nothing is logged.** The transport logs nothing at all. Transactional mail carries verification
|
||||||
|
and reset links, which are exactly the credentials those flows depend on.
|
||||||
|
- **No blind retry.** One bounded attempt. A failure after the message data has been sent is
|
||||||
|
ambiguous, and retrying it would send someone several copies of the same reset email.
|
||||||
|
|
||||||
|
Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy — is a deployment
|
||||||
|
responsibility this repository cannot verify, and Doctor says so rather than implying a successful
|
||||||
|
connection means mail arrives.
|
||||||
|
|
||||||
### Rate limiting is per-process today
|
### Rate limiting is per-process today
|
||||||
|
|
||||||
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
||||||
|
|||||||
@@ -375,6 +375,31 @@ an `authenticated` baseline, from platform ownership, or from a user grant. Its
|
|||||||
answer "yes" to every human question, so any accidental fall-through into a human path would flip
|
answer "yes" to every human question, so any accidental fall-through into a human path would flip
|
||||||
those assertions.
|
those assertions.
|
||||||
|
|
||||||
|
Outbound email is proven against a **real SMTP server** rather than a mock
|
||||||
|
(`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||||
|
`smtp-server` — the reference implementation from the same project as the client — runs in-process,
|
||||||
|
so this needs no container and adds no CI infrastructure. **No test sends real email, and no live
|
||||||
|
credential exists in this repository or in CI.**
|
||||||
|
|
||||||
|
It pins the things only a server can settle: a delivered message whose envelope sender and `From:`
|
||||||
|
header are the *host's* configured address rather than anything a caller chose; both bodies and a
|
||||||
|
reply-to arriving intact; authentication succeeding and its refusal mapping to a sanitized
|
||||||
|
`MAIL_REJECTED` that echoes neither the password nor the server's response text; a mailbox refused at
|
||||||
|
`RCPT TO` mapping to `MAIL_RECIPIENT_INVALID` rather than a generic failure; a refused connection
|
||||||
|
mapping to `MAIL_PROVIDER_UNAVAILABLE`; a server that accepts a connection and never greets hitting
|
||||||
|
its deadline instead of hanging; a `starttls` transport refusing to continue when the upgrade is not
|
||||||
|
offered; `verify()` connecting without sending anything; and one `send` producing exactly one
|
||||||
|
message.
|
||||||
|
|
||||||
|
`core/tests/mail-contract.test.ts` covers the boundary with no network at all, and is mostly
|
||||||
|
negative — the contract's job is to make an impossible message impossible to construct. It also pins
|
||||||
|
that a validated message has no `from` key, which is the structural reason no feature can spoof a
|
||||||
|
sender. `core/tests/mail-boundaries.test.ts` asserts the properties no runtime test can: the SMTP
|
||||||
|
dependency exists in Core alone, no browser-facing package imports it, module SDKs have no way to
|
||||||
|
send, no HTTP route references the mail module, Core's mail code names no provider hostname and
|
||||||
|
never mentions disabling certificate verification, and `.env.example` documents the variables with
|
||||||
|
no password value.
|
||||||
|
|
||||||
### Which kind of timing proof lives where
|
### Which kind of timing proof lives where
|
||||||
|
|
||||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||||
|
|||||||
Generated
+54
@@ -97,6 +97,9 @@ importers:
|
|||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 0.45.2
|
specifier: 0.45.2
|
||||||
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
version: 0.45.2(@types/[email protected])([email protected])([email protected])
|
||||||
|
nodemailer:
|
||||||
|
specifier: 9.0.5
|
||||||
|
version: 9.0.5
|
||||||
pg:
|
pg:
|
||||||
specifier: 8.22.0
|
specifier: 8.22.0
|
||||||
version: 8.22.0
|
version: 8.22.0
|
||||||
@@ -107,15 +110,24 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.13.3
|
specifier: 24.13.3
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: 8.0.1
|
||||||
|
version: 8.0.1
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: 8.20.0
|
specifier: 8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
|
'@types/smtp-server':
|
||||||
|
specifier: 3.5.13
|
||||||
|
version: 3.5.13
|
||||||
auth:
|
auth:
|
||||||
specifier: 1.6.23
|
specifier: 1.6.23
|
||||||
version: 1.6.23(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
version: 1.6.23(@better-fetch/[email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: 0.31.10
|
specifier: 0.31.10
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
|
smtp-server:
|
||||||
|
specifier: 3.19.3
|
||||||
|
version: 3.19.3
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
@@ -1272,6 +1284,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
|
||||||
|
|
||||||
@@ -1286,6 +1301,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==}
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
@@ -1769,6 +1787,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
|
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -1914,6 +1935,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
|
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -2004,6 +2029,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
|
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
|
||||||
|
|
||||||
@@ -2059,6 +2088,10 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-1WkMlusxxy0Pdx3Bj4+nxzXLakON3EvV8+e0ZD/u6weNkHiX/ih2KGRV2A6Hg54Z30CQjE1OvNs79EZ0r0fBkw==}
|
||||||
|
engines: {node: '>=18.18.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3114,6 +3147,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.13.3
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
@@ -3130,6 +3167,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.13.3
|
||||||
|
'@types/nodemailer': 8.0.1
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@typescript/[email protected]':
|
'@typescript/[email protected]':
|
||||||
@@ -3515,6 +3557,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -3608,6 +3652,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -3691,6 +3737,8 @@ snapshots:
|
|||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
defu: 6.1.7
|
defu: 6.1.7
|
||||||
@@ -3746,6 +3794,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
ipv6-normalize: 1.0.1
|
||||||
|
nodemailer: 9.0.5
|
||||||
|
punycode.js: 2.3.1
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
|
|||||||
Reference in New Issue
Block a user