@blamejs/core 0.18.53 → 0.18.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/NOTICE +1 -1
  3. package/README.md +5 -5
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/ai-adverse-decision.js +18 -2
  6. package/lib/audit-sign.js +24 -5
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/codepoint-class.js +72 -0
  9. package/lib/cookies.js +7 -10
  10. package/lib/credential-hash.js +8 -1
  11. package/lib/crypto.js +7 -5
  12. package/lib/db-file-lifecycle.js +14 -3
  13. package/lib/db.js +505 -49
  14. package/lib/guard-auth.js +34 -11
  15. package/lib/guard-filename.js +41 -33
  16. package/lib/guard-html.js +10 -2
  17. package/lib/guard-list-unsubscribe.js +6 -1
  18. package/lib/guard-managesieve-command.js +73 -12
  19. package/lib/guard-regex.js +3 -5
  20. package/lib/guard-smtp-command.js +20 -4
  21. package/lib/guard-svg.js +6 -1
  22. package/lib/guard-yaml.js +60 -15
  23. package/lib/http-client.js +17 -3
  24. package/lib/mail-agent.js +29 -13
  25. package/lib/mail-arc-sign.js +40 -7
  26. package/lib/mail-auth.js +134 -22
  27. package/lib/mail-crypto-pgp.js +1 -1
  28. package/lib/mail-dkim.js +80 -11
  29. package/lib/mail-helo.js +10 -0
  30. package/lib/mail-rbl.js +10 -3
  31. package/lib/mail-send-deliver.js +151 -32
  32. package/lib/mail-server-imap.js +186 -89
  33. package/lib/mail-server-jmap.js +31 -4
  34. package/lib/mail-server-managesieve.js +198 -42
  35. package/lib/mail-server-mx.js +191 -38
  36. package/lib/mail-server-net.js +281 -1
  37. package/lib/mail-server-pop3.js +89 -41
  38. package/lib/mail-server-rate-limit.js +104 -6
  39. package/lib/mail-server-submission.js +183 -35
  40. package/lib/mail-server-tls.js +48 -3
  41. package/lib/mail-store.js +33 -11
  42. package/lib/mail.js +355 -17
  43. package/lib/mcp.js +11 -3
  44. package/lib/middleware/bearer-auth.js +6 -1
  45. package/lib/middleware/fetch-metadata.js +5 -1
  46. package/lib/middleware/headers.js +7 -10
  47. package/lib/middleware/require-mtls.js +8 -1
  48. package/lib/network-dns-resolver.js +71 -8
  49. package/lib/network-dns.js +26 -0
  50. package/lib/network-smtp-policy.js +42 -10
  51. package/lib/network-tls.js +18 -0
  52. package/lib/redact.js +13 -3
  53. package/lib/retention.js +22 -2
  54. package/lib/safe-mount-info.js +39 -6
  55. package/lib/safe-smtp.js +96 -1
  56. package/lib/safe-url.js +8 -2
  57. package/lib/self-update.js +4 -1
  58. package/lib/vendor/MANIFEST.json +12 -12
  59. package/lib/vendor/blamejs-pki.cjs +672 -75
  60. package/lib/watcher.js +31 -6
  61. package/lib/ws-client.js +17 -2
  62. package/lib/yaml-lex.js +55 -1
  63. package/package.json +1 -1
  64. package/sbom.cdx.json +6 -6
@@ -75,12 +75,14 @@
75
75
  * - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
76
76
  * - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
77
77
  * - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
78
- * - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC results + action (accept / quarantine / reject / defer) + gate mode
78
+ * - `mail.server.mx.envelope_verdict` — DATA-phase SPF/DKIM/DMARC/ARC results + action (accept / quarantine / reject / defer) + gate mode
79
79
  * - `mail.server.mx.envelope_error` — DATA-phase authentication pipeline failure or timeout (disposition follows onTemperror)
80
80
  * - `mail.server.mx.delivered` — agent.handoff ack
81
81
  * - `mail.server.mx.tls_handshake_failed` — handshake error
82
82
  * - `mail.server.mx.smtp_smuggling_detected` — CRLF.CRLF injection class
83
83
  * - `mail.server.mx.relay_refused` — open-relay attempt
84
+ * - `mail.server.mx.recipient_refused` — recipientPolicy said the mailbox is unavailable (550 5.1.1)
85
+ * - `mail.server.mx.recipient_policy_threw` — recipientPolicy failed; the recipient is deferred (451 4.7.1)
84
86
  *
