@blamejs/core 0.13.10 → 0.13.12

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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.12 (2026-05-27) — **Inbound MX listener now runs the connection-level gate cascade it documented — HELO identity, DNS blocklist, and greylisting.** b.mail.server.mx.create documented helo / rbl / greylist gate options, but the listener never invoked them — an operator who wired them got silent acceptance of mail those gates would have rejected. They are now wired into the live SMTP state machine: the HELO-identity gate evaluates at HELO/EHLO and refuses a spoofed or malformed identity with 550; the DNS-blocklist gate evaluates the connecting IP once per connection and refuses a listed source with 554; the greylisting gate defers a first-seen (ip, sender, recipient) tuple with a 450 tempfail so legitimate senders retry and pass. Each gate is skipped when the operator doesn't wire it. Because these gates do DNS and store lookups, the per-connection command pump was reworked to process commands asynchronously and strictly in arrival order, so pipelined commands (RFC 2920) cannot overtake a gate still resolving and the existing SMTP-smuggling and STARTTLS-stripping defenses are unchanged. The message-authentication gate (SPF/DKIM/DMARC alignment via b.guardEnvelope) needs the inbound SPF + DKIM verification results as inputs; that inbound-auth pipeline lands as a follow-up, and the documentation no longer implies that gate is active today. **Added:** *HELO-identity / RBL / greylist gates wired into `b.mail.server.mx`* — When wired, `opts.helo` (FCrDNS / HELO-shape / self-name checks) refuses a bad HELO identity at HELO/EHLO with 550; `opts.rbl` refuses a connecting IP found on a DNS blocklist with 554 (evaluated once per connection); `opts.greylist` defers a first-seen (ip, sender, recipient) tuple with 450 4.7.1. Their verdicts surface on the `rcpt_to` event (`rblListed`, `greylist`) and the `helo` event (`heloVerdict`), with dedicated `helo_gate_refused` / `rbl_refused` / `greylist_deferred` audit events. A gate the operator doesn't supply is skipped, never synthesized. **Changed:** *MX command pump processes commands asynchronously and in arrival order* — Gate evaluation involves DNS and store lookups, so the per-connection command pump now awaits each command before the next. Pipelined commands are serialized so a gate resolving cannot let a later command answer ahead of an earlier one; reply ordering, the bare-LF SMTP-smuggling refusal, and the STARTTLS-stripping defense are unchanged. No change to the listener's external behaviour when no gates are wired. **Deprecated:** *SPF/DKIM/DMARC-alignment gate documentation corrected to match what is active* — The `envelope` (SPF/DKIM/DMARC alignment) and `dmarc` gate options were documented as wireable but require inbound SPF + DKIM verification results the listener does not yet produce. They are removed from the documented option set until the inbound-authentication pipeline (composing `b.mail.spf` + `b.mail.dmarc` + DKIM verification) lands; run those checks on the delivered message via the agent handoff in the meantime.
12
+
13
+ - v0.13.11 (2026-05-27) — **Test-suite reliability: replaced fixed-delay waits in the rate-limiter and scheduler suites with condition polling.** No runtime behaviour changes. The rate-limiter, scheduler, and websocket-channel test suites waited for asynchronous work to settle by draining a fixed number of event-loop ticks before asserting. Under heavily parallel CI that budget was occasionally too short, so an assertion read state before the async work (a cluster-backend counter update, a scheduler tick-claim) had landed — an intermittent failure unrelated to the code under test. Those waits now poll the observable condition (helpers.waitUntil) and exit as soon as it holds, with a generous upper bound, so they pass quickly on fast machines and reliably under load. A build gate is added so the fixed-tick-drain shape cannot be reintroduced. **Fixed:** *Flaky fixed-budget waits in the rate-limiter / scheduler / sandbox test suites made contention-tolerant* — The rate-limit-cluster and scheduler-exactly-once suites drained a fixed count of event-loop ticks before asserting on asynchronously-updated state; under contended CI the budget could expire before the work settled, producing intermittent failures. They now wait on the actual observable condition (a written response, a settled counter). The sandbox suite's success-path cases gave the worker a 5 s execution budget that cold worker-thread startup under heavily parallel Windows CI could just exceed; those are raised to the framework's 10 s ceiling. Affects test code only — no change to shipped framework behaviour. The unused tick-drain helper in the websocket-channel suite was removed. **Detectors:** *Build gate rejects the fixed-tick-drain wait shape in tests* — A new test-suite lint rule flags the counted microtask/tick-drain idiom (reassigning a promise to its own `.then()` in a loop to wait a fixed number of ticks), the sibling of the existing fixed-`setTimeout`-sleep rule. A single event-loop yield is unaffected; only the drain-as-wait shape is rejected, directing the wait to condition polling instead.
14
+
11
15
  - v0.13.10 (2026-05-27) — **Documented-but-inert options wired up, a non-existent CVE reference removed, and a silent iCalendar cap-bypass fixed.** A sweep for places where a documented option or citation did not match what the code does. The most operator-relevant fix: b.calendar.fromIcal documented a safeIcalOpts option that forwards parser caps (byte size, RRULE limits, nesting depth) to b.safeIcal.parse, but the value was never forwarded — so an operator who set tight caps through it got the default profile instead, silently. That is corrected; the nested options now reach the parser. b.archive.read.zip documented an AbortSignal option that was never honored; it now aborts the read at the entry boundary. b.auth.fal documented a bearerOnly alias that had no effect; it now forces the no-proof-of-possession path and refuses the contradictory combination of bearerOnly:true with a holder-of-key binding. Separately, the auth verification paths cited CVE-2026-23993 (13 places) for the "reject an unknown alg before key lookup" guard — that CVE id does not exist (the registry has no record of it); the citation is replaced with the weakness class (CWE-347 / CWE-757) and the real, verifiable neighboring CVEs. The circuit-breaker error-code note that promised a rename "in v0.10" is corrected to the actual plan (v1.0), and the build gate that catches overdue version promises now also catches two-part version numbers. **Changed:** *`b.auth.fal` `bearerOnly` is now a real alias and refuses contradictions* — `bearerOnly: true` now forces the no-proof-of-possession path (equivalent to `hokBinding: null`), as documented. Passing `bearerOnly: true` together with a non-null `hokBinding` is a contradictory assurance request and is now refused at the call rather than silently resolved one way. **Fixed:** *`b.calendar.fromIcal` now forwards `safeIcalOpts` to the parser* — The documented `safeIcalOpts` option (parser caps: max bytes, RRULE COUNT/BYxxx limits, nesting depth) was not being passed to `b.safeIcal.parse` — when supplied under the documented nested key it was silently ignored and the parser ran with its default profile. Both forms now reach the parser: the documented nested `{ safeIcalOpts: { ... } }` and the top-level `{ profile, ... }` that earlier releases accepted, with the nested form winning on conflict. No caller regresses. · *`b.archive.read.zip` honors the documented `signal` (AbortSignal)* — The `signal` option was documented but never read. A large or slow archive read can now be aborted cooperatively — the reader checks the signal at each entry boundary (`inspect`, `entries`, `extractEntries`, `extract`) and rejects with an `archive-read/aborted` error. · *Removed a non-existent CVE reference from the JWT/JWE verification paths* — The "reject an unknown/unsupported `alg` before any key lookup" guard in `b.auth.jwt.verifyExternal`, `b.auth.oauth.verifyIdToken`, `b.auth.oid4vci`, and `b.auth.sd-jwt-vc` cited a CVE id that the registry has no record of. The behaviour is unchanged; the citation is now the weakness class it defends (CWE-347 improper signature verification / CWE-757 algorithm downgrade) alongside the real, verifiable alg-confusion / JWE-bypass CVEs already cited beside it. **Detectors:** *Overdue-version-promise gate now catches two-part version numbers* — The build gate that flags a deferral whose promised landing version has already shipped previously matched only three-part versions (`vN.N.N`); a two-part promise (`vN.N`) slipped past it. It now matches both. The `b.circuitBreaker` `CIRCUIT_OPEN` error-code note that pointed at a passed version is corrected to its actual plan (rename at v1.0, with a deprecation warning a minor ahead).
