# 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.