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
@@ -691,6 +691,41 @@ targeting, device groups, and mobile dashboards are deliberately deferred** —
|
||||
encodes the same short code, and the pairing API is not desktop-specific. Ekklesia Lite has no device
|
||||
identity at all.
|
||||
|
||||
Phase 7C pass 1 adds the platform's first **outbound email transport**
|
||||
([ADR 0037](docs/architecture/decisions/0037-host-provided-outbound-email.md)). Ekklesia could
|
||||
authenticate people and pair devices but could not send a single message, which is what blocked
|
||||
email verification, password recovery, and invitations alike. Email is now a host-provided
|
||||
infrastructure service with a provider seam, exactly as object storage is: feature code says *send
|
||||
this transactional message* and the provider owns the host, port, TLS, credentials, 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, not a platform dependency.
|
||||
|
||||
The contract is deliberately tiny: one recipient or a small bounded list, a subject, text and/or
|
||||
HTML, and an optional reply-to. There are no attachments, headers, CC, BCC, or templates, and
|
||||
crucially **no `from` field at all** — the sender is host configuration, so no feature can choose who
|
||||
mail appears to come from. Control characters in addresses, subjects, and display names are refused
|
||||
rather than stripped, which is what closes header injection.
|
||||
|
||||
Security modes are named (`implicit-tls`, `starttls`, `plaintext`) rather than inferred from a
|
||||
boolean and a port, because that pairing is genuinely ambiguous and silently permits a plaintext
|
||||
session carrying a password. `starttls` **requires** the upgrade, production refuses `plaintext`, and
|
||||
certificate verification has no off switch. SMTP credentials are optional **as a pair**, since a
|
||||
relay authorized by source IP has neither — and the password is a deployment secret that never
|
||||
reaches PostgreSQL, module configuration, a log, a diagnostic, or an error.
|
||||
|
||||
Connection, greeting, and socket timeouts are all explicit, so a silent server returns a structured
|
||||
error rather than hanging. **Nothing retries**: a failure after the message data has gone out is
|
||||
ambiguous, and retrying it would send someone several copies of the same reset email — so this is a
|
||||
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
||||
`POST /api/v1/send-email`** and no route that touches the provider.
|
||||
|
||||
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since
|
||||
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
|
||||
sending anything**, while stating plainly that a successful connection does not establish
|
||||
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
|
||||
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
|
||||
the seam they will use. See [Outbound email](docs/development/email.md).
|
||||
|
||||
Ekklesia publishes the display bundle at
|
||||
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||||
`/display/<module-id>`, and exposes that page's permission-gated descriptor at
|
||||
|
||||
Reference in New Issue
Block a user