12
16
 
13
17
  - v0.13.9 (2026-05-26) — **Corrected CVE citations in source threat annotations + a build gate that refuses malformed CVE identifiers.** Several source-comment threat annotations cited CVE identifiers that were rejected by the numbering authority (never assigned to a real issue), attributed to the wrong product, or structurally malformed (a placeholder with a non-numeric sequence). The annotated defenses are unchanged — every cap, refusal, and constant-time comparison behaves exactly as before; only the reference labels were corrected, each to a verifiable CVE or to the underlying weakness class (CWE / RFC) where no single CVE fits. Notable corrections: the S/MIME SHA-1 / MD5 certificate-signature refusal now cites the SHAttered collision and RFC 8551 §2.5 instead of a rejected candidate id; decompression-output caps cite CWE-409 and CVE-2025-0725 instead of a fabricated placeholder; the iCalendar RRULE / nesting / byte caps describe the calendar-bomb recursion-DoS class instead of an unrelated SSRF advisory; and the SAML signature-wrapping (XSW) defense now cites the actively-exploited CVE-2024-45409 (ruby-saml, CVSS 10.0) and CVE-2025-25291 / -25292 that the duplicate-element refusal defeats. A new build-time detector refuses any CVE token whose sequence number is not all-numeric, so a placeholder identifier can never reach a release again. **Fixed:** *Corrected rejected / misattributed / malformed CVE references in source threat annotations* — Threat-annotation comments across the mail, crypto, auth, guard, and safe modules carried CVE identifiers that were rejected by the CVE numbering authority, attributed to the wrong product, or written as non-numeric placeholders. Each was corrected to a verifiable CVE or to the weakness class (CWE / RFC) it defends. No runtime behaviour changed — the defenses these comments describe are unchanged. The S/MIME certificate check's SHA-1 / MD5 refusal message now names the SHAttered collision and RFC 8551 §2.5; the SAML XSW defense now names CVE-2024-45409 and CVE-2025-25291 / -25292. **Detectors:** *`malformed-cve-identifier` — refuses structurally-invalid CVE tokens at build time* — A CVE identifier's sequence number is always numeric (`CVE-<year>-<digits>`). The new detector refuses any CVE token whose post-year segment contains a letter — the placeholder shape that lets a fabricated reference slip past review. It cannot verify that a well-formed id is real or correctly attributed (that stays a review responsibility), but it makes the structurally-invalid class impossible to ship.
