Mail Submission Server

Outbound SMTP submission listener per RFC 6409 (port 587) and RFC 8314 implicit-TLS submissions (port 465). Where the MX listener (b.mail.server.mx) accepts inbound mail from the internet to local mailboxes, the submission listener accepts outbound mail from authenticated MUAs / app-side mail-senders and routes it to upstream MXs via b.mail.send.

Differences from the MX listener:

- **AUTH required** — operator-supplied authenticator validates SASL credentials (PLAIN / LOGIN / SCRAM-SHA-256 / EXTERNAL / XOAUTH2). MAIL FROM is refused until AUTH succeeds.

- **Identity binding** — under strict profile, MAIL FROM: MUST match the authenticated actor's mailbox set; refused with 553 5.7.1 Sender address rejected. Permissive logs the mismatch but allows.

- **TLS required for AUTH** (RFC 4954 §4) — pre-STARTTLS AUTH refused with 538 5.7.11 Encryption required for AUTH mechanism. Permissive profile allows plaintext AUTH for legacy operator-acknowledged downgrade.

- **Implicit-TLS mode** — implicitTls: true wraps every connection in TLS from the SYN (port 465 per RFC 8314); no STARTTLS advertised because the connection is already secure.

- **Outbound routing** — successful DATA hands off to the operator-supplied agent.handoff({ ... }) for relay through b.mail.send to upstream MXs. The listener doesn't perform MX lookup or outbound delivery itself.

## Wire-protocol defenses (inherited from MX listener pattern)

- SMTP smuggling (CVE-2023-51764 / -51765 / -51766 / RFC 5321 §2.3.8): every wire line through b.guardSmtpCommand.validate; DATA-body terminator scan through b.safeSmtp.findDotTerminator (strict-CRLF); smuggling shape detected via b.guardSmtpCommand.detectBodySmuggling.

- STARTTLS-injection (CVE-2021-38371 Exim, CVE-2021-33515 Dovecot): command buffer cleared at upgrade time.

- Resource exhaustion: per-command line cap (1 KiB), DATA body cap (50 MiB per RFC 5321 §4.5.3.1.7), per-message recipient cap (100 per RFC 5321 §4.5.3.1.8), idle timeout (5 minutes per RFC 5321 §4.5.3.2.7).

## SMTP AUTH (RFC 4954)

- Mechanisms negotiated per RFC 4422 (SASL) — the operator opts the list auth.mechanisms into the EHLO advertisement. - Initial-response variant AUTH MECH (RFC 4954 §4) supported. - Failed AUTH emits mail.server.submission.auth_failed with mechanism + reason; operator's rate-limit wired via auth.rateLimit (composes b.middleware.rateLimit) trips 421 4.7.0 Too many failed AUTH after the operator-configured budget.

## Audit lifecycle (in addition to the MX listener's)

## What v1 does NOT ship

- **DKIM signing pre-relay** — operator wires b.mail.dkim.sign in their outbound agent. - **Per-actor outbound quota** — operator implements via b.dailyByteQuota against the authenticated actor.

(CHUNKING / BDAT, RFC 3030, IS supported — advertised in EHLO and handled alongside DATA.)

## Composition contract

Every gate is a primitive that already exists. Submission listener composes b.guardSmtpCommand (wire-protocol gate + smuggling defense), b.safeSmtp (wire-protocol parser), the operator's authenticator (SASL verify), b.mail.send (outbound MX routing), and the framework's TLS posture via b.network.tls.applyToContext.

b.mail.server.submission.create(opts) #

