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
+14
View File
@@ -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
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
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
+124
View File
@@ -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=[email protected]
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).
+26
View File
@@ -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
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
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
+38
View File
@@ -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
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
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
+25
View File
@@ -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
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
Pass 4 introduced several deadlines, and their tests deliberately differ in kind: