feat: add a host-provided outbound email transport
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:
co-authored by
Claude Opus 5
parent
ba32499456
commit
fa3021e49b
@@ -0,0 +1,207 @@
|
||||
# 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.
|
||||
@@ -45,3 +45,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||
|
||||
@@ -495,6 +495,19 @@ re-pairing mints a new identity rather than reviving a revoked one. Ekklesia Lit
|
||||
runtime primitives and deliberately no human authentication, permission enforcement, or managed
|
||||
device identity, so pairing is a full-Ekklesia concern only.
|
||||
|
||||
Phase 7C pass 1 adds outbound email as a host-provided infrastructure service
|
||||
([ADR 0037](decisions/0037-host-provided-outbound-email.md)), with the same shape object storage
|
||||
uses: a narrow provider-neutral contract, a `MailProvider` seam, and one concrete SMTP
|
||||
implementation. Feature code owns the recipient, subject, and body; the provider owns the host,
|
||||
port, TLS, credentials, and transport errors, and no provider hostname appears in Core. The sender
|
||||
is host configuration and a message has no `from` field, so nothing above the seam can choose an
|
||||
identity to send as. Security modes are explicit rather than a boolean plus a port, `starttls`
|
||||
requires the upgrade, production refuses plaintext, and every timeout is bounded. There is no HTTP
|
||||
route that sends and no module-facing mail service yet — services are added one at a time when
|
||||
something real needs one, and nothing does. Email is optional: an installation without SMTP is a
|
||||
complete deployment, and Better Auth's verification and reset hooks remain unimplemented until
|
||||
Pass 2.
|
||||
|
||||
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
|
||||
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
|
||||
`presentation.kind`, or host-identity branch. The canonical builder reads only
|
||||
|
||||
@@ -863,6 +863,20 @@ instead, which now carries `viewer: {kind: "authenticated", userId} | {kind: "de
|
||||
`SameSite=Strict`, a display must be served from the same origin as the API — which the platform's
|
||||
own `/display/<module-id>` route is.
|
||||
|
||||
## Outbound email
|
||||
|
||||
There is **no HTTP API for it**, deliberately
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). Email is a runtime
|
||||
capability trusted server-side features call, not a resource the platform exposes: an authenticated
|
||||
send endpoint is an open relay with extra steps, so no route exists and no route file imports the
|
||||
mail module. A test asserts both.
|
||||
|
||||
Host configuration is `EKKLESIA_SMTP_HOST`, `EKKLESIA_SMTP_PORT`, `EKKLESIA_SMTP_SECURITY`,
|
||||
`EKKLESIA_SMTP_USERNAME`/`EKKLESIA_SMTP_PASSWORD` (optional as a pair), `EKKLESIA_SMTP_FROM_ADDRESS`,
|
||||
and `EKKLESIA_SMTP_FROM_NAME`. Absent `EKKLESIA_SMTP_HOST` means this installation sends no email,
|
||||
which is a complete deployment. None of these values ever reaches a module, a browser bundle, a
|
||||
diagnostic, or an API response. See [Outbound email](email.md).
|
||||
|
||||
## Module HTTP routes
|
||||
|
||||
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
|
||||
|
||||
@@ -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).
|
||||
@@ -279,6 +279,32 @@ wins, so a browser holding both remains a person.
|
||||
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
|
||||
to decide whether a person is signed in, and a device is not one.
|
||||
|
||||
## Outbound email and the verification seam
|
||||
|
||||
Ekklesia has a host-provided SMTP transport
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md), see
|
||||
[Outbound email](email.md)) — but **no identity flow uses it yet.**
|
||||
|
||||
Better Auth exposes exactly two hooks for the flows that need mail, and this repository configures
|
||||
neither:
|
||||
|
||||
| hook | flow |
|
||||
| --- | --- |
|
||||
| `emailAndPassword.sendResetPassword({ user, url, token })` | Password reset |
|
||||
| `emailVerification.sendVerificationEmail({ user, url, token })` | Email verification |
|
||||
|
||||
Phase 7C Pass 2 implements them over the mail transport. `startApi` already constructs the provider
|
||||
and closes it on every shutdown path, so the remaining work is to inject it into
|
||||
`createIdentityRuntime` and write the two message bodies.
|
||||
|
||||
**The `url` and `token` those hooks receive are credentials.** The entire security of both flows is
|
||||
that only the recipient sees them, so they must never be logged, never appear in an error, and never
|
||||
reach a diagnostic. The transport already logs nothing and refuses to echo a message body.
|
||||
|
||||
Until that pass lands: email verification, password recovery, and invitations do not exist, and
|
||||
`requireEmailVerification` stays off — turning it on without a working sender would lock every
|
||||
account out.
|
||||
|
||||
## Creating the first platform owner
|
||||
|
||||
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
|
||||
|
||||
@@ -582,6 +582,44 @@ connection immediately, but an already-open SSE stream is not torn down at that
|
||||
five-minute forced reconnect is the upper bound on how long a just-revoked screen can keep receiving
|
||||
a channel. That window is stated rather than described as instant.
|
||||
|
||||
### Outbound email is host-owned, and cannot be turned into a relay
|
||||
|
||||
Ekklesia sends transactional email through a host-configured SMTP transport
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). What keeps it from
|
||||
becoming a liability:
|
||||
|
||||
- **No feature can choose a sender.** A message has no `from` field at all; the sender is host
|
||||
configuration. Feature-chosen senders are an impersonation primitive and a deliverability
|
||||
catastrophe at the same time.
|
||||
- **No HTTP route can send.** There is deliberately no `POST /api/v1/send-email` and no route that
|
||||
touches the mail provider — an authenticated relay endpoint is an open relay with extra steps. A
|
||||
test asserts no route file even imports the mail module.
|
||||
- **No module can send.** `ModuleRuntimeServices` carries no mail service, and a test asserts the
|
||||
module SDKs have no way to. Browser code has none either: the SMTP dependency exists only in Core,
|
||||
which a surface cannot import.
|
||||
- **Header injection is refused, not repaired.** Control characters in addresses, display names,
|
||||
subjects, and reply-to values are rejected. The first implementation trimmed before checking and
|
||||
silently accepted a trailing carriage return; a test caught it and the check now runs on the raw
|
||||
value.
|
||||
- **Bounded everything.** Recipients (10), subject (200), text (64 KiB), HTML (256 KiB), and
|
||||
connection, greeting, and socket timeouts are all explicit. A silent server returns a structured
|
||||
error rather than hanging a request.
|
||||
- **No downgrade.** Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than
|
||||
inferred from a boolean and a port. `starttls` **requires** the upgrade, so a server that stops
|
||||
offering it — or an attacker who strips it — fails the send instead of continuing in the clear.
|
||||
Production refuses `plaintext`. Certificate verification is always on and there is no setting that
|
||||
disables it.
|
||||
- **The password is a deployment secret.** Never in PostgreSQL, never in module configuration, never
|
||||
in an API response, never in Doctor output, never in an error, and no value in `.env.example`.
|
||||
- **Nothing is logged.** The transport logs nothing at all. Transactional mail carries verification
|
||||
and reset links, which are exactly the credentials those flows depend on.
|
||||
- **No blind retry.** One bounded attempt. A failure after the message data has been sent is
|
||||
ambiguous, and retrying it would send someone several copies of the same reset email.
|
||||
|
||||
Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy — is a deployment
|
||||
responsibility this repository cannot verify, and Doctor says so rather than implying a successful
|
||||
connection means mail arrives.
|
||||
|
||||
### Rate limiting is per-process today
|
||||
|
||||
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
||||
|
||||
@@ -375,6 +375,31 @@ an `authenticated` baseline, from platform ownership, or from a user grant. Its
|
||||
answer "yes" to every human question, so any accidental fall-through into a human path would flip
|
||||
those assertions.
|
||||
|
||||
Outbound email is proven against a **real SMTP server** rather than a mock
|
||||
(`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||
`smtp-server` — the reference implementation from the same project as the client — runs in-process,
|
||||
so this needs no container and adds no CI infrastructure. **No test sends real email, and no live
|
||||
credential exists in this repository or in CI.**
|
||||
|
||||
It pins the things only a server can settle: a delivered message whose envelope sender and `From:`
|
||||
header are the *host's* configured address rather than anything a caller chose; both bodies and a
|
||||
reply-to arriving intact; authentication succeeding and its refusal mapping to a sanitized
|
||||
`MAIL_REJECTED` that echoes neither the password nor the server's response text; a mailbox refused at
|
||||
`RCPT TO` mapping to `MAIL_RECIPIENT_INVALID` rather than a generic failure; a refused connection
|
||||
mapping to `MAIL_PROVIDER_UNAVAILABLE`; a server that accepts a connection and never greets hitting
|
||||
its deadline instead of hanging; a `starttls` transport refusing to continue when the upgrade is not
|
||||
offered; `verify()` connecting without sending anything; and one `send` producing exactly one
|
||||
message.
|
||||
|
||||
`core/tests/mail-contract.test.ts` covers the boundary with no network at all, and is mostly
|
||||
negative — the contract's job is to make an impossible message impossible to construct. It also pins
|
||||
that a validated message has no `from` key, which is the structural reason no feature can spoof a
|
||||
sender. `core/tests/mail-boundaries.test.ts` asserts the properties no runtime test can: the SMTP
|
||||
dependency exists in Core alone, no browser-facing package imports it, module SDKs have no way to
|
||||
send, no HTTP route references the mail module, Core's mail code names no provider hostname and
|
||||
never mentions disabling certificate verification, and `.env.example` documents the variables with
|
||||
no password value.
|
||||
|
||||
### Which kind of timing proof lives where
|
||||
|
||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||
|
||||
Reference in New Issue
Block a user