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]>
208 lines
12 KiB
Markdown
208 lines
12 KiB
Markdown
# 0037: Outbound email is a host-provided transport, and SMTP is its first provider
|
|
|
|
- Status: Accepted
|
|
- Date: 2026-08-24
|
|
- Extends: [0011](0011-identity-http-transport-and-public-enrollment.md),
|
|
[0029](0029-module-configuration-and-secrets.md),
|
|
[0030](0030-host-provided-module-object-storage.md),
|
|
[0034](0034-host-owned-browser-authentication.md),
|
|
[0035](0035-initial-platform-owner-bootstrap.md)
|
|
|
|
## Context
|
|
|
|
Ekklesia can authenticate people and pair devices, but it cannot send a single email. Better Auth
|
|
has been configured since [ADR 0011](0011-identity-http-transport-and-public-enrollment.md) with
|
|
`emailAndPassword` and nothing else: its `sendResetPassword` and `emailVerification.sendVerificationEmail`
|
|
hooks are both unimplemented, so email verification, password recovery, and invitations are all
|
|
blocked on the same missing thing.
|
|
|
|
That missing thing is not "a Google Workspace integration". It is a transport — and the shape of the
|
|
transport decides whether the features built on it stay portable. The first real deployment is
|
|
expected to relay through Google Workspace, but a church that later moves to Microsoft 365, a
|
|
transactional provider, or its own Postfix must not be rewriting feature code to do it.
|
|
|
|
## Decision
|
|
|
|
Outbound email is a **host-provided infrastructure service** with a provider seam, exactly as object
|
|
storage is under [ADR 0030](0030-host-provided-module-object-storage.md):
|
|
|
|
```text
|
|
trusted server-side feature
|
|
↓ "send this transactional message"
|
|
MailProvider seam
|
|
↓
|
|
SMTP → whatever the deployment relays through
|
|
```
|
|
|
|
Feature code owns the recipient, subject, and body. 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.
|
|
|
|
### The contract is deliberately tiny
|
|
|
|
One recipient or a small bounded list, a subject, a text body, an HTML body, and an optional
|
|
reply-to. There are no attachments, no custom headers, no CC, no BCC, no templates, and no mailing
|
|
lists. This is transactional infrastructure: every field beyond that is surface a future caller
|
|
could abuse, and it stays out until something real needs it.
|
|
|
|
At least one of `text` or `html` is required. A message with neither is not a message, and sending
|
|
an empty one silently would be worse than refusing it.
|
|
|
|
### There is no `from` field, anywhere
|
|
|
|
The sender is host configuration. A message has nowhere to put a sender, so no feature — and no
|
|
future module — can choose who mail appears to come from. Letting feature code pick a sender is how
|
|
a platform acquires an impersonation primitive and destroys its own domain reputation at the same
|
|
time. If several approved sender identities are ever genuinely needed, that is a deliberate design
|
|
with its own authorization, not a field.
|
|
|
|
### Header injection is refused, never repaired
|
|
|
|
Carriage returns, line feeds, and every other control character are rejected in addresses, display
|
|
names, subjects, and reply-to values. A mail header is line-oriented, so a `\r\n` in a subject lets a
|
|
caller append headers of its own — a `Bcc:` elsewhere, a replaced `From:`, or a second body.
|
|
|
|
They are **refused rather than stripped** because silently rewriting input means the value that was
|
|
validated is not the value that gets sent. Writing that rule down was not enough: the first
|
|
implementation trimmed an address before checking it, which quietly accepted a trailing carriage
|
|
return, and a test caught it. The check now runs on the raw value.
|
|
|
|
### Explicit TLS modes, never a boolean
|
|
|
|
`implicit-tls` (conventionally 465), `starttls` (587/25), or `plaintext`.
|
|
|
|
A boolean plus a port is genuinely ambiguous: `secure: false` on 587 means "connect in the clear and
|
|
hope STARTTLS is offered", and a server that stops advertising it — or an attacker who strips it —
|
|
turns that into a plaintext session carrying a password, with nothing in the configuration having
|
|
changed. Naming the mode makes the downgrade unreachable by accident: `starttls` sets Nodemailer's
|
|
`requireTLS`, so a missing upgrade is a failed send. An omitted mode defaults to `starttls`, never to
|
|
the one that sends a password in the clear, and production refuses `plaintext` outright.
|
|
|
|
Certificate verification is left at the library's verifying default, and there is deliberately **no
|
|
configuration path that could switch it off** — a setting that disables verification is one someone
|
|
eventually disables in production.
|
|
|
|
### Credentials are optional as a pair
|
|
|
|
Most SMTP examples assume a username and password. Google Workspace SMTP Relay is commonly
|
|
authorized by **source IP** and has neither, so requiring them would make the intended production
|
|
arrangement unconfigurable. Half a pair is always a mistake, though — someone set one variable and
|
|
expected authentication — so it fails loudly rather than silently connecting anonymously.
|
|
|
|
The password is a deployment secret. It is never persisted in PostgreSQL, never routed through
|
|
module configuration, never returned by any route, never printed by Doctor, and never placed in an
|
|
error. It follows the discipline [ADR 0029](0029-module-configuration-and-secrets.md) established.
|
|
|
|
### Bounded on every axis, and no retry
|
|
|
|
`connectionTimeout`, `greetingTimeout`, and `socketTimeout` are all set explicitly. Nodemailer's own
|
|
defaults are two minutes and ten minutes, which is not a deadline anyone would choose for a
|
|
password-reset email someone is waiting on — and the object-storage pass already learned what an
|
|
unbounded network client does to a request under load.
|
|
|
|
**Nothing retries.** A failure after the message data has gone out is genuinely ambiguous: the
|
|
server may have accepted it and only the acknowledgement was lost. Retrying an ambiguous send is how
|
|
one password-reset request becomes three emails. A single bounded attempt is the whole behaviour,
|
|
and `send` resolving means the next hop took responsibility — not that anyone received anything.
|
|
This is deliberately **not** an exactly-once promise. A durable outbox with deliberate retry
|
|
semantics can be added later if product requirements justify it; guessing at one now would be
|
|
inventing delivery guarantees this transport does not have.
|
|
|
|
### Errors are structured and say nothing
|
|
|
|
`MAIL_NOT_CONFIGURED`, `MAIL_RECIPIENT_INVALID`, `MAIL_MESSAGE_INVALID`, `MAIL_PROVIDER_UNAVAILABLE`,
|
|
`MAIL_REJECTED`, `MAIL_SEND_FAILED`. A 4xx SMTP reply maps to unavailable and a 5xx to rejected, so a
|
|
caller can tell a transient outage from something that will never be accepted, and a refused mailbox
|
|
keeps its own code because it is the one failure a human can usually act on.
|
|
|
|
Nothing from the underlying error survives: not the server's response text, not the greeting, not
|
|
the auth challenge, not the envelope. SMTP servers routinely echo back what was sent to them, and an
|
|
authentication failure's reply can name the account.
|
|
|
|
### Email is optional, and the platform still runs without it
|
|
|
|
An installation with no SMTP host configured is a **complete deployment**. Nothing in the platform
|
|
sends email yet, so Doctor reports it as `skipped` rather than a warning, and a future feature that
|
|
needs to send discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing. Whether email
|
|
becomes *required* for particular identity flows is Pass 2's decision to make when those flows exist.
|
|
|
|
### Nothing sends at startup, and no route sends at all
|
|
|
|
Constructing the transport opens no socket. The first connection happens when something actually
|
|
sends, so an unreachable relay can never stop the platform from booting, and a temporary SMTP outage
|
|
is an operational condition rather than a module lifecycle event.
|
|
|
|
There is deliberately **no `POST /api/v1/send-email`**, and no HTTP route anywhere touches the mail
|
|
provider. An authenticated relay endpoint is an open relay with extra steps; a test asserts that no
|
|
route file so much as imports the mail module.
|
|
|
|
### Core only, for now
|
|
|
|
`ModuleRuntimeServices` carries `database` and nothing else, with a standing note that services are
|
|
added **one at a time, when something real needs one**. No module needs email in this pass —
|
|
Announcements migration is out of scope — so modules get none, and a test asserts the module SDKs
|
|
have no way to send.
|
|
|
|
This is not a permanent decision. When a module genuinely needs transactional mail, the module-facing
|
|
contract goes in `@ekklesia/contracts` and Core layers a scoped facade over this same
|
|
`MailProvider` seam — exactly the path object storage took. The seam is already the right shape for
|
|
it: a scoped facade would add the module's identity and leave the sender, the bounds, and the
|
|
validation exactly where they are.
|
|
|
|
## Consequences
|
|
|
|
### The seam Pass 2 wires into
|
|
|
|
Better Auth's two hooks take a `{ user, url, token }` and return a promise:
|
|
|
|
```ts
|
|
emailAndPassword: { sendResetPassword: async ({ user, url }) => { /* mail.send(...) */ } }
|
|
emailVerification: { sendVerificationEmail: async ({ user, url }) => { /* mail.send(...) */ } }
|
|
```
|
|
|
|
`createIdentityRuntime` will take the provider as an injected option and call `mail.send` from
|
|
inside them. `startApi` already constructs the provider and closes it on every shutdown path, so
|
|
Pass 2 adds the two functions and the message bodies and nothing else. **The `url` and `token` those
|
|
hooks receive are credentials** — the whole point of the email is that only the recipient sees them
|
|
— so they must never be logged, and the transport already refuses to log a message body.
|
|
|
|
### What is safe to log
|
|
|
|
Send attempted, the provider's message id, and an error category. Never a body, never a subject,
|
|
never a recipient beyond what an operator genuinely needs, and never a verification or reset link.
|
|
The transport itself logs nothing at all.
|
|
|
|
### Doctor reports configuration, and probes without sending
|
|
|
|
`outbound-email.configuration` reports absent, valid-and-reachable, valid-but-unreachable, or
|
|
structurally invalid. The probe performs the SMTP handshake and, where credentials are set, an
|
|
authentication exchange — and **sends no message**: a diagnostic that emailed somebody every time an
|
|
operator ran it would be its own incident.
|
|
|
|
An unreachable relay is a warning rather than a failure, because nothing depends on mail yet and
|
|
some relays decline a connection that sends nothing. The report says so explicitly instead of
|
|
claiming a health it cannot prove, and it states that a successful connection does not establish
|
|
deliverability — that depends on the relay's authorization policy and the domain's SPF, DKIM, and
|
|
DMARC records, none of which is knowable from here.
|
|
|
|
### Google Workspace is a target, not a dependency
|
|
|
|
It is the expected first deployment and appears nowhere in Core. Whether a relay needs credentials,
|
|
and which source addresses it accepts, is configured in Google Workspace Admin and varies per
|
|
deployment — so this repository documents the shape and refuses to claim that a relay works
|
|
anonymously from an arbitrary host.
|
|
|
|
### Ekklesia Lite
|
|
|
|
Lite is a minimal trusted-network module runtime. It may one day supply this same contract if an
|
|
operator configures SMTP, and it must never *require* mail to run modules that do not need it —
|
|
which is already true, because a provider is optional and its absence is a complete deployment.
|
|
Nothing here is implemented in Lite and no module code branches on which host is running it.
|
|
|
|
### Deferred
|
|
|
|
Email verification, password reset, and invitations (Pass 2). Templates, which belong to the
|
|
features that render them rather than to a transport. Bulk or marketing mail, mailing lists,
|
|
attachments, DKIM key management, domain-verification automation, provider-specific HTTP API
|
|
adapters, and a durable outbox with retry workers.
|