# Outbound email Ekklesia can send transactional email through a host-configured SMTP transport ([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). Two things send email: **email verification** and **password recovery** ([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations do not exist. An installation with no SMTP configured is still a complete, valid deployment — the platform starts, every existing feature works, and the initial platform owner can sign in. What it cannot do is verify a new account or recover a password, because both need a message to arrive. See [When no relay is configured](#when-no-relay-is-configured). ## 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. | | `EKKLESIA_SMTP_HELO_NAME` | Optional. The name this client announces in `EHLO`. See [When a relay refuses the connection](#when-a-relay-refuses-the-connection). | ### 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. ### When a relay refuses the connection If a relay closes the connection before accepting anything — a `421`, or an `MAIL_PROVIDER_UNAVAILABLE` from a relay you can reach perfectly well with another client — suspect the **greeting** before suspecting the network. An SMTP client has to identify itself in the `EHLO` command, and Ekklesia does not invent that name. With nothing configured, Nodemailer derives it from the machine's own hostname, and substitutes the address literal `[127.0.0.1]` whenever that hostname is not fully qualified — which is the normal state of a container, or of a VM nobody has named. A relay that requires a resolvable client identity rejects that greeting outright, and because the rejection lands before any message is offered it is indistinguishable from the relay being down. This was a real deployment defect, and a generic one: the relay worked, `swaks` sent from the same machine, and only the greeting differed. ```text C: EHLO [127.0.0.1] S: 421-4.7.0 Try again later, closing connection. (EHLO) ``` Set the name explicitly: ```bash EKKLESIA_SMTP_HELO_NAME=example.org ``` ```text C: EHLO example.org S: 250 ... ``` Use a fully qualified domain name the deployment owns, typically its sending domain. The value is validated at startup: no scheme, no port, no brackets, no whitespace, no trailing dot, and at least one label plus a domain — a single unqualified name such as `localhost` is refused, because that is exactly the input Nodemailer already discards. It is **not** a credential. It travels in the clear on every connection, so it appears in `pnpm doctor` output where an operator can check it against what the relay expects. Leaving it unset preserves Nodemailer's own behaviour exactly. It is deliberately not defaulted to the sender's domain: a machine whose hostname *is* a valid FQDN may already be greeting correctly — some relays check the greeting against the connecting address's reverse DNS — and silently replacing a working identity would trade one deployment's bug for another's. ### 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. Such a relay is also likely to require a fully qualified `EHLO` name — this is the deployment that exposed the generic issue described in [When a relay refuses the connection](#when-a-relay-refuses-the-connection) — so `EKKLESIA_SMTP_HELO_NAME` is usually needed alongside the variables above. 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. Doctor reads the `EKKLESIA_SMTP_*` values through the same `loadHostConfiguration` the API executable uses, so the relay it reports on is the relay the runtime would use. The check is rendered in Doctor's own **Outbound email** section. ## 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.** ## What the platform actually sends | message | when | contents | | --- | --- | --- | | Verify your Ekklesia email address | a new account signs in unverified, or asks for a resend | one link, an expiry sentence, and ignore-if-unexpected wording | | Reset your Ekklesia password | someone asks to recover an account | one link, an expiry sentence, and a note that the current password still works | Both are plain text and HTML, both are generic Ekklesia templates with no branding and no marketing content, and both are built in `core/src/identity/notifications.ts` — which knows a recipient, a subject, and two bodies, and nothing about SMTP. Links are rebuilt from validated configuration rather than taken from the request that triggered them, so no caller can steer where a recipient lands. They point at `EKKLESIA_DASHBOARD_ORIGIN` (defaulting to the identity base URL). See [Identity](identity.md#email-verification-and-password-recovery). ## When no relay is configured Nothing crashes and nothing changes shape. A verification or recovery request answers exactly as it would with a working relay — the response must not reveal whether an account exists, and it must not start revealing it the moment SMTP breaks — and the operator is told out of band: ```text [identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED ``` That line carries a flow and a sanitized code. It never carries the address, the token, or the link. `pnpm doctor` reports the same absence under `outbound-email.configuration`. The operator requirement is therefore: - **normal production use of verification and recovery** → requires configured outbound email - **initial platform-owner bootstrap** (`pnpm identity:create-initial-owner`) → does not