Files
Ekklesia/docs/development/email.md
T
ekklesiaandClaude Opus 5 ea28f8aa2e
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
feat: verify email addresses and recover forgotten passwords
Ekklesia could authenticate a person and, since Pass 1, send a
transactional email — but it had no way to establish that an address
belonged to whoever claimed it, and no way back in for someone who
forgot their password.

A verified address is now a precondition for holding a session. Better
Auth enforces that after the password check and before any session
exists, so an unverified sign-in returns 403 with no cookie. That is why
no "unverified principal" was needed: a correct password on an
unverified address carries exactly the authority of no password, and
/api/v1/principal is unchanged.

Better Auth keeps every token. Ekklesia adds no token table, no hashing,
and no expiry clock. It supplies a recipient, a subject, and two bodies,
and the mail provider supplies everything about SMTP.

Verification and reset links are rebuilt from a validated Dashboard
origin, taking only the token — never the URL Better Auth prebuilds from
a caller-supplied callbackURL — so no request can steer where a
recipient lands. Following a verification link mints no session, because
mail infrastructure dereferences links. Reset revokes that person's
other sessions and leaves managed devices alone.

Recovery answers identically for an address with an account, without
one, already verified, or undeliverable — including when SMTP is broken,
which is why the delivery adapter never throws.

The initial platform owner is the one exception: created already
verified by an operator command with no mail prerequisite, so a fresh
server can be signed in to before a relay exists. Migration 0007 extends
that to owners of installations that already exist. Public signup stays
disabled; verification is not a licence to self-register.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-25 07:21:17 -05:00

162 lines
8.5 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)).
Two things send email: **email verification** and **password recovery**
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations
do not exist.
An installation with no SMTP configured is still a complete, valid deployment — the platform starts,
every existing feature works, and the initial platform owner can sign in. What it cannot do is verify
a new account or recover a password, because both need a message to arrive. See
[When no relay is configured](#when-no-relay-is-configured).
## 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.**
## What the platform actually sends
| message | when | contents |
| --- | --- | --- |
| Verify your Ekklesia email address | a new account signs in unverified, or asks for a resend | one link, an expiry sentence, and ignore-if-unexpected wording |
| Reset your Ekklesia password | someone asks to recover an account | one link, an expiry sentence, and a note that the current password still works |
Both are plain text and HTML, both are generic Ekklesia templates with no branding and no marketing
content, and both are built in `core/src/identity/notifications.ts` — which knows a recipient, a
subject, and two bodies, and nothing about SMTP.
Links are rebuilt from validated configuration rather than taken from the request that triggered
them, so no caller can steer where a recipient lands. They point at
`EKKLESIA_DASHBOARD_ORIGIN` (defaulting to the identity base URL). See
[Identity](identity.md#email-verification-and-password-recovery).
## When no relay is configured
Nothing crashes and nothing changes shape. A verification or recovery request answers exactly as it
would with a working relay — the response must not reveal whether an account exists, and it must not
start revealing it the moment SMTP breaks — and the operator is told out of band:
```text
[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED
```
That line carries a flow and a sanitized code. It never carries the address, the token, or the link.
`pnpm doctor` reports the same absence under `outbound-email.configuration`.
The operator requirement is therefore:
- **normal production use of verification and recovery** → requires configured outbound email
- **initial platform-owner bootstrap** (`pnpm identity:create-initial-owner`) → does not