Files
Ekklesia/docs/architecture/decisions/0037-host-provided-outbound-email.md
T
ekklesiaandClaude Opus 5 fa3021e49b
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s
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]>
2026-08-24 18:37:16 -05:00

12 KiB

0037: Outbound email is a host-provided transport, and SMTP is its first provider

Context

Ekklesia can authenticate people and pair devices, but it cannot send a single email. Better Auth has been configured since ADR 0011 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:

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 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:

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.