`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]>
129 lines
6.7 KiB
Markdown
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).
|