package/README.md CHANGED
@@ -169,7 +169,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
169
169
  - **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
170
170
  - **Mail (outbound delivery)** — turnkey MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
171
171
  - **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
172
- - **Mail server listeners** — RFC 5321 MX inbound (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
172
+ - **Mail server listeners** — RFC 5321 MX inbound with connection-level gate cascade (HELO identity / DNS blocklist / greylisting) (`b.mail.server.mx`), RFC 6409 submission with SASL + identity-binding (`b.mail.server.submission`), RFC 9051 IMAP4rev2 with CONDSTORE / QRESYNC / NOTIFY / METADATA / CATENATE (`b.mail.server.imap`), RFC 8620 + RFC 8621 JMAP Core + Mail over HTTP/SSE/WebSocket (`b.mail.server.jmap`), POP3 (`b.mail.server.pop3`), ManageSieve (`b.mail.server.managesieve`)
173
173
  - **JMAP EmailSubmission reference** — composes `b.mail.send.deliver` to land the RFC 8621 §7.5 surface end-to-end (`b.mail.server.jmap.emailSubmissionSetHandler`)
174
174
  - **Mail crypto** — PQC-first S/MIME via CMS (`b.mail.crypto.cms`) + OpenPGP encrypt/decrypt + WKD key discovery with IDN-homograph defense (`b.mail.crypto.pgp`)
175
175
  - **Mail-stack agent** — multi-threaded worker pool + queue dispatch + sealed mail-store backed by SQLite FTS5 (`b.mail.agent`, `b.mailStore`)
@@ -67,9 +67,11 @@
67
67
  *
68
68
  * - `mail.server.mx.connect` — IP, TLS state, FCrDNS hostname
69
69
  * - `mail.server.mx.helo` — HELO greeting, helo-gate verdict
70
- * - `mail.server.mx.mail_from` sender, SPF verdict, alignment verdict
71
- * - `mail.server.mx.rcpt_to` recipient, RBL verdict, greylist verdict
72
- * - `mail.server.mx.data_accepted` message size, DKIM verdict, DMARC verdict
70
+ * - `mail.server.mx.helo_gate_refused` HELO identity refused (gate action)
71
+ * - `mail.server.mx.mail_from` sender address
72
+ * - `mail.server.mx.rcpt_to` recipient, rblListed flag, greylist action
73
+ * - `mail.server.mx.rbl_refused` — connecting IP on a DNS blocklist (zones)
74
+ * - `mail.server.mx.greylist_deferred` — (ip, from, rcpt) first-seen 450 deferral
73
75
  * - `mail.server.mx.data_refused` — refusal reason + SMTP code (5xx vs 4xx)
74
76
  * - `mail.server.mx.delivered` — agent.handoff ack
75
77
  * - `mail.server.mx.tls_handshake_failed` — handshake error
@@ -100,19 +102,30 @@
100
102
  *
101
103
  * Every gate is a primitive that already exists. The MX slice is a
102
104
  * state-machine + wire-protocol coordinator — no new crypto, no
103
- * new parsing, no new RFC-layer primitives. If a gate isn't ready
104
- * (e.g. operator hasn't wired `b.mail.auth.dmarc`), the listener
105
- * skips that phase with an audit note rather than synthesizing a
106
- * verdict.
105
+ * new parsing, no new RFC-layer primitives. When the operator
106
+ * doesn't wire a gate (e.g. omits `opts.greylist`), the listener
107
+ * skips that phase rather than synthesizing a verdict.
108
+ *
109
+ * Connection-level gates are wired into the live state machine:
110
+ * `opts.helo` (HELO identity) evaluates at HELO/EHLO; `opts.rbl`
111
+ * (connecting-IP DNS blocklist, evaluated once per connection) and
112
+ * `opts.greylist` ((ip, from, rcpt) first-seen deferral) evaluate at
113
+ * RCPT TO and surface their verdicts on the `rcpt_to` event. The
114
+ * message-authentication gates (`b.guardEnvelope` SPF/DKIM/DMARC
115
+ * alignment) require the inbound SPF + DKIM verification results as
116
+ * inputs; that inbound-auth pipeline composes `b.mail.spf` +
117
+ * `b.mail.dmarc` + DKIM verification and lands as a follow-up, at
118
+ * which point the DATA-phase envelope/DMARC gate wires in. Until
119
+ * then operators run those checks on the delivered message via the
120
+ * agent handoff.
107
121
  *
108
122
  * @card
109
123
  * Inbound SMTP / MX listener. RFC 5321 state machine with SMTP-
110
124
  * smuggling defense baked into the wire-protocol layer (RFC 5321
111
125
  * §2.3.8 + CVE-2023-51764 / 51765 / 51766), open-relay refusal by
112
126
  * default, STARTTLS-stripping defense (CVE-2021-38371), and the
113
- * framework's mail-gate cascade (HELO / RBL / greylist /
114
- * guardEnvelope / DMARC / safeMime / guardEmail) running at the
115
- * appropriate phase.
127
+ * connection-level gate cascade (HELO identity / RBL / greylist)
128
+ * running at the appropriate phase.
116
129
  */
117
130
 
118
131
  var net = require("node:net");
@@ -149,6 +162,7 @@ var REPLY_221_BYE = "221";
149
162
  var REPLY_250_OK = "250";
150
163
  var REPLY_354_START_INPUT = "354";
151
164
  var REPLY_421_SERVICE_NOT_AVAIL = "421"; // allow:raw-byte-literal — SMTP transient code
165
+ var REPLY_450_MAILBOX_BUSY = "450"; // allow:raw-byte-literal — SMTP transient code (greylist tempfail)
152
166
  var REPLY_451_LOCAL_ERROR = "451"; // allow:raw-byte-literal — SMTP transient code
153
167
  var REPLY_452_INSUFFICIENT_STG = "452"; // allow:raw-byte-literal — SMTP transient code
154
168
  var REPLY_500_SYNTAX = "500"; // allow:raw-byte-literal — SMTP permanent code
@@ -177,11 +191,9 @@ var RE_SIZE = /SIZE=(\d+)/i;
177
191
  * @opts
178
192
  * tlsContext: TlsContext, // required — b.network.tls.context() output (no implicit plaintext)
179
193
  * greeting: string, // default "blamejs ESMTP" — HELO/EHLO 220-line banner
180
- * helo: b.mail.helo, // optional gate
181
- * rbl: b.mail.rbl, // optional gate
182
- * greylist: b.mail.greylist, // optional gate
183
- * envelope: b.guardEnvelope, // optional gate (SPF/DKIM alignment)
184
- * dmarc: b.mail.auth.dmarc, // optional gate
194
+ * helo: b.mail.helo, // optional gate — HELO identity (FCrDNS / shape / self-name)
195
+ * rbl: b.mail.rbl.create(…), // optional gate — DNS blocklist on the connecting IP
196
+ * greylist: b.mail.greylist.create(…), // optional gate — defer first-seen (ip, from, rcpt)
185
197
  * agent: b.mail.agent, // optional delivery handoff
186
198
  * relayAllowedFor: [{ cidr, scope }], // operator-explicit relay allowlist; default [] = MX-only
187
199
  * localDomains: [string], // RCPT TO local-domain allowlist (refuse non-local with 550 5.7.1)
@@ -199,7 +211,6 @@ var RE_SIZE = /SIZE=(\d+)/i;
199
211
  * helo: b.mail.helo,
200
212
  * rbl: b.mail.rbl.create({ providers: ["zen.spamhaus.org"] }),
201
213
  * greylist: b.mail.greylist.create({ store: greylistStore }),
202
- * envelope: b.guardEnvelope,
203
214
  * agent: b.mail.agent.create({ store: mailStore }),
204
215
  * localDomains: ["example.com"],
205
216
  * });
