fix: let a deployment name itself in the SMTP greeting #39
@@ -81,6 +81,18 @@ S3_BUCKET=ekklesia-development
|
||||
# EKKLESIA_SMTP_PASSWORD=
|
||||
# [email protected]
|
||||
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||
#
|
||||
# EKKLESIA_SMTP_HELO_NAME is the name Ekklesia announces itself as in the SMTP EHLO command.
|
||||
#
|
||||
# Leave it unset unless a relay refuses the connection. An SMTP client has to identify itself, 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 or an unnamed VM. Some relays close the connection on that greeting with a
|
||||
# 421 before any message is offered, which looks exactly like the relay being unreachable.
|
||||
#
|
||||
# Set it to a fully qualified domain name your deployment owns — typically the sending domain.
|
||||
# No scheme, no port, no brackets, and at least one dot; anything else is refused at startup.
|
||||
# EKKLESIA_SMTP_HELO_NAME=example.org
|
||||
|
||||
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
|
||||
#
|
||||
|
||||
@@ -33,6 +33,7 @@ function environment(
|
||||
|
||||
const configuredInfrastructure = Object.freeze({
|
||||
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||
@@ -77,6 +78,9 @@ describe("host configuration parity between startApi and Doctor", () => {
|
||||
|
||||
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
||||
expect(apiView(env).mail).toMatchObject({
|
||||
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
|
||||
// must fail in Doctor for the same reason it fails at runtime.
|
||||
heloName: "mail.example.org",
|
||||
host: "smtp.example.org",
|
||||
port: 587,
|
||||
security: "starttls",
|
||||
@@ -123,6 +127,10 @@ describe("host configuration parity between startApi and Doctor", () => {
|
||||
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
||||
],
|
||||
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
||||
[
|
||||
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
||||
"MAIL_HELO_NAME_INVALID",
|
||||
],
|
||||
] as const) {
|
||||
const env = environment(overrides);
|
||||
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -363,6 +363,36 @@ describe("Doctor host-configuration parity — outbound email regression", () =>
|
||||
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||
});
|
||||
|
||||
it("probes with the SMTP client identity the runtime would use, and reports it", async () => {
|
||||
const services = dependencies();
|
||||
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "mail.example.org" }),
|
||||
services,
|
||||
);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
// Doctor builds its provider from the same validated configuration `startApi` does, so a relay
|
||||
// that refuses the greeting fails here rather than only when a reset email silently never
|
||||
// arrives. The name is announced in the clear on every connection, so showing it leaks nothing.
|
||||
expect(services.probeMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ heloName: "mail.example.org" }),
|
||||
);
|
||||
expect(mailCheck.details[0]).toContain("HELO mail.example.org");
|
||||
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||
});
|
||||
|
||||
it("reports a malformed SMTP client identity as a typed failure, naming no value", async () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "smtp://internal-host:2525" }),
|
||||
);
|
||||
const mailCheck = check(report, "outbound-email.configuration");
|
||||
|
||||
expect(mailCheck.status).toBe("failure");
|
||||
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_HELO_NAME_INVALID.");
|
||||
expect(JSON.stringify(mailCheck)).not.toContain("internal-host");
|
||||
});
|
||||
|
||||
it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => {
|
||||
const { report } = await diagnose(
|
||||
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
|
||||
|
||||
@@ -71,6 +71,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
||||
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
||||
"EKKLESIA_SMTP_FROM_ADDRESS",
|
||||
"EKKLESIA_SMTP_FROM_NAME",
|
||||
"EKKLESIA_SMTP_HELO_NAME",
|
||||
"EKKLESIA_SMTP_HOST",
|
||||
"EKKLESIA_SMTP_PASSWORD",
|
||||
"EKKLESIA_SMTP_PORT",
|
||||
|
||||
@@ -20,6 +20,7 @@ const productionEnvironment = Object.freeze({
|
||||
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
||||
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||
EKKLESIA_SMTP_FROM_NAME: "Ekklesia",
|
||||
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||
EKKLESIA_SMTP_PASSWORD: "sup3r-secret-relay-password",
|
||||
EKKLESIA_SMTP_PORT: "587",
|
||||
@@ -67,6 +68,7 @@ describe("host configuration loading", () => {
|
||||
expect(host.mail).toEqual({
|
||||
fromAddress: "[email protected]",
|
||||
fromName: "Ekklesia",
|
||||
heloName: "mail.example.org",
|
||||
host: "smtp.example.org",
|
||||
password: "sup3r-secret-relay-password",
|
||||
port: "587",
|
||||
@@ -80,6 +82,9 @@ describe("host configuration loading", () => {
|
||||
});
|
||||
expect(relayByAddress.mail.username).toBeUndefined();
|
||||
expect(relayByAddress.mail.password).toBeUndefined();
|
||||
// The SMTP client identity is optional too, and unset is the ordinary case: it matters only
|
||||
// where the machine's own hostname is not a fully qualified name the relay accepts.
|
||||
expect(relayByAddress.mail.heloName).toBeUndefined();
|
||||
// A source-IP-authorized relay has no credentials at all, and that must still validate.
|
||||
expect(createMailProviderConfig(relayByAddress.mail)).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -161,13 +161,42 @@ describe("outbound email stays server-only", () => {
|
||||
});
|
||||
|
||||
it("hardcodes no provider hostname in Core", async () => {
|
||||
// Google Workspace is a deployment target, not the architecture.
|
||||
// Google Workspace is a deployment target, not the architecture. It is the deployment that
|
||||
// exposed the generic `EHLO` defect the HELO-name setting fixes, and it is named nowhere the
|
||||
// code could come to depend on it.
|
||||
for (const file of await readTextFiles("core/src/mail")) {
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("gmail");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("resend");
|
||||
expect(file.text.toLowerCase(), file.path).not.toContain("brevo");
|
||||
const text = file.text.toLowerCase();
|
||||
for (const forbidden of [
|
||||
"gmail",
|
||||
"smtp-relay",
|
||||
"googlemail",
|
||||
"google.com",
|
||||
"outlook",
|
||||
"office365",
|
||||
"sendgrid",
|
||||
"mailgun",
|
||||
"postmark",
|
||||
"resend",
|
||||
"brevo",
|
||||
]) {
|
||||
expect(text, `${file.path} names ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the SMTP client identity to nothing, so no deployment's name is baked in", async () => {
|
||||
const config = await readFile(path.join(repositoryRoot, "core/src/mail/config.ts"), "utf8");
|
||||
const provider = await readFile(
|
||||
path.join(repositoryRoot, "core/src/mail/smtp-provider.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// The value that fixed a real deployment is configuration, never a constant. A fallback literal
|
||||
// here would be one church's domain compiled into every installation.
|
||||
expect(config).not.toContain("riverofglorymn");
|
||||
expect(provider).not.toContain("riverofglorymn");
|
||||
// The provider passes it through and supplies nothing of its own when it is absent.
|
||||
expect(provider).toContain("config.heloName === undefined ? {} : { name: config.heloName }");
|
||||
});
|
||||
|
||||
it("never disables TLS certificate verification", async () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { AddressInfo, Socket } from "node:net";
|
||||
import { createServer, type Server } from "node:net";
|
||||
import { SMTPServer } from "smtp-server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -25,6 +25,8 @@ interface CapturedMessage {
|
||||
readonly from: string;
|
||||
readonly to: readonly string[];
|
||||
readonly data: string;
|
||||
/** The argument this client actually sent in `EHLO`, lowercased by the server. */
|
||||
readonly greetedAs: string;
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
@@ -52,6 +54,14 @@ interface HarnessOptions {
|
||||
readonly rejectRecipients?: readonly string[];
|
||||
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
|
||||
readonly offerStartTls?: boolean;
|
||||
/**
|
||||
* Refuses any client whose `EHLO` argument is not exactly this name, as a relay that requires a
|
||||
* fully qualified client identity does.
|
||||
*
|
||||
* The refusal is a `421`, the same class of response the real relay returned: a temporary failure
|
||||
* that closes the connection before a message is offered.
|
||||
*/
|
||||
readonly requireGreeting?: string;
|
||||
}
|
||||
|
||||
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||
@@ -81,11 +91,26 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||
captured.push({
|
||||
data,
|
||||
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
|
||||
greetedAs: session.hostNameAppearsAs,
|
||||
to: session.envelope.rcptTo.map((entry) => entry.address),
|
||||
});
|
||||
callback();
|
||||
});
|
||||
},
|
||||
onMailFrom(_address, session, callback) {
|
||||
if (
|
||||
options.requireGreeting !== undefined &&
|
||||
session.hostNameAppearsAs !== options.requireGreeting.toLowerCase()
|
||||
) {
|
||||
const error = new Error("Try again later, closing connection.") as Error & {
|
||||
responseCode?: number;
|
||||
};
|
||||
error.responseCode = 421;
|
||||
callback(error);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
onRcptTo(address, _session, callback) {
|
||||
if (options.rejectRecipients?.includes(address.address)) {
|
||||
// 550 is a permanent refusal of this mailbox, which is what a real relay returns.
|
||||
@@ -107,6 +132,64 @@ async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
||||
return { captured, close, port: address.port };
|
||||
}
|
||||
|
||||
/**
|
||||
* A relay that refuses the greeting itself, byte for byte as the real one did.
|
||||
*
|
||||
* `smtp-server` exposes no `EHLO` hook — its earliest usable one is `MAIL FROM`, which
|
||||
* `transporter.verify()` never reaches — so modelling this failure at the point it actually happens
|
||||
* needs a socket that speaks the handful of SMTP verbs involved. That matters: the whole reason the
|
||||
* defect was hard to diagnose is that it aborts the session *before* a message is offered, which is
|
||||
* also why Doctor's send-nothing probe can see it.
|
||||
*/
|
||||
async function startGreetingStrictRelay(expected: string): Promise<Harness> {
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => undefined);
|
||||
socket.write("220 relay.invalid ESMTP\r\n");
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
let index = buffer.indexOf("\r\n");
|
||||
while (index !== -1) {
|
||||
const line = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 2);
|
||||
const [verb = "", argument = ""] = line.split(" ");
|
||||
if (verb.toUpperCase() === "EHLO" || verb.toUpperCase() === "HELO") {
|
||||
if (argument.toLowerCase() !== expected.toLowerCase()) {
|
||||
// The exact shape the deployment saw: a 421 naming no reason, then the door shut.
|
||||
socket.write("421-4.7.0 Try again later, closing connection. (EHLO)\r\n");
|
||||
socket.write("421 4.7.0 Goodbye\r\n");
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
socket.write("250-relay.invalid\r\n250 OK\r\n");
|
||||
} else if (verb.toUpperCase() === "QUIT") {
|
||||
socket.write("221 Bye\r\n");
|
||||
socket.end();
|
||||
return;
|
||||
} else {
|
||||
socket.write("250 OK\r\n");
|
||||
}
|
||||
index = buffer.indexOf("\r\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
const close = async (): Promise<void> => {
|
||||
// `close()` waits for open sockets, and a refused client may still be holding one.
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
};
|
||||
openHarnesses.push(close);
|
||||
return { captured: [], close, port: address.port };
|
||||
}
|
||||
|
||||
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
|
||||
const built = createMailProviderConfig(
|
||||
{
|
||||
@@ -355,3 +438,94 @@ describe.sequential("live SMTP transport", () => {
|
||||
expect(harness.captured).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SMTP client identity on the wire", () => {
|
||||
/**
|
||||
* The regression guard for a real production defect.
|
||||
*
|
||||
* A deployment relaying through an IP-authorized relay could not send at all: every attempt
|
||||
* surfaced as `MAIL_PROVIDER_UNAVAILABLE`. The relay was healthy — `swaks` sent from the same
|
||||
* machine — and the cause was the greeting. 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. The relay closed the
|
||||
* connection on it with a `421`.
|
||||
*
|
||||
* These tests are written against the wire rather than against the transport options, because the
|
||||
* defect was invisible in the options: everything about the configuration looked correct.
|
||||
*/
|
||||
it("greets with the configured name, exactly as configured", async () => {
|
||||
const harness = await startSmtpServer();
|
||||
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
|
||||
await mail.send(
|
||||
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
||||
);
|
||||
|
||||
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
||||
});
|
||||
|
||||
it("never greets as the address literal when a name is configured", async () => {
|
||||
const harness = await startSmtpServer();
|
||||
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
|
||||
await mail.send(
|
||||
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
||||
);
|
||||
|
||||
// The exact greeting the relay refused.
|
||||
expect(harness.captured[0]?.greetedAs).not.toBe("[127.0.0.1]");
|
||||
});
|
||||
|
||||
it("delivers to a relay that demands a fully qualified greeting", async () => {
|
||||
const harness = await startSmtpServer({ requireGreeting: "mail.example.org" });
|
||||
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
|
||||
await mail.send(
|
||||
validateMailMessage({ subject: "Accepted", text: "Body.", to: "[email protected]" }),
|
||||
);
|
||||
|
||||
expect(harness.captured).toHaveLength(1);
|
||||
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
||||
});
|
||||
|
||||
it("is refused by that same relay when no name is configured", async () => {
|
||||
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||
const mail = provider(config(harness.port));
|
||||
|
||||
// Deterministic regardless of what this machine is called: the server accepts exactly one name,
|
||||
// and Nodemailer's derivation cannot produce `mail.example.org` on any host.
|
||||
await expect(
|
||||
mail.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" })),
|
||||
).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE" });
|
||||
expect(harness.captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("carries the refused greeting into no error a caller can see", async () => {
|
||||
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||
const mail = provider(config(harness.port));
|
||||
|
||||
const failure = await mail
|
||||
.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" }))
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
// A `421` is SMTP's "try later", so it stays `MAIL_PROVIDER_UNAVAILABLE` rather than becoming a
|
||||
// permanent rejection — and the server's response text never reaches the caller.
|
||||
const serialized = `${(failure as Error).message} ${(failure as Error).stack ?? ""}`;
|
||||
expect(serialized).not.toContain("Try again later");
|
||||
expect(serialized).not.toContain("closing connection");
|
||||
});
|
||||
|
||||
it("still verifies through the same greeting Doctor's probe will use", async () => {
|
||||
const harness = await startGreetingStrictRelay("mail.example.org");
|
||||
const configured = provider(config(harness.port, { heloName: "mail.example.org" }));
|
||||
const unconfigured = provider(config(harness.port));
|
||||
|
||||
// Doctor builds its provider from the identical validated configuration, so an operator whose
|
||||
// relay refuses the greeting sees it in `pnpm doctor` rather than only when a reset email
|
||||
// silently fails to arrive.
|
||||
await expect(configured.verify()).resolves.toBeUndefined();
|
||||
await expect(unconfigured.verify()).rejects.toMatchObject({
|
||||
code: "MAIL_PROVIDER_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user