feat: add a host-provided outbound email transport
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m23s

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:
ekklesia
2026-08-24 18:37:16 -05:00
co-authored by Claude Opus 5
parent ba32499456
commit fa3021e49b
26 changed files with 2435 additions and 20 deletions
@@ -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.
+1
View File
@@ -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
- [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
- [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
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 same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only