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
+110
View File
@@ -193,6 +193,116 @@ describe("host mail configuration", () => {
});
});
describe("SMTP client identity (HELO name)", () => {
/**
* A real deployment defect, and a generic one.
*
* An SMTP client must greet the server with its own identity. Nodemailer derives that from the
* machine hostname and falls back to the address literal `[127.0.0.1]` when the hostname is not
* fully qualified — the normal state of a container. Some relays close the connection on that
* greeting with a `421` before any message is offered, which reaches a caller as an
* indistinguishable `MAIL_PROVIDER_UNAVAILABLE`. The deployment can now say what to greet as.
*/
it("omits the setting entirely when the deployment configures none", () => {
const config = createMailProviderConfig(validConfig);
// Absent means Nodemailer's own derivation, unchanged. The key is not present at all rather
// than present-and-undefined, so nothing downstream can mistake it for a configured empty name.
expect(config).not.toHaveProperty("heloName");
expect(createMailProviderConfig({ ...validConfig, heloName: "" })).not.toHaveProperty(
"heloName",
);
expect(createMailProviderConfig({ ...validConfig, heloName: undefined })).not.toHaveProperty(
"heloName",
);
});
it.each([
["a two-label domain", "example.org"],
["a subdomain", "mail.example.org"],
["a deep subdomain", "smtp.relay.mail.example.org"],
["hyphens inside labels", "out-bound.example-church.org"],
["digits", "mx1.example2.org"],
["mixed case", "Mail.Example.ORG"],
])("accepts %s", (_label, heloName) => {
expect(createMailProviderConfig({ ...validConfig, heloName })).toMatchObject({ heloName });
});
it.each([
["a scheme", "smtp://example.org"],
["a port", "example.org:587"],
["a path", "example.org/relay"],
["a space", "example org"],
["a leading space", " example.org"],
["a trailing space", "example.org "],
["a carriage return", "example.org\r"],
["a line feed", "example.org\nEHLO evil.example.net"],
["a tab", "example.org\t"],
["a comma", "example.org,evil.example.net"],
["an unqualified single label", "localhost"],
["a machine name with no domain", "ekklesia-host"],
["a bracketed address literal", "[127.0.0.1]"],
["a bare IPv4 address", "192.0.2.1"],
["a trailing dot", "example.org."],
["a leading dot", ".example.org"],
["consecutive dots", "example..org"],
["a leading hyphen in a label", "-example.org"],
["a trailing hyphen in a label", "example-.org"],
["an email address", "[email protected]"],
["prose", "our church mail server"],
["nothing but dots", "..."],
["an over-long name", `${"a".repeat(250)}.example.org`],
])("refuses %s", (_label, heloName) => {
expect(() => createMailProviderConfig({ ...validConfig, heloName })).toThrow(
expect.objectContaining({ code: "MAIL_HELO_NAME_INVALID" }),
);
});
it("refuses an unqualified name, because that is the input the fallback already rejects", () => {
// Configuring a single label would reproduce the very failure this setting exists to fix:
// Nodemailer discards a non-FQDN hostname and substitutes the address literal.
expect(() => createMailProviderConfig({ ...validConfig, heloName: "ekklesia" })).toThrow(
MailConfigError,
);
});
it("never carries the offending value in the error", () => {
try {
createMailProviderConfig({ ...validConfig, heloName: "smtp://secret-internal-name:2525" });
throw new Error("expected a refusal");
} catch (error) {
const serialized = `${(error as Error).message} ${(error as Error).stack ?? ""}`;
expect(serialized).not.toContain("secret-internal-name");
expect(serialized).not.toContain("2525");
}
});
it("is described to an operator, because it is infrastructure rather than a credential", () => {
const described = describeMailProvider(
createMailProviderConfig({
...validConfig,
heloName: "mail.example.org",
password,
username: "[email protected]",
}) as Parameters<typeof describeMailProvider>[0],
);
// It is announced in the clear on every connection, so printing it leaks nothing — and it is
// exactly what an operator needs when a relay has refused the greeting.
expect(described).toContain("HELO mail.example.org");
expect(described).not.toContain(password);
expect(described).not.toContain("[email protected]");
});
it("says nothing about a HELO name when none is configured", () => {
const described = describeMailProvider(
createMailProviderConfig(validConfig) as Parameters<typeof describeMailProvider>[0],
);
expect(described).not.toContain("HELO");
});
});
describe("transactional message validation", () => {
const message = {
subject: "Verify your email address",