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.auth.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 — bare-LF dot-terminators are refused. 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.context 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

## 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. 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 7489) policy + alignment on the From-header domain — 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 7489 §6.6.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) #

stable0.9.46
{
  tlsContext:        TlsContext,      // required — b.network.tls.context() output (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
  relayAllowedFor:   [{ cidr, scope }],  // operator-explicit relay allowlist; default [] = MX-only
  localDomains:      [string],        // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
  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
  profile:           "strict" | "balanced" | "permissive",  // gate posture cascade
  guardEnvelope:     true | {        // optional gate — DATA-phase SPF/DKIM/DMARC 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; default localDomains[0]
    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)
  },
}

Build the MX listener. Returns { listen({ port?, address? }), close({ timeoutMs? }), connectionCount(), _portForTest() }.

var tls = b.network.tls.context({ 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 });

Last updated 2026-08-08T16:39:49.652Z by seeder.