# 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= EKKLESIA_SMTP_PORT=587 EKKLESIA_SMTP_SECURITY=starttls EKKLESIA_SMTP_FROM_ADDRESS=noreply@example.org 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).