@@ -367,6 +378,15 @@ function create(opts) {
367
378
  var lineBuffer = Buffer.alloc(0);
368
379
  var bodyCollector = null;
369
380
  var inDataBody = false;
381
+ // Async command pump: gates (HELO / RBL / greylist / envelope /
382
+ // DMARC) may await DNS or a store, so command handling is async.
383
+ // `pumpChain` FIFO-serializes per-chunk processing so a gate
384
+ // resolving cannot let a later pipelined command (RFC 2920) jump
385
+ // ahead of an earlier one — reply ordering + the per-command
386
+ // smuggling defenses stay intact. `connClosed` short-circuits any
387
+ // chunk queued before a teardown.
388
+ var pumpChain = Promise.resolve();
389
+ var connClosed = false;
370
390
 
371
391
  socket.setTimeout(idleTimeoutMs);
372
392
  socket.on("timeout", function () {
@@ -382,6 +402,7 @@ function create(opts) {
382
402
  });
383
403
 
384
404
  socket.on("close", function () {
405
+ connClosed = true;
385
406
  connections.delete(socket);
386
407
  });
387
408
 
@@ -395,20 +416,35 @@ function create(opts) {
395
416
  // 220 banner — RFC 5321 §3.1.
396
417
  _writeReply(socket, REPLY_220_READY, greeting + " ready");
397
418
 
398
- socket.on("data", function (chunk) {
399
- try { _ingestBytes(state, socket, chunk); }
400
- catch (err) {
419
+ // Feed a chunk into the per-connection command pump. Chains each
420
+ // chunk behind the previous one's full (async) processing so command
421
+ // handlers + their gates run strictly in arrival order. Used by BOTH
422
+ // the plaintext `socket.on("data")` path AND the post-STARTTLS
423
+ // TLSSocket onData path — otherwise gate awaits on the upgraded
424
+ // socket would overlap later TLS chunks (the default strict/balanced
425
+ // profiles require STARTTLS before MAIL, so the gates run there) and
426
+ // async gate rejections would go unhandled instead of producing the
427
+ // 421 path. `activeSock` is whichever socket is current (plaintext or
428
+ // TLS) so the 421/close lands on the right transport.
429
+ function _feedChunk(activeSock, chunk) {
430
+ pumpChain = pumpChain.then(function () {
431
+ if (connClosed) return undefined;
432
+ return _ingestBytes(state, activeSock, chunk);
433
+ }).catch(function (err) {
434
+ if (connClosed) return;
401
435
  _emit("mail.server.mx.handler_threw",
402
436
  { connectionId: state.id, error: (err && err.message) || String(err) },
403
437
  "failure");
404
- try { _writeReply(socket, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
438
+ try { _writeReply(activeSock, REPLY_421_SERVICE_NOT_AVAIL, "4.3.0 Server error"); }
405
439
  catch (_e) { /* socket already gone */ }
406
- _closeConnection(socket);
407
- }
408
- });
440
+ _closeConnection(activeSock);
441
+ });
442
+ }
443
+
444
+ socket.on("data", function (chunk) { _feedChunk(socket, chunk); });
409
445
 
410
446
  // ---- Byte-level ingestion --------------------------------------------
411
- function _ingestBytes(state, socket, chunk) {
447
+ async function _ingestBytes(state, socket, chunk) {
412
448
  if (inDataBody) {
413
449
  // DATA body — accumulate via boundedChunkCollector, watch for
414
450
  // canonical "\r\n.\r\n" terminator only. Bare-LF dot terminator
@@ -444,9 +480,9 @@ function create(opts) {
444
480
  var endIdx = safeSmtp.findDotTerminator(collected);
445
481
  if (endIdx !== -1) {
446
482
  var body = collected.subarray(0, endIdx);
447
- _finalizeDataBody(state, socket, body);
448
483
  inDataBody = false;
449
484
  bodyCollector = null;
485
+ await _finalizeDataBody(state, socket, body);
450
486
  }
451
487
  return;
452
488
  }
@@ -464,12 +500,13 @@ function create(opts) {
464
500
  while ((crlf = lineBuffer.indexOf(crlfNeedle)) !== -1) {
465
501
  var line = lineBuffer.subarray(0, crlf).toString("utf8");
466
502
  lineBuffer = lineBuffer.subarray(crlf + 2);
467
- _handleCommand(state, socket, line);
503
+ await _handleCommand(state, socket, line);
468
504
  if (inDataBody) return;
505
+ if (connClosed) return;
469
506
  }
470
507
  }
471
508
 
472
- function _handleCommand(state, socket, line) {
509
+ async function _handleCommand(state, socket, line) {
473
510
  // Per-line guard — refuse bare LF / NUL / C0 / DEL / oversize
474
511
  // BEFORE state-machine dispatch.
475
512
  try {
@@ -494,16 +531,16 @@ function create(opts) {
494
531
  switch (verb) {
495
532
  case "EHLO":
496
533
  case "HELO":
497
- _handleEhlo(state, socket, line, verb);
534
+ await _handleEhlo(state, socket, line, verb);
498
535
  return;
499
536
  case "STARTTLS":
500
537
  _handleStartTls(state, socket);
501
538
  return;
502
539
  case "MAIL":
503
- _handleMailFrom(state, socket, line);
540
+ await _handleMailFrom(state, socket, line);
504
541
  return;
505
542
  case "RCPT":
506
- _handleRcptTo(state, socket, line);
543
+ await _handleRcptTo(state, socket, line);
507
544
  return;
508
545
  case "DATA":
509
546
  _handleData(state, socket);
@@ -531,7 +568,7 @@ function create(opts) {
531
568
  }
532
569
 
533
570
  // ---- EHLO / HELO ------------------------------------------------------
534
- function _handleEhlo(state, socket, line, verb) {
571
+ async function _handleEhlo(state, socket, line, verb) {
535
572
  var helo = line.slice(verb.length).trim();
536
573
  if (!helo) {
537
574
  _writeReply(socket, REPLY_501_BAD_ARGS, "5.5.4 " + verb + " requires a domain argument");
@@ -552,6 +589,25 @@ function create(opts) {
552
589
  return;
553
590
  }
554
591
  }
592
+ // Operator HELO-identity gate (b.mail.helo) — FCrDNS / HELO-shape /
593
+ // self-name spoofing checks. Composed when the operator wires
594
+ // `opts.helo`; skipped silently otherwise (no synthesized verdict).
595
+ // Hard-reject actions (reject-shape / match-self-refused /
596
+ // literal-mismatch) refuse the connection; "accept" and the
597
+ // advisory "soft-*" actions pass (the soft verdict rides the event).
598
+ if (opts.helo && typeof opts.helo.evaluate === "function") {
599
+ var heloGate = await opts.helo.evaluate(
600
+ { claimedName: helo, ip: state.remoteAddress, tls: state.tls }, {});
601
+ state.heloVerdict = heloGate && heloGate.action;
602
+ if (heloGate && heloGate.action && heloGate.action !== "accept" &&
603
+ heloGate.action.indexOf("soft") !== 0) {
604
+ _emit("mail.server.mx.helo_gate_refused",
605
+ { connectionId: state.id, helo: helo, action: heloGate.action }, "denied");
606
+ _writeReply(socket, REPLY_550_MAILBOX_UNAVAIL,
607
+ "5.7.1 " + verb + " identity refused (" + heloGate.action + ")");
608
+ return;
609
+ }
610
+ }
555
611
  state.helo = helo;
556
612
  state.stage = "ehlo";
557
613
  // Multi-line 250 capabilities advertisement per RFC 5321 §4.1.1.1.
@@ -572,7 +628,8 @@ function create(opts) {
572
628
  _writeReply(socket, REPLY_250_OK, greeting + " greets " + helo);
573
629
  }
574
630
  _emit("mail.server.mx.helo",
575
- { connectionId: state.id, verb: verb, helo: helo, tls: state.tls });
631
+ { connectionId: state.id, verb: verb, helo: helo, tls: state.tls,
632
+ heloVerdict: state.heloVerdict || null });
576
633
  }
577
634
 
578
635
  // ---- STARTTLS ---------------------------------------------------------
@@ -604,13 +661,11 @@ function create(opts) {
604
661
  state.helo = null;
605
662
  },
606
663
  onData: function (tlsSocket, chunk) {
607
- try { _ingestBytes(state, tlsSocket, chunk); }
608
- catch (err) {
609
- _emit("mail.server.mx.handler_threw",
610
- { connectionId: state.id, error: (err && err.message) || String(err) },
611
- "failure");
612
- _closeConnection(tlsSocket);
613
- }
664
+ // Route the upgraded socket through the SAME serialized pump as
665
+ // the plaintext path — post-STARTTLS is where the gates run in
666
+ // the default strict/balanced profiles, so it MUST be serialized
667
+ // and its async rejections MUST hit the 421 path.
668
+ _feedChunk(tlsSocket, chunk);
614
669
  },
615
670
  onError: function (err) {
616
671
  _emit("mail.server.mx.tls_handshake_failed",
@@ -626,7 +681,7 @@ function create(opts) {
626
681
  }
627
682
 
628
683
  // ---- MAIL FROM --------------------------------------------------------
629
- function _handleMailFrom(state, socket, line) {
684
+ async function _handleMailFrom(state, socket, line) {
630
685
  if (!state.tls && _requiresStartTls()) {
631
686
  _writeReply(socket, REPLY_530_AUTH_REQUIRED, "5.7.0 Must issue a STARTTLS command first");
632
687
  return;
@@ -677,7 +732,7 @@ function create(opts) {
677
732
  }
678
733
 
679
734
  // ---- RCPT TO ----------------------------------------------------------
680
- function _handleRcptTo(state, socket, line) {
735
+ async function _handleRcptTo(state, socket, line) {
681
736
  if (state.stage !== "rcpt") {
682
737
  _writeReply(socket, REPLY_503_BAD_SEQUENCE, "5.5.1 MAIL FROM first");
683
738
  return;
@@ -740,9 +795,49 @@ function create(opts) {
740
795
  return;
741
796
  }
742
797
  }
798
+ // RBL gate (b.mail.rbl) — DNS blocklist check on the connecting
799
+ // IP. The verdict is per-connection, so it's evaluated once and
800
+ // cached on state; a listed IP refuses with 554. Skipped silently
801
+ // when opts.rbl isn't wired.
802
+ if (opts.rbl && typeof opts.rbl.query === "function") {
803
+ if (state.rblVerdict === undefined) {
804
+ state.rblVerdict = await opts.rbl.query(state.remoteAddress);
805
+ }
806
+ if (state.rblVerdict && Array.isArray(state.rblVerdict.listed) &&
807
+ state.rblVerdict.listed.length > 0) {
808
+ _trackRefusedRcpt(state, rcpt, "rbl-listed");
809
+ _emit("mail.server.mx.rbl_refused",
810
+ { connectionId: state.id, remoteAddress: state.remoteAddress,
811
+ zones: state.rblVerdict.listed.map(function (l) { return l.zone; }) }, "denied");
812
+ _writeReply(socket, REPLY_554_TRANSACTION_FAILED,
813
+ "5.7.1 Connecting IP is on a DNS blocklist");
814
+ return;
815
+ }
816
+ }
817
+ // Greylist gate (b.mail.greylist) — defer first sight of an
818
+ // (ip, mailFrom, rcpt) tuple with a 450 tempfail; legitimate
819
+ // senders retry and pass. "defer" → 450; "accept" → continue.
820
+ // Skipped silently when opts.greylist isn't wired.
821
+ var greyVerdict = null;
822
+ if (opts.greylist && typeof opts.greylist.check === "function") {
823
+ greyVerdict = await opts.greylist.check(
824
+ { ip: state.remoteAddress, mailFrom: state.mailFrom || "", rcptTo: rcpt });
825
+ if (greyVerdict && greyVerdict.action === "defer") {
826
+ _emit("mail.server.mx.greylist_deferred",
827
+ { connectionId: state.id, remoteAddress: state.remoteAddress,
828
+ mailFrom: state.mailFrom, rcptTo: rcpt,
829
+ reason: greyVerdict.reason }, "denied");
830
+ _writeReply(socket, REPLY_450_MAILBOX_BUSY,
831
+ "4.7.1 Greylisted — please retry shortly");
832
+ return;
833
+ }
834
+ }
743
835
  state.rcpts.push(rcpt);
744
836
  _emit("mail.server.mx.rcpt_to",
745
- { connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length });
837
+ { connectionId: state.id, rcptTo: rcpt, rcptCount: state.rcpts.length,
838
+ rblListed: !!(state.rblVerdict && Array.isArray(state.rblVerdict.listed) &&
839
+ state.rblVerdict.listed.length > 0),
840
+ greylist: greyVerdict ? greyVerdict.action : null });
746
841
  _writeReply(socket, REPLY_250_OK, "2.1.5 Recipient OK");
747
842
  }
748
843
 
@@ -764,7 +859,7 @@ function create(opts) {
764
859
  });
765
860
  }
766
861
 
767
- function _finalizeDataBody(state, socket, body) {
862
+ async function _finalizeDataBody(state, socket, body) {
768
863
  // body is the raw bytes BEFORE dot-stuffing reversal. RFC 5321
769
864
  // §4.5.2 — a single leading "." is doubled on the wire; undo.
770
865
  var dedotted = safeSmtp.dotUnstuff(body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.10",
3
+ "version": "0.13.12",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:62fe872e-bfa6-4982-9eb9-f37d3f139663",
5
+ "serialNumber": "urn:uuid:cfe1e116-abd7-4631-96e5-8a2a865e2a16",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-27T08:47:25.364Z",
8
+ "timestamp": "2026-05-27T11:02:44.192Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.13.10",
22
+ "bom-ref": "@blamejs/core@0.13.12",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.10",
25
+ "version": "0.13.12",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.13.10",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.12",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.13.10",
57
+ "ref": "@blamejs/core@0.13.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]