85
87
  * ## What v1 does NOT ship
86
88
  *
@@ -114,11 +116,25 @@
114
116
  * `opts.helo` (HELO identity) evaluates at HELO/EHLO; `opts.rbl`
115
117
  * (connecting-IP DNS blocklist, evaluated once per connection) and
116
118
  * `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
117
- * RCPT TO and surface their verdicts on the `rcpt_to` event. The
119
+ * RCPT TO and surface their verdicts on the `rcpt_to` event.
120
+ *
121
+ * `opts.recipientPolicy` decides whether a mailbox on a local domain
122
+ * exists, and is the only way to answer "no such user" the way RFC
123
+ * 5321 §3.3 asks. It runs at RCPT TO, so the refusal costs one command
124
+ * rather than a whole message body: `{ ok: false, reason }` becomes
125
+ * 550 5.1.1 with the reason, and a throw becomes 451 4.7.1 (a
126
+ * directory that cannot be reached is not a verdict about the
127
+ * mailbox). Refusals charge the same per-IP recipient-failure budget
128
+ * as a relay refusal, because the 250-vs-550 difference is a
129
+ * mailbox-existence oracle. Unwired, every syntactically valid
130
+ * recipient on a local domain is accepted and the agent handoff is
131
+ * the operator's last chance to reject. The
132
+
118
133
  * message-authentication gate (`opts.guardEnvelope`) runs at DATA
119
134
  * completion through `b.mail.inbound.verify` — SPF (RFC 7208) on the
120
135
  * envelope identity, DKIM (RFC 6376) on the message bytes, DMARC
121
- * (RFC 9989) policy + alignment on the From-header domain — and in
136
+ * (RFC 9989) policy + alignment on the From-header domain, ARC
137
+ * (RFC 8617) on any chain a forwarder left behind — and in
122
138
  * enforce mode refuses before the agent handoff: 550 5.7.26
123
139
  * (RFC 7372) when the sender's published policy says reject, 550
124
140
  * 5.7.1 on the RFC 9989 §5.3.1 multi-From spoofing shape, 451 4.7.0
@@ -142,7 +158,6 @@
142
158
  var net = require("node:net");
143
159
  var lazyRequire = require("./lazy-require");
144
160
  var C = require("./constants");
145
- var bCrypto = require("./crypto");
146
161
  var numericBounds = require("./numeric-bounds");
147
162
  var safeAsync = require("./safe-async");
148
163
  var safeBuffer = require("./safe-buffer");
@@ -307,12 +322,14 @@ function _stripForgedAuthResults(messageBuf, authservId) {
307
322
  * agent: b.mail.agent, // optional delivery handoff
308
323
  * relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
309
324
  * localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
325
+ * recipientPolicy: function (ctx) → { ok } | { ok: false, reason }, // optional RCPT-time mailbox check; refuses 550 5.1.1, defers 451 when it throws
310
326
  * maxLineBytes: number, // default 1 KiB — per-command line cap
311
327
  * maxMessageBytes: number, // default 50 MiB — DATA body cap
312
328
  * maxRcptsPerMessage: number, // default 100 — per RFC 5321 §4.5.3.1.8
313
329
  * idleTimeoutMs: number, // default 5 minutes — RFC 5321 §4.5.3.2.7
330
+ * maxConnections: number, // default 1024 — listener-wide ceiling
314
331
  * profile: "strict" | "balanced" | "permissive", // gate posture cascade
315
- * guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC via b.mail.inbound.verify
332
+ * guardEnvelope: true | { // optional gate — DATA-phase SPF/DKIM/DMARC/ARC via b.mail.inbound.verify
316
333
  * mode?: "enforce" | "monitor", // default: enforce (monitor when profile is permissive)
317
334
  * onTemperror?: "defer" | "accept", // DNS temperror disposition; default "defer" (451 4.7.5)
318
335
  * authservId?: string, // RFC 8601 authserv-id; default localDomains[0]
@@ -348,12 +365,20 @@ function create(opts) {
348
365
  "output directly. Cert provisioning lives in b.acme (RFC 8555 + RFC 9773 ARI).");
349
366
  }
350
367
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
351
- ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
368
+ ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs", "maxConnections"],
352
369
  "mail.server.mx.", MailServerMxError, "mail-server-mx/bad-bound");
353
- if (opts.localDomains !== undefined &&
354
- (!Array.isArray(opts.localDomains) || opts.localDomains.length === 0)) {
370
+ // An EMPTY array is accepted and means "this server hosts no domains", which
371
+ // is a real state — a first boot before the first domain is added — and the
372
+ // only honest thing to do with it is refuse every recipient. Refusing it at
373
+ // construction left the operator with one spelling that started a server
374
+ // (omitting the option), and that spelling used to skip the relay check
375
+ // entirely, so the only way to get a listener was to get one that accepted
376
+ // everything. An allowlist that disappears when it is empty is a firewall
377
+ // rule set that opens when the last rule is deleted.
378
+ if (opts.localDomains !== undefined && !Array.isArray(opts.localDomains)) {
355
379
  throw new MailServerMxError("mail-server-mx/bad-opts",
356
- "mail.server.mx.create: localDomains must be a non-empty array if provided");
380
+ "mail.server.mx.create: localDomains must be an array of domain strings " +
381
+ "(an empty array means this server hosts no domains and refuses every recipient)");
357
382
  }
358
383
  if (opts.relayAllowedFor !== undefined && !Array.isArray(opts.relayAllowedFor)) {
359
384
  throw new MailServerMxError("mail-server-mx/bad-opts",
@@ -542,15 +567,17 @@ function create(opts) {
542
567
  function _handleConnection(socket) {
543
568
  // 421 4.7.0 — transient refusal; sender retries elsewhere or later.
544
569
  // RFC 5321 §3.8 + §4.5.4.2 (transient negative completion).
545
- var remoteAddress = mailServerNet.admitConnection(socket, rateLimit, _emit, {
570
+ var accepted = mailServerNet.acceptConnection(socket, {
571
+ rateLimit: rateLimit,
572
+ connections: connections,
573
+ emit: _emit,
546
574
  refusedEvent: "mail.server.mx.rate_limit_refused",
547
575
  refusalLine: "421 4.7.0 Too many connections from your IP\r\n",
576
+ idPrefix: "mxconn-",
548
577
  });
549
- if (remoteAddress === null) return;
550
- socket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
551
-
552
- var connectionId = "mxconn-" + bCrypto.generateToken(8); // connection-id length
553
- connections.add(socket);
578
+ if (accepted === null) return;
579
+ var remoteAddress = accepted.remoteAddress;
580
+ var connectionId = accepted.connectionId;
554
581
 
555
582
  // Backpressure observer — `_writeReply` flips `_bpEmitted` after
556
583
  // the first audit emission per socket to bound the audit volume.
@@ -580,6 +607,15 @@ function create(opts) {
580
607
  // message. Decode to string only for the per-command line parse.
581
608
  var lineBuffer = Buffer.alloc(0);
582
609
  var bodyCollector = null;
610
+ // Watches the DATA body for its terminator and the smuggling shape as bytes
611
+ // arrive, so neither screen re-reads what it has already seen. Lives exactly
612
+ // as long as bodyCollector.
613
+ var bodyScanner = null;
614
+ // The slow-loris byte-rate floor, measured over bounded windows so an early
615
+ // burst cannot buy credit for a slow tail.
616
+ var bodyRateWindow = mailServerNet.createBodyRateWindow(rateLimit);
617
+ // Every byte this connection has received, counted once at the wire funnel.
618
+ var wireBytes = 0;
583
619
  var inDataBody = false;
584
620
  // Async command pump: gates (HELO / RBL / greylist / envelope /
585
621
  // DMARC) may await DNS or a store, so command handling is async.
@@ -604,10 +640,9 @@ function create(opts) {
604
640
  _closeConnection(socket);
605
641
  });
606
642
 
607
- socket.on("close", function () {
608
- connClosed = true;
609
- connections.delete(socket);
610
- });
643
+ // The set entry and the rate-limit slot are released by trackConnection;
644
+ // this handler carries only the per-transaction flag the drain reads.
645
+ socket.on("close", function () { connClosed = true; });
611
646
 
612
647
  _emit("mail.server.mx.connect", {
613
648
  connectionId: state.id,
@@ -630,6 +665,12 @@ function create(opts) {
630
665
  // 421 path. `activeSock` is whichever socket is current (plaintext or
631
666
  // TLS) so the 421/close lands on the right transport.
632
667
  function _feedChunk(activeSock, chunk) {
668
+ // Every wire byte, counted once, on the single funnel both the plaintext
669
+ // and the post-STARTTLS socket feed. The rate window takes its baseline
670
+ // from this, so the measurement is "bytes since the transfer opened"
671
+ // rather than "bytes the body parser happened to see" — the distinction
672
+ // that let the sibling listener's count go flat across a window roll.
673
+ wireBytes += chunk.length;
633
674
  pumpChain = pumpChain.then(function () {
634
675
  if (connClosed) return undefined;
635
676
  return _ingestBytes(state, activeSock, chunk);
@@ -648,6 +689,27 @@ function create(opts) {
648
689
 
649
690
  // ---- Byte-level ingestion --------------------------------------------
650
691
  async function _ingestBytes(state, socket, chunk) {
692
+ // The body-rate floor is enforced HERE, on every inbound byte, rather
693
+ // than inside the DATA handler below. A check reached only from a body
694
+ // handler is one the peer chooses whether to reach: on the sibling
695
+ // listener the same floor was skipped first by using BDAT, then by a
696
+ // zero-length chunk, then by interleaving NOOP, each of which resets the
697
+ // socket idle timer without passing through a body handler. What a peer
698
+ // cannot do is hold the connection without sending bytes, and every byte
699
+ // arrives here.
700
+ if (inDataBody && bodyRateWindow.starved(wireBytes, Date.now())) {
701
+ _emit("mail.server.mx.data_refused",
702
+ { connectionId: state.id, reason: "body-rate-below-floor",
703
+ minBytesPerSecond: rateLimit.minBytesPerSecond() }, "denied");
704
+ _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL,
705
+ "4.7.0 Message body arriving below the minimum rate; closing connection");
706
+ _resetTransaction(state);
707
+ inDataBody = false;
708
+ bodyCollector = null;
709
+ bodyScanner = null;
710
+ _closeConnection(socket);
711
+ return;
712
+ }
651
713
  if (inDataBody) {
652
714
  // DATA body — accumulate via boundedChunkCollector, watch for
653
715
  // canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
@@ -662,13 +724,25 @@ function create(opts) {
662
724
  _resetTransaction(state);
663
725
  inDataBody = false;
664
726
  bodyCollector = null;
727
+ bodyScanner = null;
665
728
  return;
666
729
  }
667
- var collected = bodyCollector.result();
668
- // Smuggling detector bare LF dot-line in body before the
669
- // CRLF dot terminator. Refuse the whole transaction; emit
670
- // smuggling-detected audit.
671
- if (guardSmtpCommand.detectBodySmuggling(collected)) {
730
+ // Scanned INCREMENTALLY — only this chunk plus a four-byte overlap.
731
+ // Re-deriving the whole accumulated body per chunk (`result()` is a
732
+ // fresh concat of everything received so far) and scanning it twice
733
+ // made acceptance quadratic in the message size. The byte cap bounds
734
+ // BYTES, not processor time, so a message well inside maxMessageBytes
735
+ // still cost 4949 ms at 8 MiB against 143 ms at 1 MiB — and on this
736
+ // listener that is reachable unauthenticated. `result()` is now called
737
+ // ONCE, when the terminator is found.
738
+ //
739
+ // The slow-loris floor is NOT applied here — it runs at the top of
740
+ // _ingestBytes, where every inbound byte passes whatever command it
741
+ // belongs to.
742
+ var seen = bodyScanner.push(chunk);
743
+ // Smuggling detector — bare LF dot-line in body before the CRLF dot
744
+ // terminator. Refuse the whole transaction; emit a smuggling audit.
745
+ if (seen.smuggling) {
672
746
  _emit("mail.server.mx.smtp_smuggling_detected",
673
747
  { connectionId: state.id, mailFrom: state.mailFrom, rcptCount: state.rcpts.length },
674
748
  "denied");
@@ -677,14 +751,15 @@ function create(opts) {
677
751
  _resetTransaction(state);
678
752
  inDataBody = false;
679
753
  bodyCollector = null;
754
+ bodyScanner = null;
680
755
  return;
681
756
  }
682
757
  // Canonical \r\n.\r\n terminator?
683
- var endIdx = safeSmtp.findDotTerminator(collected);
684
- if (endIdx !== -1) {
685
- var body = collected.subarray(0, endIdx);
758
+ if (seen.terminatorAt !== -1) {
759
+ var body = bodyCollector.result().subarray(0, seen.terminatorAt);
686
760
  inDataBody = false;
687
761
  bodyCollector = null;
762
+ bodyScanner = null;
688
763
  await _finalizeDataBody(state, socket, body);
689
764
  }
690
765
  return;
@@ -851,6 +926,7 @@ function create(opts) {
851
926
  // shared upgradeSocket helper (b.mail.server.tls.upgradeSocket).
852
927
  lineBuffer = Buffer.alloc(0);
853
928
  bodyCollector = null;
929
+ bodyScanner = null;
854
930
  inDataBody = false;
855
931
  mailServerTls.upgradeSocket({
856
932
  plainSocket: socket,
@@ -986,17 +1062,21 @@ function create(opts) {
986
1062
  }
987
1063
  // Local-domain check — refuse non-local recipients unless the
988
1064
  // operator explicitly allowed relay for this scope.
989
- if (localDomains.length > 0) {
990
- if (localDomains.indexOf(rcptDomain) === -1 &&
991
- !_isRelayAllowed(state.remoteAddress, rcpt)) {
992
- rateLimit.noteRcptFailure(state.remoteAddress);
993
- _trackRefusedRcpt(state, rcpt, "relay-denied");
994
- _emit("mail.server.mx.relay_refused",
995
- { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
996
- remoteAddress: state.remoteAddress }, "denied");
997
- _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
998
- return;
999
- }
1065
+ //
1066
+ // Run UNCONDITIONALLY. This used to sit inside `if (localDomains.length
1067
+ // > 0)`, so a server hosting no domains ran no check at all and accepted
1068
+ // every recipient. An empty hosted set now refuses everything naturally,
1069
+ // which is what an empty allowlist has to mean; `relayAllowedFor` is
1070
+ // still the way to permit a scope deliberately.
1071
+ if (localDomains.indexOf(rcptDomain) === -1 &&
1072
+ !_isRelayAllowed(state.remoteAddress, rcpt)) {
1073
+ rateLimit.noteRcptFailure(state.remoteAddress);
1074
+ _trackRefusedRcpt(state, rcpt, "relay-denied");
1075
+ _emit("mail.server.mx.relay_refused",
1076
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
1077
+ remoteAddress: state.remoteAddress }, "denied");
1078
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL, "5.7.1 Relaying denied");
1079
+ return;
1000
1080
  }
