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]>
52 lines
2.6 KiB
TypeScript
52 lines
2.6 KiB
TypeScript
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;
|
|
}
|