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]>
6.5 KiB
Outbound email
Ekklesia can send transactional email through a host-configured SMTP transport (ADR 0037).
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.
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): 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.
sendresolving 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_UNAVAILABLErather 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.