1001
1081
  // RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
1002
1082
  // IP. The verdict is per-connection, so it's evaluated once and
@@ -1035,6 +1115,60 @@ function create(opts) {
1035
1115
  return;
1036
1116
  }
1037
1117
  }
1118
+ // Operator-supplied recipient policy — the only place a listener can
1119
+ // answer "no such mailbox" the way RFC 5321 §3.3 asks. Without it, a
1120
+ // local domain accepted every local part and the application first met
1121
+ // the recipient at agent.handoff, after 354 and after the whole message:
1122
+ // from there the choices were 250 (tell the peer it arrived, then owe a
1123
+ // DSN) or 451 (tell a peer holding a permanent condition to retry
1124
+ // forever). Neither is a refusal. Same shape as the submission
1125
+ // listener's hook: `{ ok: true }` accepts, `{ ok: false, reason }`
1126
+ // refuses. Unwired, every syntactically valid recipient on a local
1127
+ // domain is accepted, exactly as before.
1128
+ if (typeof opts.recipientPolicy === "function") {
1129
+ var rcptVerdictPolicy;
1130
+ try {
1131
+ rcptVerdictPolicy = await opts.recipientPolicy({
1132
+ mailFrom: state.mailFrom,
1133
+ rcptTo: rcpt,
1134
+ connectionId: state.id,
1135
+ remoteAddress: state.remoteAddress,
1136
+ tls: state.tls,
1137
+ heloName: state.heloName || null,
1138
+ });
1139
+ } catch (policyErr) {
1140
+ // The operator's directory being unreachable is not a verdict about
1141
+ // this mailbox. A 550 here would permanently reject mail for a
1142
+ // legitimate recipient because a lookup failed, so it defers.
1143
+ _emit("mail.server.mx.recipient_policy_threw",
1144
+ { connectionId: state.id, rcptTo: rcpt,
1145
+ error: (policyErr && policyErr.message) || String(policyErr) }, "failure");
1146
+ _writeReply(socket, REPLY_451_LOCAL_ERROR,
1147
+ "4.7.1 Recipient policy temporarily unavailable");
1148
+ return;
1149
+ }
1150
+ if (!rcptVerdictPolicy || rcptVerdictPolicy.ok !== true) {
1151
+ // The 250-vs-550 difference is a mailbox-existence oracle, so a
1152
+ // policy refusal charges the same per-IP recipient-failure budget
1153
+ // the relay refusal does. Without that, wiring this hook would hand
1154
+ // a scanner a free enumeration channel the listener did not have.
1155
+ rateLimit.noteRcptFailure(state.remoteAddress);
1156
+ _trackRefusedRcpt(state, rcpt, "recipient-policy");
1157
+ _emit("mail.server.mx.recipient_refused",
1158
+ { connectionId: state.id, mailFrom: state.mailFrom, rcptTo: rcpt,
1159
+ reason: (rcptVerdictPolicy && rcptVerdictPolicy.reason) || "policy-refused",
1160
+ remoteAddress: state.remoteAddress }, "denied");
1161
+ // The reason goes onto a line-oriented reply, and a directory wrapper
1162
+ // routinely quotes the address it looked up — which the peer chose.
1163
+ // A CR or LF in it would end the 550 early and let the remainder be
1164
+ // read as a second, forged reply. The refusal still happens; only the
1165
+ // prose falls back.
1166
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
1167
+ "5.1.1 " + mailServerNet.replyTextOrFallback(
1168
+ rcptVerdictPolicy && rcptVerdictPolicy.reason, "Mailbox unavailable"));
1169
+ return;
1170
+ }
1171
+ }
1038
1172
  state.rcpts.push(rcpt);
