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