fix: let a deployment name itself in the SMTP greeting
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m41s

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]>
This commit is contained in:
ekklesia
2026-08-25 20:28:30 -05:00
co-authored by Claude Opus 5
parent 450d65116f
commit 3563549e86
14 changed files with 504 additions and 8 deletions
+51
View File
@@ -22,6 +22,7 @@ a new account or recover a password, because both need a message to arrive. See
| `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
@@ -36,6 +37,51 @@ plaintext session waiting to happen. Naming the mode removes the ambiguity:
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
@@ -58,6 +104,11 @@ controls which envelope senders are accepted.
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.
+4 -1
View File
@@ -99,7 +99,10 @@ Doctor reports a fixed, deterministic set of checks:
logical bucket are reported separately, because binding is not health.
- Outbound email: skipped when no `EKKLESIA_SMTP_HOST` is set, a blocking failure when the
`EKKLESIA_SMTP_*` settings are structurally invalid, and otherwise a connection that authenticates
where credentials are set and **sends nothing**.
where credentials are set and **sends nothing**. That probe performs the same `EHLO` greeting the
runtime performs, so a relay that refuses the greeting is reported here rather than discovered when
a message silently fails to arrive — see
[Outbound email](email.md#when-a-relay-refuses-the-connection).
No check prints a credential: not the storage access or secret key, not the SMTP password, not the
identity secret, not a module secret value.
+4
View File
@@ -614,6 +614,10 @@ becoming a liability:
disables it.
- **The password is a deployment secret.** Never in PostgreSQL, never in module configuration, never
in an API response, never in Doctor output, never in an error, and no value in `.env.example`.
- **The SMTP client identity is not a secret.** `EKKLESIA_SMTP_HELO_NAME` is announced in the clear
in `EHLO` on every connection, so it is shown in Doctor output where an operator can compare it
with what the relay expects. It is validated as a fully qualified domain name — no scheme, port,
brackets, whitespace, or control characters — so nothing arbitrary reaches the wire.
- **Nothing is logged.** The transport logs nothing at all. Transactional mail carries verification
and reset links, which are exactly the credentials those flows depend on.
- **No blind retry.** One bounded attempt. A failure after the message data has been sent is