1039
1173
  _emit("mail.server.mx.rcpt_to",
1040
1174
  { connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length,
@@ -1060,6 +1194,8 @@ function create(opts) {
1060
1194
  sizeCode: "mail-server-mx/body-too-large",
1061
1195
  sizeMessage: "DATA body exceeded maxMessageBytes (" + maxMessageBytes + ")",
1062
1196
  });
1197
+ bodyScanner = safeSmtp.createBodyScanner();
1198
+ bodyRateWindow.start(Date.now(), wireBytes);
1063
1199
  }
1064
1200
 
1065
1201
  async function _finalizeDataBody(state, socket, body) {
@@ -1143,6 +1279,12 @@ function create(opts) {
1143
1279
  spf: inboundVerdict.spf.result,
1144
1280
  dkim: dkimSummary,
1145
1281
  dmarc: inboundVerdict.dmarc.result,
1282
+ arc: inboundVerdict.arc && inboundVerdict.arc.chainStatus,
1283
+ // The status alone cannot tell an operator reading the audit
1284
+ // whether a chain failed because a seal was bad or because a
1285
+ // resolver was down — the two want opposite responses.
1286
+ arcReason: inboundVerdict.arc && inboundVerdict.arc.reason,
1287
+ arcTransient: !!(inboundVerdict.arc && inboundVerdict.arc.transient),
1146
1288
  action: envAction,
1147
1289
  mode: envelopeGate.mode,
1148
1290
  }, (envAction === "reject" || envAction === "defer") ? "denied" : "success");
