feat: add a host-provided outbound email transport
Ekklesia could authenticate people and pair devices but could not send a single message. Better Auth has been configured with emailAndPassword since ADR 0011 and neither of its mail hooks — sendResetPassword and emailVerification.sendVerificationEmail — was ever implemented, so email verification, password recovery, and invitations were all blocked on the same missing thing. That missing thing is a transport, not a Google Workspace integration. Email is now a host-provided infrastructure service with a provider seam, shaped exactly like object storage: feature code says "send this transactional message" and the provider owns the host, port, TLS, credentials, connection behaviour, and every transport error. No provider's hostname appears anywhere in Core, and a test asserts it. Google Workspace SMTP Relay is the expected first deployment and a compatibility target — the same variables address Microsoft 365, a transactional provider, or a local Postfix. The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or HTML, an optional reply-to, and nothing else. There is no `from` field at all — the sender is host configuration, so no feature and no future module can choose who mail appears to come from, which is how a platform otherwise acquires an impersonation primitive. Control characters are refused rather than stripped in addresses, display names, subjects, and reply-to values; writing that rule down was not enough, because the first implementation trimmed an address before checking it and silently accepted a trailing carriage return until a test caught it. Security modes are named rather than inferred from a boolean and a port, which is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and hope STARTTLS is offered". `starttls` sets requireTLS so a stripped upgrade fails the send instead of continuing in plaintext, production refuses `plaintext` outright, and certificate verification has no off switch. Credentials are optional as a pair, because a relay authorized by source IP has neither and requiring them would make the intended production arrangement unconfigurable; half a pair fails loudly. Connection, greeting, and socket timeouts are all explicit — the object-storage pass already showed what a network client on library defaults does to a request. Nothing retries: a failure after the message data has gone out is ambiguous, and retrying it turns one password-reset request into several emails, so this is a single bounded attempt and deliberately not an exactly-once promise. Email stays optional. An installation with no SMTP host is a complete deployment, nothing sends at startup, and constructing the transport opens no socket. There is no POST /api/v1/send-email and no route that touches the provider — an authenticated send endpoint is an open relay with extra steps. Modules get nothing either; services are added one at a time when something real needs one, and nothing does yet. Proven against a real in-process SMTP server rather than a mock: delivery with the host's sender on the wire, authentication and its sanitized refusal, a rejected mailbox distinguished from an outage, a refused connection, a silent server hitting its deadline, and a starttls transport refusing to continue when the upgrade is not offered. No test sends real email and no live credential exists in this repository or in CI. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ba32499456
commit
fa3021e49b
+12
-1
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
diagnoseEkklesia,
|
||||
type DoctorOptions,
|
||||
diagnoseEkklesia,
|
||||
doctorExitCode,
|
||||
formatDoctorReport,
|
||||
} from "../diagnostics/index.js";
|
||||
@@ -96,6 +96,17 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
||||
},
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
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: {
|
||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
|
||||
+120
-18
@@ -4,25 +4,26 @@ import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
isVersionCompatible,
|
||||
logicalBucketIdSchema,
|
||||
type ModuleSecretSource,
|
||||
moduleConfigurationKeySchema,
|
||||
moduleIdSchema,
|
||||
type ModuleSecretSource,
|
||||
permissionIdSchema,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||
import {
|
||||
assertProductionDatabaseTransport,
|
||||
type DatabaseTransportEnvironment,
|
||||
type DatabaseTransportPolicyInput,
|
||||
DatabaseTransportError,
|
||||
createDatabaseTransportPolicy,
|
||||
type DatabaseTransportEnvironment,
|
||||
DatabaseTransportError,
|
||||
type DatabaseTransportPolicyInput,
|
||||
describeDatabaseTransport,
|
||||
} from "../database/transport.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
ClientAddressConfigError,
|
||||
type ClientAddressConfigInput,
|
||||
createClientAddressConfig,
|
||||
} from "../identity/client-address.js";
|
||||
import {
|
||||
@@ -32,7 +33,14 @@ import {
|
||||
} from "../identity/config.js";
|
||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../identity/schema.js";
|
||||
import type { PostgresPoolOptions } from "../database/pool.js";
|
||||
import {
|
||||
createMailProviderConfig,
|
||||
createSmtpMailProvider,
|
||||
describeMailProvider,
|
||||
MailConfigError,
|
||||
type MailProviderConfig,
|
||||
type MailProviderConfigInput,
|
||||
} from "../mail/index.js";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
@@ -43,16 +51,8 @@ import {
|
||||
type PostgresMigrationDatabaseOptions,
|
||||
planModuleMigrations,
|
||||
} from "../migrations/index.js";
|
||||
import {
|
||||
createS3StorageProvider,
|
||||
describeStorageProvider,
|
||||
physicalBucketName,
|
||||
type StorageProviderConfig,
|
||||
} from "../storage/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
isConfigurationBlockedDetail,
|
||||
isStorageBlockedDetail,
|
||||
createModuleConfigurationDatabase,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleDatabase,
|
||||
@@ -60,15 +60,23 @@ import {
|
||||
type DiscoveredModule,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
type ModuleEligibility,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleDiscoveryOptions,
|
||||
isConfigurationBlockedDetail,
|
||||
isStorageBlockedDetail,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleDiscoveryOptions,
|
||||
type ModuleEligibility,
|
||||
type ModuleLifecycleConfiguration,
|
||||
type ModuleMigrationAggregate,
|
||||
ModulePermissionCompositionError,
|
||||
type ModuleSchemaAggregate,
|
||||
resolveModuleEligibility,
|
||||
} from "../modules/index.js";
|
||||
import {
|
||||
createS3StorageProvider,
|
||||
describeStorageProvider,
|
||||
physicalBucketName,
|
||||
type StorageProviderConfig,
|
||||
} from "../storage/index.js";
|
||||
|
||||
export type DoctorCheckId =
|
||||
| "database.transport"
|
||||
@@ -85,7 +93,8 @@ export type DoctorCheckId =
|
||||
| "identity.configuration"
|
||||
| "identity.client-address"
|
||||
| "object-storage.integration"
|
||||
| "object-storage.bindings";
|
||||
| "object-storage.bindings"
|
||||
| "outbound-email.configuration";
|
||||
|
||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
|
||||
@@ -126,6 +135,15 @@ export interface DoctorOptions {
|
||||
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
|
||||
* never receives raw environment values and never prints a credential. */
|
||||
readonly storage?: StorageProviderConfig | undefined;
|
||||
/**
|
||||
* The host's outbound mail settings as the executable read them, validated here.
|
||||
*
|
||||
* Deliberately **raw input** rather than a validated config, exactly like `identityConfiguration`:
|
||||
* a malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has
|
||||
* to be the one that validates it and reports the sanitized code. A pre-validated option would
|
||||
* mean a bad value crashed the command instead of appearing in its report.
|
||||
*/
|
||||
readonly mailConfiguration?: MailProviderConfigInput | undefined;
|
||||
readonly runtimeRange: string;
|
||||
readonly runtimeVersion: string;
|
||||
}
|
||||
@@ -166,6 +184,9 @@ export interface DoctorDependencies {
|
||||
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
|
||||
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
|
||||
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
|
||||
/** Opens an SMTP connection, authenticates when credentials are configured, and closes —
|
||||
* **sending nothing**. Behind the dependency boundary like every other I/O Doctor performs. */
|
||||
readonly probeMail: (config: MailProviderConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
const doctorPostgresTimeoutMs = 10_000;
|
||||
@@ -220,6 +241,22 @@ async function probeStorageForRuntime(config: StorageProviderConfig): Promise<vo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a transport, verifies, and always closes it — Doctor never leaves a connection behind.
|
||||
*
|
||||
* `verify()` performs the SMTP handshake and, where credentials are configured, an authentication
|
||||
* exchange, then disconnects. It **never sends a message**: a diagnostic that emailed somebody every
|
||||
* time an operator ran it would be its own incident.
|
||||
*/
|
||||
async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
|
||||
const provider = createSmtpMailProvider(config);
|
||||
try {
|
||||
await provider.verify();
|
||||
} finally {
|
||||
await provider.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
composePermissions: composePlatformPermissionRegistry,
|
||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||
@@ -227,6 +264,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
|
||||
discoverModules,
|
||||
discoverSchemas: discoverModuleSchemas,
|
||||
planMigrations: planModuleMigrations,
|
||||
probeMail: probeMailForRuntime,
|
||||
probeStorage: probeStorageForRuntime,
|
||||
readModuleConfiguration: readModuleConfigurationForRuntime,
|
||||
readModuleLifecycle: readModuleLifecycleForRuntime,
|
||||
@@ -1081,6 +1119,69 @@ export async function diagnoseEkklesia(
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
|
||||
// Outbound email, reported the same way storage is: configured-and-reachable, configured-but-not
|
||||
// answering, or simply absent. Absent is deliberately `skipped` rather than a warning — nothing in
|
||||
// the platform sends email yet, so an installation without SMTP is complete rather than degraded
|
||||
// (ADR 0037).
|
||||
let mailCheck: DoctorCheck;
|
||||
let mailConfig: MailProviderConfig | undefined;
|
||||
let mailConfigurationError: string | undefined;
|
||||
try {
|
||||
mailConfig = createMailProviderConfig(options.mailConfiguration, {
|
||||
environment: options.databaseEnvironment === "development" ? "development" : "production",
|
||||
});
|
||||
} catch (error) {
|
||||
// The sanitized code only — never the host, the username, or the password.
|
||||
mailConfigurationError =
|
||||
error instanceof MailConfigError ? error.code : "MAIL_CONFIGURATION_INVALID";
|
||||
}
|
||||
|
||||
if (mailConfigurationError !== undefined) {
|
||||
mailCheck = freezeCheck(
|
||||
"outbound-email.configuration",
|
||||
"failure",
|
||||
"Outbound email configuration invalid",
|
||||
[
|
||||
`The SMTP settings were rejected: ${mailConfigurationError}.`,
|
||||
"Nothing was connected to and no credential is shown. Correct the EKKLESIA_SMTP_* values and run Doctor again.",
|
||||
],
|
||||
);
|
||||
} else if (!mailConfig) {
|
||||
mailCheck = freezeCheck(
|
||||
"outbound-email.configuration",
|
||||
"skipped",
|
||||
"Outbound email not configured",
|
||||
[
|
||||
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.",
|
||||
],
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await dependencies.probeMail(mailConfig);
|
||||
mailCheck = freezeCheck("outbound-email.configuration", "pass", "Outbound email reachable", [
|
||||
describeMailProvider(mailConfig),
|
||||
// Said plainly: this connected and, where credentials are set, authenticated. It did not
|
||||
// send anything, so it proves the relay accepts this deployment — not that a message would
|
||||
// reach an inbox, which depends on SPF, DKIM, DMARC, and the relay's own authorization
|
||||
// policy and cannot be established from here.
|
||||
"Connected without sending a message. Deliverability still depends on the relay's authorization policy and this domain's SPF/DKIM/DMARC records.",
|
||||
]);
|
||||
} catch {
|
||||
// A warning, never a failure, exactly as for storage: the configuration is valid and nothing
|
||||
// in the platform depends on mail yet, so a relay that is briefly unreachable — or one that
|
||||
// declines a bare connectivity check — must not make the deployment read as broken.
|
||||
mailCheck = freezeCheck(
|
||||
"outbound-email.configuration",
|
||||
"warning",
|
||||
"Outbound email unreachable",
|
||||
[
|
||||
describeMailProvider(mailConfig),
|
||||
"The configured SMTP service did not accept a connection. Some relays also refuse a connection that sends no message, so verify with a real send before treating this as an outage.",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return publishReport([
|
||||
runtimeCheck,
|
||||
moduleCheck,
|
||||
@@ -1097,5 +1198,6 @@ export async function diagnoseEkklesia(
|
||||
clientAddressDoctorCheck,
|
||||
storageCheck,
|
||||
storageBindings,
|
||||
mailCheck,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user