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
+4
View File
@@ -133,6 +133,10 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
mail: {
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
fromName: environment.EKKLESIA_SMTP_FROM_NAME,
// The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it
// matters only where the machine's own hostname is not a fully qualified name the relay will
// accept.
heloName: environment.EKKLESIA_SMTP_HELO_NAME,
host: environment.EKKLESIA_SMTP_HOST,
password: environment.EKKLESIA_SMTP_PASSWORD,
port: environment.EKKLESIA_SMTP_PORT,
+61 -1
View File
@@ -8,6 +8,7 @@ export type MailConfigErrorCode =
| "MAIL_FROM_ADDRESS_INVALID"
| "MAIL_FROM_ADDRESS_MISSING"
| "MAIL_FROM_NAME_INVALID"
| "MAIL_HELO_NAME_INVALID"
| "MAIL_HOST_INVALID"
| "MAIL_PASSWORD_WITHOUT_USERNAME"
| "MAIL_PORT_INVALID"
@@ -19,6 +20,8 @@ const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
MAIL_HELO_NAME_INVALID:
"The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.",
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
MAIL_PASSWORD_WITHOUT_USERNAME:
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
@@ -70,6 +73,7 @@ export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
export interface MailProviderConfigInput {
readonly host?: string | undefined;
readonly heloName?: string | undefined;
readonly port?: string | number | undefined;
readonly security?: string | undefined;
readonly username?: string | undefined;
@@ -87,6 +91,24 @@ export interface MailProviderConfigInput {
*/
export interface MailProviderConfig {
readonly host: string;
/**
* The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets
* one.
*
* This exists because of a real failure, and the failure is generic rather than tied to any one
* provider. An SMTP client must greet the server with its own identity, and when nothing is
* configured Nodemailer derives it from the machine's hostname — falling back to the address
* literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state
* of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the
* connection with a `421` before any message is offered, which surfaces here as a bare
* `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down.
*
* Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to
* the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting
* correctly — some relays check it against the connecting address's reverse DNS — and silently
* replacing a working identity would trade one deployment's bug for another's.
*/
readonly heloName?: string;
readonly port: number;
readonly security: MailSecurityMode;
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
@@ -119,6 +141,29 @@ function validateHost(host: string): string {
return trimmed;
}
/**
* A fully qualified domain name, and deliberately nothing else.
*
* Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a
* single unqualified label is precisely the input Nodemailer already refuses to use — configuring
* one would reproduce the failure this setting exists to fix. And it requires the final label to
* begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax,
* and the bracketed address-literal form that *is* valid is the greeting the affected relays reject.
*
* Everything the requirement rules out falls out of the anchors and the character class rather than
* from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a
* carriage return or line feed, and any other prose all fail to match.
*/
const HELO_NAME_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
function validateHeloName(heloName: string): string {
if (!HELO_NAME_PATTERN.test(heloName)) {
throw new MailConfigError("MAIL_HELO_NAME_INVALID");
}
return heloName;
}
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
return DEFAULT_PORTS[security];
@@ -174,6 +219,16 @@ export function createMailProviderConfig(
const validatedHost = validateHost(host);
const port = validatePort(input?.port, security);
// Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding
// whitespace as a copy-paste artefact; this one must not, because a stray character in the value
// goes straight onto the wire in a protocol command. It is refused rather than repaired, the same
// rule an address follows.
const heloNameInput = input?.heloName;
const heloName =
heloNameInput === undefined || heloNameInput === ""
? undefined
: validateHeloName(heloNameInput);
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
const password = input?.password === "" ? undefined : input?.password;
if (username !== undefined && password === undefined) {
@@ -201,6 +256,7 @@ export function createMailProviderConfig(
host: validatedHost,
port,
security,
...(heloName === undefined ? {} : { heloName }),
...(fromName === undefined ? {} : { fromName }),
...(username === undefined || password === undefined ? {} : { password, username }),
});
@@ -219,7 +275,11 @@ export function describeMailProvider(config: MailProviderConfig): string {
config.fromName === undefined
? config.fromAddress
: `${config.fromName} <${config.fromAddress}>`;
// The HELO name is host infrastructure, not a credential: it is the identity this client announces
// in the clear on every connection, so printing it costs nothing and is exactly what an operator
// needs when a relay has refused the greeting.
const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`;
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
config.username === undefined ? "unauthenticated relay" : "authenticated"
}`;
}${helo}`;
}
+5
View File
@@ -126,6 +126,11 @@ function transportOptions(
disableUrlAccess: true,
greetingTimeout: bounds.greetingTimeoutMs,
host: config.host,
// The identity this client announces in `EHLO`. Left to Nodemailer when the deployment
// configures none — it derives one from the machine hostname, falling back to the address
// literal `[127.0.0.1]` when that hostname is not fully qualified, which some relays refuse
// with a `421` before a message is ever offered (ADR 0037).
...(config.heloName === undefined ? {} : { name: config.heloName }),
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
// platform sending a handful of transactional messages buys nothing and makes the failure modes