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
+35
View File
@@ -691,6 +691,41 @@ targeting, device groups, and mobile dashboards are deliberately deferred** —
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
identity at all.
Phase 7C pass 1 adds the platform's first **outbound email transport**
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
authenticate people and pair devices but could not send a single message, which is what blocked
email verification, password recovery, and invitations alike. Email is now a host-provided
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
this transactional message* and the provider owns the host, port, TLS, credentials, 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, not a platform dependency.
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
mail appears to come from. Control characters in addresses, subjects, and display names are refused
rather than stripped, which is what closes header injection.
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
relay authorized by source IP has neither — and the password is a deployment secret that never
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
single bounded attempt and deliberately not an exactly-once promise. There is **no
`POST /api/v1/send-email`** and no route that touches the provider.
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
sending anything**, while stating plainly that a successful connection does not establish
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
the seam they will use. See [Outbound email](docs/development/email.md).
Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
`/display/<module-id>`, and exposes that page's permission-gated descriptor at