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