stable0.9.47
{
  tlsContext:      TlsContext,   // required — b.network.tls.applyToContext() output
  implicitTls:     boolean,      // wrap connection in TLS from the SYN (port 465); default false
  greeting:        string,       // EHLO/220 banner; default "blamejs Submission"
  auth:            object,       // SASL config (required unless permissive profile)
    mechanisms:    string[],     // SASL mechs to advertise; default ["PLAIN","LOGIN"]
    verify:        function,     // async (mechanism, credentials) => { ok, actor }
    rateLimit:     object,       // optional b.middleware.rateLimit instance for failure budget
  agent:           object,       // outbound delivery handoff (handoff({ ... }) → ack). 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
  identityBinding: "strict" | "permissive",  // MAIL FROM must match auth identity (default strict)
  requireDkim:     boolean,      // a message must carry a DKIM-Signature; default true under the strict profile
  dkimRequireMode: "self" | "any" | "off",   // which signature satisfies requireDkim; default "any". "any" takes any DKIM-Signature header. "self" takes one whose d= names a domain this connection may send as: the actor's `domain`, the domain of an `actor.id` spelled as an address, the domain of any assigned mailbox, or the domain of an alias `senderPolicy` approved for the transaction being sent. An actor carrying none of those, and a connection with no authenticated actor, have no domain to be "self", so every signature is refused. An envelope sender the operator has not approved is not consulted, because that is what the mode is checking. "off" clears requireDkim
  subaddressDelimiter: string,   // RFC 5233 local-part separator the identity check folds on ("+" on most systems, "-" on qmail-derived ones). OFF unless set: subaddressing is a delivery convention, and folding where plus-addresses are distinct mailboxes would grant send-as authority over another account
  senderPolicy:    function (ctx) → { ok } | { ok: false },   // optional, asked only when the actor's mailbox set does not already cover MAIL FROM — for aliases and anything else resolved at request time. Fails CLOSED: a policy that throws or rejects refuses the sender
  maxLineBytes:    number,       // default 1 KiB
  maxMessageBytes: number,       // default 50 MiB
  maxRcptsPerMessage: number,    // default 100
  idleTimeoutMs:   number,       // default 5 minutes
  maxConnections:  number,       // default 1024 — listener-wide ceiling
  profile:         string,       // "strict" | "balanced" | "permissive"; default "strict"
  rateLimit:       b.mail.server.rateLimit handle | opts | false,  // connection and command
                                 // admission control for the listener, distinct from `auth`'s
                                 // own nested `rateLimit`, which budgets authentication
                                 // failures. `false` disables it. 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
  allowSmtpUtf8:   boolean,      // offer RFC 6531 SMTPUTF8; default false. Anything other than a
                                 // boolean raises mail-server-submission/bad-opts
  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.submission.domain_refused
  recipientPolicy: function (ctx) → { ok } | { ok: false },  // asked once per RCPT TO with
                                 // { actor, mailFrom, rcptTo, connectionId, remoteAddress, tls }.
                                 // Admits only on an exact `ok: true`, so a policy that throws or
                                 // rejects refuses the recipient
  tenantScope:     object,       // a b.agent.tenant.create() instance whose `check` runs against the
                                 // authenticated actor. One without a `.check` function raises
                                 // mail-server-submission/bad-tenant-scope at create, since a
                                 // malformed scope would refuse every auth as cross-tenant. Requires
                                 // agentTenantId, or create raises
                                 // mail-server-submission/no-agent-tenant-id
  agentTenantId:   string,       // the tenant this listener belongs to, which tenantScope compares
                                 // against the actor's; a mismatch emits
                                 // mail.server.submission.cross_tenant_refused
}

Build the submission 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.submission.create({
  tlsContext: tls,
  greeting:   "smtp.example.com Submission blamejs",
  auth: {
    mechanisms: ["PLAIN", "SCRAM-SHA-256"],
    verify: async function (mech, creds) {
      var actor = await myAuthService.verify(mech, creds);
      return actor ? { ok: true, actor: actor } : { ok: false };
    },
  },
  agent: b.mail.agent.create({ outboundSend: b.mail.send }),
});
await server.listen({ port: 587 });

Last updated 2026-10-12T14:21:56.879Z by seeder.