Files
Ekklesia/docs/development/email.md
T
ekklesiaandClaude Opus 5 2b4f0afb72
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 14s
CI / migration-smoke (pull_request) Successful in 1m24s
fix: give Doctor the host configuration the runtime receives
`pnpm doctor` and `startApi` each read their own subset of the same
environment variables, and the subsets had drifted. Doctor never read
`S3_*` or module secrets at all, so a correctly configured installation
was told its object storage was "not configured" and every module
requiring a bucket or a secret was reported blocked -- while the runtime,
from the identical environment, bound those buckets and ran the modules.

Both executables now map the environment through one
`loadHostConfiguration(env)` in Core, which takes the environment as an
argument and never reads `process.env` itself. Validation stays with the
existing per-domain validators, so each caller keeps its own failure
behaviour: startup refuses a rejected value, Doctor reports it as a
sanitized code.

Doctor's storage option becomes raw input like its mail option, so a
half-configured provider is reported as a blocking configuration failure
instead of throwing before a report exists. The outbound-email check --
computed, counted toward the result, but absent from every rendered
section -- is now printed, and the rendered set is asserted against a
real report so a future check cannot go missing the same way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 19:15:49 -05:00

129 lines
6.7 KiB
Markdown

# 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.
Doctor reads the `EKKLESIA_SMTP_*` values through the same `loadHostConfiguration` the API
executable uses, so the relay it reports on is the relay the runtime would use. The check is
rendered in Doctor's own **Outbound email** section.
## 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).