@@ -1185,6 +1327,16 @@ function create(opts) {
1185
1327
  spf: inboundVerdict.spf,
1186
1328
  dkim: inboundVerdict.dkim,
1187
1329
  dmarc: inboundVerdict.dmarc,
1330
+ // The ARC chain sits beside the other three because it was
1331
+ // computed with them. It used to reach the delivered message as an
1332
+ // `arc=` token and the audit event as a status, and stop there —
1333
+ // so a consumer wanting to act on it re-parsed a header the
1334
+ // pipeline had just written. The header is also lossier than the
1335
+ // verdict: RFC 8601 has one `arc=fail` token, while the verdict
1336
+ // separates a chain that is structurally incomplete from one whose
1337
+ // seal did not verify, and only the second says anything about the
1338
+ // sender.
1339
+ arc: inboundVerdict.arc,
1188
1340
  from: inboundVerdict.from,
1189
1341
  action: envAction,
1190
1342
  mode: envelopeGate.mode,
@@ -1298,6 +1450,7 @@ function create(opts) {
1298
1450
  // explicit 0 (only an OMITTED port falls back to the default).
1299
1451
  var _tcpListener = mailServerNet.createTcpListener(net, {
1300
1452
  defaultPort: 25, // SMTP MX port (IANA)
1453
+ maxConnections: opts.maxConnections,
1301
1454
  handleConnection: _handleConnection,
1302
1455
  errorFactory: function (code, message) { return new MailServerMxError("mail-server-mx/" + code, message); },
1303
1456
  emit: _emit,