A real deployment could not send mail through an IP-authorized relay. Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was healthy and swaks sent from the same machine. The cause was the greeting, and it is generic rather than tied to any provider. An SMTP client identifies itself in EHLO, and with nothing configured Nodemailer derives that from the machine hostname — substituting the address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal state of a container. The relay closed the connection on it with a 421 before any message was offered, which is indistinguishable from the relay being down. EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It is validated as a fully qualified domain name — no scheme, port, brackets, whitespace, or control characters, and at least one dot, since a single unqualified label is exactly the input Nodemailer already discards. It is not a credential: it travels in the clear on every connection, so Doctor prints it where an operator can compare it against what the relay expects, and Doctor's probe performs the same greeting the runtime does. Absent preserves Nodemailer's behaviour exactly. It is deliberately not defaulted to the sender's domain: a machine whose hostname is already a valid FQDN may be greeting correctly, and some relays check that against reverse DNS, so replacing it silently would trade one deployment's bug for another's. The regression guard is a raw SMTP server that refuses any greeting but the expected one, because smtp-server exposes no EHLO hook and its earliest hook is never reached by a send-nothing probe. Co-Authored-By: Claude Opus 5 <[email protected]>
213 lines
11 KiB
Markdown
213 lines
11 KiB
Markdown
# 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=<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.
|
|
|
|
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
|