Mail MX Server
Inbound SMTP / MX listener. Composes the framework's existing mail-gate substrates (b.mail.helo, b.mail.rbl, b.mail.greylist, b.guardEnvelope, b.mail.dmarc, b.safeMime, b.guardEmail, b.guardSmtpCommand, b.mail.agent) into one operator-facing server that accepts inbound mail per RFC 5321 with PQC-shaped TLS posture, SMTP- smuggling defense baked into the wire-protocol layer, and the gate cascade running at the right phase of the state machine.
create({ ... }).listen() binds the TCP port; every incoming connection drives the CONNECT → EHLO → [STARTTLS → EHLO] → MAIL → RCPT (×N) → DATA → DATA-body → QUIT state machine. Each phase passes through the operator-supplied gates (defaulting to "no-op" when the operator hasn't wired a gate) and refuses with the appropriate 5xx (permanent) or 4xx (transient) SMTP reply code on gate fail.
## Defenses baked in
- **SMTP smuggling** (CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim) — every wire line passes through b.guardSmtpCommand.validate which refuses bare LF, bare CR, NUL, C0 controls, DEL, and oversize. The DATA body's \r\n.\r\n terminator is matched on canonical CRLF only, and the body is refused if it carries a .-alone-line whose surrounding line endings are not both canonical CRLF (b.guardSmtpCommand.detectBodySmuggling covers every variant: \n.\n, \r\n.\n, \n.\r\n, \r.\r, \r.\n, \n.\r, \r\n.\r), so no non-canonical dot-terminator survives to a lenient relay hop. Together this defends the CVE-2023-51764 class where a hostile sender smuggles a second message past the framework's filter by terminating the first one with \n.\n instead of \r\n.\r\n.
- **Open-relay defense** — RCPT TO non-local refused with 550 5.7.1 Relaying denied unless the operator explicitly registered the destination via relayAllowedFor: [{ cidr, scope }]. The default posture is "MX-only, no relay" so a misconfigured boot can't accidentally become an open relay.
- **STARTTLS stripping (CVE-2021-38371 Exim, CVE-2021-33515 Dovecot)** — once STARTTLS is advertised + selected, subsequent commands MUST run over the negotiated TLS context. A pre-STARTTLS pipelining attempt (RFC 2920) to inject commands that take effect post-handshake is refused by clearing the command buffer at STARTTLS time and reading fresh from the TLS socket only — defends both the Exim and Dovecot variants of the STARTTLS-injection class.
- **Resource exhaustion** — per-command line cap (default 1 KiB), DATA body cap (default 50 MiB per RFC 5321 §4.5.3.1.7), per-recipient cap (default 100 per RFC 5321 §4.5.3.1.8), connection idle timeout (default 5 minutes per RFC 5321 §4.5.3.2.7). Operator opts up with explicit bounds.
- **TLS posture** — tlsContext MUST be supplied (no implicit plaintext-only mode). Operator passes a b.network.tls.applyToContext output which carries the framework's TLS 1.3 default + OCSP / CT-log posture. Pre-STARTTLS plain commands are limited to EHLO / HELO / STARTTLS / NOOP / QUIT / RSET; MAIL / RCPT / DATA all refused with 530 5.7.0 Must issue a STARTTLS command first.
## Audit lifecycle
mail.server.mx.connect— IP, TLS state, FCrDNS hostnamemail.server.mx.helo— HELO greeting, helo-gate verdictmail.server.mx.helo_gate_refused— HELO identity refused (gate action)mail.server.mx.mail_from— sender addressmail.server.mx.rcpt_to— recipient, rblListed flag, greylist actionmail.server.mx.rbl_refused— connecting IP on a DNS blocklist (zones)mail.server.mx.greylist_deferred— (ip, from, rcpt) first-seen 450 deferralmail.server.mx.data_refused— refusal reason + SMTP code (5xx vs 4xx)mail.server.mx.envelope_verdict— DATA-phase SPF/DKIM/DMARC/ARC results + action (accept / quarantine / reject / defer) + gate modemail.server.mx.envelope_error— DATA-phase authentication pipeline failure or timeout (disposition follows onTemperror)mail.server.mx.delivered— agent.handoff ackmail.server.mx.tls_handshake_failed— handshake errormail.server.mx.smtp_smuggling_detected— CRLF.CRLF injection classmail.server.mx.relay_refused— open-relay attemptmail.server.mx.recipient_refused— recipientPolicy said the mailbox is unavailable (550 5.1.1)mail.server.mx.recipient_policy_threw— recipientPolicy failed; the recipient is deferred (451 4.7.1)
## What v1 does NOT ship
- **AUTH / submission auth** — MX listener is inbound from the internet, no authentication. Submission listener (port 587) is a separate slice with SCRAM-SHA-256 / XOAUTH2 / EXTERNAL. - **Sieve filtering** — composes via b.mail.agent at delivery time; the MX listener doesn't decide policy itself. - **Outbound DSN generation** — b.guardDsn parses inbound DSNs; outbound DSN emission deferred to the submission slice. - **8BITMIME** (RFC 6152, obsoletes RFC 1652) — advertised in the EHLO capabilities since the DATA body parser via b.safeMime is octet-clean; no transcoding needed. - **SMTPUTF8** (RFC 6531) + **IDN** (RFC 5891) — the wire-protocol layer here is encoding-agnostic; SMTPUTF8 capability advertisement is a follow-up slice once the operator's downstream (mail-store + delivery agent) accepts Unicode mailbox-local-part bytes. Today the listener does not advertise SMTPUTF8 and refuses non-ASCII in MAIL FROM / RCPT TO via b.guardSmtpCommand.
## Composition contract
Every gate is a primitive that already exists. The MX slice is a state-machine + wire-protocol coordinator — no new crypto, no new parsing, no new RFC-layer primitives. When the operator doesn't wire a gate (e.g. omits opts.greylist), the listener skips that phase rather than synthesizing a verdict.
Connection-level gates are wired into the live state machine: opts.helo (HELO identity) evaluates at HELO/EHLO; opts.rbl (connecting-IP DNS blocklist, evaluated once per connection) and opts.greylist ((ip, from, rcpt) first-seen deferral) evaluate at RCPT TO and surface their verdicts on the rcpt_to event.
opts.recipientPolicy decides whether a mailbox on a local domain exists, and is the only way to answer "no such user" the way RFC 5321 §3.3 asks. It runs at RCPT TO, so the refusal costs one command rather than a whole message body: { ok: false, reason } becomes 550 5.1.1 with the reason, and a throw becomes 451 4.7.1 (a directory that cannot be reached is not a verdict about the mailbox). Refusals charge the same per-IP recipient-failure budget as a relay refusal, because the 250-vs-550 difference is a mailbox-existence oracle. Unwired, every syntactically valid recipient on a local domain is accepted and the agent handoff is the operator's last chance to reject. The
message-authentication gate (opts.guardEnvelope) runs at DATA completion through b.mail.inbound.verify — SPF (RFC 7208) on the envelope identity, DKIM (RFC 6376) on the message bytes, DMARC (RFC 9989) policy + alignment on the From-header domain, ARC (RFC 8617) on any chain a forwarder left behind — and in enforce mode refuses before the agent handoff: 550 5.7.26 (RFC 7372) when the sender's published policy says reject, 550 5.7.1 on the RFC 9989 §5.3.1 multi-From spoofing shape, 451 4.7.0 on DNS temperror or pipeline timeout (operator-tunable via onTemperror / timeoutMs). Accepted messages carry the verdict to the agent handoff as auth and gain the receiver's RFC 8601 Authentication-Results header — any sender-attached header forging this receiver's authserv-id is stripped first (§5) — so downstream consumers act on authenticated results instead of re-verifying; monitor mode annotates without refusing.
b.mail.server.mx.create(opts) #
{
tlsContext: TlsContext, // required — a SecureContext built from
// b.network.tls.buildOptions() (no implicit plaintext)
greeting: string, // default "blamejs ESMTP" — HELO/EHLO 220-line banner
helo: b.mail.helo, // optional gate — HELO identity (FCrDNS / shape / self-name)
rbl: b.mail.rbl.create(…), // optional gate — DNS blocklist on the connecting IP
greylist: b.mail.greylist.create(…), // optional gate — defer first-seen (ip, from, rcpt)
agent: b.mail.agent, // optional delivery handoff. A rejection may carry smtpCode
// ("550"), enhancedStatus ("5.7.1") and replyText to choose its
// own refusal; without them the reply is 451 4.3.0
relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
localDomains: [string]|fn, // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1). A function returning the array is answered per RCPT, for a hosted set the operator changes while the server runs; an entry it returns that b.guardDomain refuses is dropped with an audit event rather than throwing on the connection that happened to arrive.
recipientPolicy: function (ctx) → { ok } | { ok: false, reason }, // optional RCPT-time mailbox check; refuses 550 5.1.1, defers 451 when it throws
maxLineBytes: number, // default 1 KiB — per-command line cap
maxMessageBytes: number, // default 50 MiB — DATA body cap
maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
maxConnections: number, // default 1024 — listener-wide ceiling
profile: "strict" | "balanced" | "permissive", // gate posture cascade
guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
authservId?: string, // RFC 8601 authserv-id; defaults to the first local domain, re-read per message when localDomains is a function
dnsLookup?: function, // async (qname, type) override for SPF/DKIM/DMARC lookups
maxSignatures?: number, // DKIM verify cap (1-16)
clockSkewMs?: number, // DKIM timestamp skew tolerance
minRsaBits?: number, // DKIM minimum RSA key size
timeoutMs?: number, // pipeline wall-clock ceiling; default 20s (timeout → temperror disposition)
},
allowSmtpUtf8: boolean, // offer RFC 6531 SMTPUTF8; default false. Read strictly, so
only `true` offers it and any other value, the string "true" included,
leaves it off. The submission listener validates the same option and
refuses a non-boolean outright
guardDomain: boolean | object, // the b.guardDomain screen every envelope domain passes, at
this listener's `profile` unless an object carries its own `profile`.
`false` turns the screen off; a refusal emits mail.server.mx.domain_refused,
and a recipient domain outside localDomains emits
mail.server.mx.local_domain_refused
rateLimit: b.mail.server.rateLimit handle | opts | false, // `false` disables admission
control. A custom limiter must implement the whole interface the listeners
call, or b.mail.server.rateLimit.resolve raises
mail-server-rate-limit/incomplete-limiter rather than leaving a method
added later silently unimplemented
}
Build the MX listener. Returns { listen({ port?, address? }), close({ timeoutMs? }), connectionCount(), _portForTest() }.
var tls = require("node:tls").createSecureContext(
b.network.tls.buildOptions({ cert: certPem, key: keyPem }));
var server = b.mail.server.mx.create({
tlsContext: tls,
greeting: "mx.example.com ESMTP blamejs",
helo: b.mail.helo,
rbl: b.mail.rbl.create({ providers: ["zen.spamhaus.org"] }),
greylist: b.mail.greylist.create({ store: greylistStore }),
agent: b.mail.agent.create({ store: mailStore }),
localDomains: ["example.com"],
});
await server.listen({ port: 25 });