@blamejs/core 0.18.54 → 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.
@@ -561,7 +561,14 @@ function _detectIssues(input, opts) {
561
561
  }
562
562
 
563
563
  // 14. Extension allowlist.
564
- if (Array.isArray(opts.extensionAllowlist) && opts.extensionAllowlist.length > 0) {
564
+ //
565
+ // Gated on "is an allowlist supplied at all", NOT on "is it non-empty". The
566
+ // profiles spell "no restriction" as `null`, so an operator who computes the
567
+ // list and gets back an empty one is saying nothing is permitted — and an
568
+ // empty list that silently permitted everything inverted exactly that. An
569
+ // allowlist that disappears when empty is a firewall rule set that opens when
570
+ // the last rule is deleted.
571
+ if (Array.isArray(opts.extensionAllowlist)) {
565
572
  var split = _splitExt(name);
566
573
  var ext = split.ext.toLowerCase();
567
574
  var allowed = opts.extensionAllowlist.map(function (e) { return e.toLowerCase(); });
package/lib/guard-html.js CHANGED
@@ -556,7 +556,12 @@ function _detectIssues(input, opts) {
556
556
  location: tok.start,
557
557
  snippet: "dangerous tag <" + tok.name + ">",
558
558
  });
559
- } else if (Object.keys(allowedTags).length > 0 && !allowedTags[tok.name]) {
559
+ // No `length > 0` guard: the resolved profile always supplies a tag set
560
+ // (balanced carries 52), so an EMPTY set here can only be a caller who
561
+ // passed `allowedTags: []` — which means "permit nothing", not "permit
562
+ // everything". An allowlist that disappears when empty is a firewall rule
563
+ // set that opens when the last rule is deleted.
564
+ } else if (!allowedTags[tok.name]) {
560
565
  issues.push({
561
566
  kind: "non-allowlisted-tag", severity: "high", ruleId: "html.tag",
562
567
  location: tok.start,
@@ -734,7 +739,10 @@ function _sanitize(input, opts) {
734
739
  var an = a.name.toLowerCase();
735
740
  if (_isEventHandlerAttr(an)) continue;
736
741
  if (DANGEROUS_ATTRS.indexOf(an) !== -1) continue;
737
- if (Object.keys(allowedAttrs).length > 0 && !allowedAttrs[an]) continue;
742
+ // Same reasoning as the tag allowlist above: the resolved profile always
743
+ // supplies an attribute set, so an empty one is a caller asking that no
744
+ // attribute be permitted.
745
+ if (!allowedAttrs[an]) continue;
738
746
  if (a.value && Buffer.byteLength(a.value, "utf8") > opts.maxAttrValueBytes) continue;
739
747
  if (_isUrlAttr(an)) {
740
748
  var scheme = _extractScheme(a.value);
@@ -211,7 +211,12 @@ function _isRefusedAutoFetchHost(hostname, allowedHosts) {
211
211
  if (lower === "internal" || lower.endsWith(".internal")) return "reserved-local-suffix";
212
212
  // Optional operator allowlist — when supplied, hostname (or any
213
213
  // ancestor domain) MUST be present.
214
- if (Array.isArray(allowedHosts) && allowedHosts.length > 0) {
214
+ // Gated on "was an allowlist supplied", NOT on "is it non-empty". Omitting
215
+ // the option is how "any host" is spelled; an operator who computes the list
216
+ // and gets an empty one is saying no host may be auto-fetched, and reading
217
+ // that as "any host" inverts it. An allowlist that disappears when empty is a
218
+ // firewall rule set that opens when the last rule is deleted.
219
+ if (Array.isArray(allowedHosts)) {
215
220
  var matched = false;
216
221
  for (var i = 0; i < allowedHosts.length; i += 1) {
217
222
  var allowed = String(allowedHosts[i]).toLowerCase();
@@ -333,8 +333,9 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
333
333
  }
334
334
  }
335
335
  }
336
- var literalBytes = null;
337
- var literalPlus = false;
336
+ var literalBytes = null;
337
+ var literalPlus = false;
338
+ var initialResponse = null;
338
339
  if (trailing) {
339
340
  // Optional initial-response — either `{N+?}` literal or a quoted
340
341
  // base64 string.
@@ -365,9 +366,29 @@ function _validateAuthenticate(rest, caps, profileName, opts) {
365
366
  "guardManageSieveCommand.validate: AUTHENTICATE initial-response must be a " +
366
367
  "literal `{N}` / `{N+}` or quoted base64 string");
367
368
  }
369
+ // Returned rather than discarded. RFC 5804 §2.1 allows the initial
370
+ // response inline, and this branch already parsed and validated it; the
371
+ // value was then dropped, so a client that used the quoted form was
372
+ // answered as though it had sent no initial response at all.
373
+ if (inner.value.length > MAX_SASL_TOKEN_BYTES) {
374
+ throw new GuardManageSieveCommandError("guard-managesieve-command/literal-too-large",
375
+ "guardManageSieveCommand.validate: AUTHENTICATE initial-response " +
376
+ inner.value.length + " bytes exceeds " + MAX_SASL_TOKEN_BYTES + "-byte cap");
377
+ }
378
+ initialResponse = inner.value;
368
379
  }
369
380
  }
370
- return { verb: "AUTHENTICATE", args: [mech], literalBytes: literalBytes, literalPlus: literalPlus };
381
+ return {
382
+ verb: "AUTHENTICATE",
383
+ args: [mech],
384
+ literalBytes: literalBytes,
385
+ literalPlus: literalPlus,
386
+ // The quoted form's value, or null when the client sent none or sent it as
387
+ // a literal (whose bytes arrive on the following line). One field, so a
388
+ // caller asks "was there an initial response" once rather than per
389
+ // representation.
390
+ initialResponse: initialResponse,
391
+ };
371
392
  }
372
393
 
373
394
  function _validateNoop(rest, caps) {
@@ -500,7 +500,7 @@ function gate(opts) {
500
500
 
501
501
  /**
502
502
  * @primitive b.guardSmtpCommand.detectBodySmuggling
503
- * @signature b.guardSmtpCommand.detectBodySmuggling(buf)
503
+ * @signature b.guardSmtpCommand.detectBodySmuggling(buf, isBodyStart?, precededByCr?)
504
504
  * @since 0.9.46
505
505
  * @status stable
506
506
  * @related b.guardSmtpCommand.validate, b.safeSmtp.findDotTerminator
@@ -526,11 +526,25 @@ function gate(opts) {
526
526
  * b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\n.\n"));
527
527
  * // → true (bare-LF dot-line — CVE-2023-51764 shape)
528
528
  */
529
- function detectBodySmuggling(buf) {
529
+ // `isBodyStart` defaults true, which is the whole-body call every existing
530
+ // caller makes. An incremental scanner passes false for every window after the
531
+ // first: the dot-at-offset-0 case below is about a dot that begins the BODY,
532
+ // and a window whose index 0 is mid-line would otherwise report one that is not
533
+ // there.
534
+ //
535
+ // `precededByCr` supplies the one byte a window cannot carry. Deciding whether
536
+ // an LF is bare means reading the byte before it, and the buffer has that byte
537
+ // for every offset except zero. Overlapping windows does not supply it — it
538
+ // only moves which byte lands at offset zero — so a scanner whose window opens
539
+ // on the LF of a canonical `\r\n` had no way to say so, and the boundary read
540
+ // as bare. Default false: at a true body start there is no preceding byte, and
541
+ // a leading LF there really is bare.
542
+ function detectBodySmuggling(buf, isBodyStart, precededByCr) {
530
543
  if (!Buffer.isBuffer(buf)) {
531
544
  throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
532
545
  "detectBodySmuggling: input must be a Buffer");
533
546
  }
547
+ var atStart = isBodyStart === undefined ? true : isBodyStart === true;
534
548
  // The CVE-2023-51764 / 51765 / 51766 class is any
535
549
  // dot-line whose line boundary is anything OTHER than canonical
536
550
  // \r\n on BOTH sides of the dot. The canonical-and-only terminator
@@ -548,14 +562,16 @@ function detectBodySmuggling(buf) {
548
562
  // count: a dot at byte 0 followed by `\n` would terminate any
549
563
  // receiver that accepts bare-LF dot.
550
564
  // 0x0a = LF, 0x0d = CR, 0x2e = `.`
551
- if (buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x0a) return true;
565
+ if (atStart && buf.length >= 2 && buf[0] === 0x2e && buf[1] === 0x0a) return true;
552
566
  // Walk every LF in the buffer. The previous byte must be CR for the
553
567
  // line boundary to be canonical; otherwise the line started with
554
568
  // bare-LF. If the next bytes are `.` followed by ANY of (LF, CRLF),
555
569
  // the shape is a smuggling candidate.
556
570
  for (var i = 0; i < buf.length - 1; i += 1) {
557
571
  if (buf[i] !== 0x0a) continue;
558
- var leadingBareLf = (i === 0) || (buf[i - 1] !== 0x0d);
572
+ var leadingBareLf = (i === 0)
573
+ ? (precededByCr !== true)
574
+ : (buf[i - 1] !== 0x0d);
559
575
  if (buf[i + 1] !== 0x2e) continue;
560
576
  // Trailing terminator shape after the dot:
561
577
  // buf[i+2] == LF → bare-LF terminator (always smuggling)
package/lib/guard-svg.js CHANGED
@@ -561,7 +561,12 @@ function _detectIssues(input, opts) {
561
561
  });
562
562
  continue;
563
563
  }
564
- } else if (Object.keys(allowedTags).length > 0 && !allowedTags[tok.name]) {
564
+ // No `length > 0` guard: the resolved profile always supplies a tag set
565
+ // (balanced carries 54), so an EMPTY set here can only be a caller who
566
+ // passed `allowedTags: []` — which means "permit nothing", not "permit
567
+ // everything". An allowlist that disappears when empty is a firewall rule
568
+ // set that opens when the last rule is deleted.
569
+ } else if (!allowedTags[tok.name]) {
565
570
  issues.push({
566
571
  kind: "non-allowlisted-tag", severity: "high", ruleId: "svg.tag",
567
572
  location: tok.start,
@@ -621,7 +621,13 @@ function _fromH2Headers(h2Headers) {
621
621
  // { host: "api.x.com", methods: ["GET","HEAD"] }
622
622
  // method-restricted; methods omitted = any method
623
623
  function hostAllowed(host, allowedHosts, method) {
624
- if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) return true;
624
+ // A NON-array means no pin was supplied, so nothing is constrained. An EMPTY
625
+ // array is a pin that permits nothing, and falls through to the `false` at
626
+ // the end rather than short-circuiting to `true` here — an allowlist that
627
+ // disappears when empty is a firewall rule set that opens when the last rule
628
+ // is deleted. Every caller enforcing this pin routes through here, so the
629
+ // distinction has to live at this function and not at each of them.
630
+ if (!Array.isArray(allowedHosts)) return true;
625
631
  var wanted = String(host || "").toLowerCase();
626
632
  var verb = String(method || "GET").toUpperCase();
627
633
  for (var ai = 0; ai < allowedHosts.length; ai++) {
@@ -720,7 +726,9 @@ function pinnedClient(client, allowedHosts) {
720
726
  // redirect the wrapper never sees is a hop none of that applied to. Returning
721
727
  // the client untouched when no pin was named would leave a 307 from https to
722
728
  // http free to resend the credentials that were checked onto the first hop.
723
- var pin = (Array.isArray(allowedHosts) && allowedHosts.length > 0) ? allowedHosts : null;
729
+ // An EMPTY allowlist is a real pin meaning "reach nothing", not the absence
730
+ // of one — same reasoning as the request path above.
731
+ var pin = Array.isArray(allowedHosts) ? allowedHosts : null;
724
732
  return {
725
733
  request: function (opts) {
726
734
  var target = opts && opts.url;
@@ -1746,7 +1754,13 @@ function _requestSingle(opts) {
1746
1754
  // A disallowed call rejects with HOST_DISALLOWED AND emits an
1747
1755
  // audit event when opts.audit is wired (operator gets a structured
1748
1756
  // signal that the application tried to reach somewhere it shouldn't).
1749
- if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
1757
+ // Gated on "was an allowlist supplied", NOT on "is it non-empty". Omitting
1758
+ // the option is how "no egress pin" is spelled; an operator who builds the
1759
+ // list from config and gets an empty one is saying this process may reach
1760
+ // nothing, and reading that as "no pin" inverts the request exactly. An
1761
+ // allowlist that disappears when empty is a firewall rule set that opens when
1762
+ // the last rule is deleted.
1763
+ if (Array.isArray(opts.allowedHosts)) {
1750
1764
  var host = u.hostname.toLowerCase();
1751
1765
  var method = (opts.method || "GET").toUpperCase();
1752
1766
  var ok = hostAllowed(host, opts.allowedHosts, method);
package/lib/mail-agent.js CHANGED
@@ -77,6 +77,7 @@
77
77
  var lazyRequire = require("./lazy-require");
78
78
  var validateOpts = require("./validate-opts");
79
79
  var C = require("./constants");
80
+ var agentAudit = require("./agent-audit");
80
81
  var { defineClass } = require("./framework-error");
81
82
  var guardMailQuery = require("./guard-mail-query");
82
83
  var guardMailCompose = require("./guard-mail-compose");
@@ -789,10 +790,11 @@ function _auditEmitter(auditOverride) {
789
790
  };
790
791
  }
791
792
 
792
- function _actorShape(actor) {
793
- if (!actor || typeof actor !== "object") return { id: "<unknown>" };
794
- return { id: actor.id, roles: actor.roles || [] };
795
- }
793
+ // Through the shared shaper rather than a local copy. This module had its own,
794
+ // and the two drifted in exactly the way that matters: both emitted `{ id,
795
+ // roles }` while `b.audit.record` reads `actor.userId`, so every row either
796
+ // produced landed unattributable. One shaper means one place to be right.
797
+ var _actorShape = agentAudit.actorShape;
796
798
 
797
799
  module.exports = {
798
800
  create: create,
package/lib/mail-auth.js CHANGED
@@ -2088,6 +2088,15 @@ async function arcVerify(rfc822, opts) {
2088
2088
  // 3. Per-hop AMS + AS verification.
2089
2089
  var perHop = [];
2090
2090
  var anyFail = false;
2091
+ // A hop that failed for a reason OTHER than the key being unreachable. RFC
2092
+ // 8601 §2.7 keeps `temperror` apart from `fail` for exactly this reason: one
2093
+ // is a statement about the message, the other about the resolver. Collapsing
2094
+ // them tells a consumer a seal was forged when a DNS query timed out.
2095
+ //
2096
+ // Tracked as the hard case rather than the transient one, because `anyFail`
2097
+ // without `anyHardFail` already means every non-passing hop was a temperror,
2098
+ // and a second flag could disagree with the first.
2099
+ var anyHardFail = false;
2091
2100
  // RFC 8617 §5.2 — operator-tunable clock skew on t= (signing
2092
2101
  // timestamp) and x= (expiration) tags. Default 5 min.
2093
2102
  // A present clockSkewMs must be a non-negative finite integer; an Infinity /
@@ -2147,7 +2156,16 @@ async function arcVerify(rfc822, opts) {
2147
2156
  amsErrors: amsResult.errors,
2148
2157
  asErrors: asResult.errors,
2149
2158
  });
2150
- if (amsResult.result !== "pass" || asResult.result !== "pass") anyFail = true;
2159
+ if (amsResult.result !== "pass" || asResult.result !== "pass") {
2160
+ anyFail = true;
2161
+ // Accumulated across ALL hops, not decided per hop: a temperror on one
2162
+ // hop and a genuine failure on another is a genuine failure, whichever
2163
+ // order they arrive in.
2164
+ if ((amsResult.result !== "pass" && amsResult.result !== "temperror") ||
2165
+ (asResult.result !== "pass" && asResult.result !== "temperror")) {
2166
+ anyHardFail = true;
2167
+ }
2168
+ }
2151
2169
  }
2152
2170
 
2153
2171
  // 4. Chain Validation per RFC 8617 §5.2.
@@ -2190,12 +2208,37 @@ async function arcVerify(rfc822, opts) {
2190
2208
  var lastCv = perHopCv[perHopCv.length - 1];
2191
2209
  var chainStatus;
2192
2210
  var reasonOut = null;
2211
+ // Whether this verdict is worth retrying. A consumer that lets the chain
2212
+ // influence a disposition must not read a resolver outage as a forgery.
2213
+ var transientOut = false;
2193
2214
  if (hopRuleViolation) {
2194
2215
  chainStatus = "fail";
2195
2216
  reasonOut = hopRuleViolation;
2196
2217
  } else if (anyFail) {
2197
2218
  chainStatus = "fail";
2198
- reasonOut = "signature-verification-failed";
2219
+ // The chain does not validate either way, so the wire token stays "fail"
2220
+ // RFC 8617 §5.2 gives the chain none / pass / fail and nothing else, and
2221
+ // putting a word on the wire that receivers have no rule for would be a
2222
+ // worse answer than a coarse one. The distinction a consumer needs lives in
2223
+ // the structured verdict beside it.
2224
+ // A cv=fail recorded by a hop is TERMINAL — RFC 8617 §5.2 makes the chain
2225
+ // unrecoverable from that point, so it is reported as the reason even when
2226
+ // an upstream lookup also failed, and it is never transient. Retrying DNS
2227
+ // cannot turn it into a pass, and calling it transient tells a consumer to
2228
+ // defer and re-deliver mail that will never validate.
2229
+ //
2230
+ // Believing that claim requires the hop making it to have proved it made
2231
+ // it. cv= is a token in a header the sender wrote; only the ARC-Seal
2232
+ // covering that header says the named hop is who wrote it. So terminality
2233
+ // is gated on the LAST hop's seal verifying: when its own key lookup
2234
+ // temperrored, the token is unauthenticated text, and honouring it would
2235
+ // let anyone who can stall one DNS query stamp cv=fail on a chain and turn
2236
+ // a retryable outage into a permanent rejection.
2237
+ var lastSeal = perHop.length ? perHop[perHop.length - 1].asResult : null;
2238
+ var terminalCv = lastCv === "fail" && lastSeal === "pass";
2239
+ reasonOut = terminalCv ? "last-as-cv=fail"
2240
+ : (anyHardFail ? "signature-verification-failed" : "key-lookup-unavailable");
2241
+ transientOut = !terminalCv && !anyHardFail;
2199
2242
  } else if (lastCv === "fail") {
2200
2243
  chainStatus = "fail";
2201
2244
  reasonOut = "last-as-cv=fail";
@@ -2216,6 +2259,7 @@ async function arcVerify(rfc822, opts) {
2216
2259
  hops: perHop,
2217
2260
  };
2218
2261
  if (reasonOut) out.reason = reasonOut;
2262
+ if (transientOut) out.transient = true;
2219
2263
  return out;
2220
2264
  }
2221
2265
 
@@ -2490,6 +2534,9 @@ async function arcEvaluate(rfc822, opts) {
2490
2534
  breakAt: null,
2491
2535
  };
2492
2536
  if (verdict.reason) out.reason = verdict.reason;
2537
+ // Carried for the same reason `reason` is: a caller deciding what to do about
2538
+ // a chain that did not validate needs to know whether the answer is stable.
2539
+ if (verdict.transient) out.transient = true;
2493
2540
 
2494
2541
  // Re-extract per-hop d= (signing domain on AS) AND the AAR text from
2495
2542
  // the original headers — the verify-result shape doesn't carry
@@ -2719,6 +2766,16 @@ function authResultsEmit(opts) {
2719
2766
  // // → { spf, dkim, from, dmarc, arc, authResults }
2720
2767
  // if (v.dmarc.recommendedAction === "reject") { /* refuse 550 5.7.1 */ }
2721
2768
  //
2769
+ // `arc.chainStatus` is "none" / "pass" / "fail" — the vocabulary RFC 8617 §5.2
2770
+ // gives the chain, and the token that goes on the wire in
2771
+ // Authentication-Results. A "fail" carries `arc.reason`, and `arc.transient` is
2772
+ // true when the chain did not validate because a key could not be looked up
2773
+ // rather than because a seal did not verify. Those want opposite responses: one
2774
+ // is a statement about the sender, the other about a resolver, and a consumer
2775
+ // that lets the chain influence a disposition must not read an outage as a
2776
+ // forgery. The header cannot carry the distinction, which is why the structured
2777
+ // verdict does.
2778
+ //
2722
2779
  // From-header discipline (RFC 9989 §5.3.1, RFC 7489 §6.6.1 before it):
2723
2780
  // DMARC evaluates exactly one author domain. A message with zero From fields, several From fields,
2724
2781
  // or several author addresses in one field is the header-duplication
@@ -98,10 +98,18 @@
98
98
  *
99
99
  * ## What v1 does NOT ship
100
100
  *
101
- * - **SEARCH** — operator wires `opts.search(actor, mailbox, query)`
102
- * when ready; the listener emits `BAD search-not-configured`
103
- * until then. SEARCH expressions are operator-domain logic
104
- * against the mailStore index.
101
+ * - **SEARCH / COPY / MOVE** — operator-domain logic against the
102
+ * mailStore index, supplied through `opts.overrides`; until then
103
+ * the listener answers `NO <verb> not configured`. A handler
104
+ * supplied once serves BOTH the plain and the `UID` form: the UID
105
+ * verb dispatches back through the same registry entry and sets
106
+ * `parsed.useUid`, which the handler reads to answer in unique
107
+ * identifiers per RFC 9051 §6.4.9.
108
+ * - **UID EXPUNGE (RFC 4315)** — refused unless the operator
109
+ * supplies an EXPUNGE handler that reads the uid-set. The shipped
110
+ * EXPUNGE takes no set and expunges every message flagged
111
+ * `\Deleted`, so serving the UID form from it would delete
112
+ * messages the client did not name.
105
113
  * - **NOTIFY (RFC 5465)**, **METADATA (RFC 5464)**, **CATENATE
106
114
  * (RFC 4469)**, **URLAUTH (RFC 4467)**, **IMAPSIEVE (RFC 6785)**,
107
115
  * **COMPRESS=DEFLATE (RFC 4978)** — opt-in / refused.
@@ -121,7 +129,6 @@
121
129
  var net = require("node:net");
122
130
  var safeBuffer = require("./safe-buffer");
123
131
  var C = require("./constants");
124
- var bCrypto = require("./crypto");
125
132
  var numericBounds = require("./numeric-bounds");
126
133
  var validateOpts = require("./validate-opts");
127
134
  var guardImapCommand = require("./guard-imap-command");
@@ -229,6 +236,7 @@ function _validateMailboxName(name, opts) {
229
236
  * maxLineBytes: number, // default 8192
230
237
  * maxLiteralBytes: number, // default 64 MiB
231
238
  * idleTimeoutMs: number, // default 30 min
239
+ * maxConnections: number, // default 1024 — listener-wide ceiling
232
240
  * profile: "strict" | "balanced" | "permissive",
233
241
  * auth: {
234
242
  * mechanisms: ["PLAIN", "LOGIN", "SCRAM-SHA-256", "EXTERNAL", "XOAUTH2"],
@@ -274,7 +282,7 @@ function create(opts) {
274
282
  "mail.server.imap.create: mailStore is required (compose b.mailStore.create({ backend: ... }))");
275
283
  }
276
284
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
277
- ["maxLineBytes", "maxLiteralBytes", "idleTimeoutMs"],
285
+ ["maxLineBytes", "maxLiteralBytes", "idleTimeoutMs", "maxConnections"],
278
286
  "mail.server.imap.", MailServerImapError, "mail-server-imap/bad-bound");
279
287
 
280
288
  var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
@@ -293,16 +301,18 @@ function create(opts) {
293
301
  var _emit = auditEmit.emit;
294
302
 
295
303
  function _handleConnection(rawSocket) {
296
- var remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
304
+ var accepted = mailServerNet.acceptConnection(rawSocket, {
305
+ rateLimit: rateLimit,
306
+ connections: connections,
307
+ emit: _emit,
297
308
  refusedEvent: "mail.server.imap.rate_limit_refused",
298
309
  refusalLine: "* BAD Too many connections from your IP\r\n",
310
+ idPrefix: "imapconn-",
299
311
  });
300
- if (remoteAddress === null) return;
301
- rawSocket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
302
-
303
- var connectionId = "imapconn-" + bCrypto.generateToken(8); // connection-id length
304
- var socket = rawSocket;
305
- connections.add(socket);
312
+ if (accepted === null) return;
313
+ var remoteAddress = accepted.remoteAddress;
314
+ var connectionId = accepted.connectionId;
315
+ var socket = accepted.socket;
306
316
 
307
317
  var state = {
308
318
  id: connectionId,
@@ -1733,29 +1743,49 @@ function create(opts) {
1733
1743
  var subArgs = sub[2];
1734
1744
  if (subVerb === "FETCH") return _handleFetch(state, socket, tag, subArgs, true);
1735
1745
  if (subVerb === "STORE") return _handleStore(state, socket, tag, subArgs, true);
1736
- // RFC 9051 §6.4.9 also defines UID SEARCH / UID COPY / UID MOVE /
1737
- // UID EXPUNGE; deferred from the initial listener slice.
1738
- //
1739
- // SEARCH: composes with the existing _handleSearch path; needs
1740
- // the searchRange path threaded through `useUid: true`.
1741
- // COPY: composes with the existing _handleCopy path; needs
1742
- // the mailStore.copyRange opt accepted.
1743
- // MOVE: RFC 6851; same shape as COPY plus an atomic-delete
1744
- // step on the source mailbox.
1745
- // EXPUNGE: RFC 4315 UIDPLUS; expunges by uid-set instead of by
1746
- // \Deleted-flag scan.
1746
+ // Every other sub-command RFC 9051 §6.4.9 defines SEARCH, COPY, MOVE,
1747
+ // EXPUNGE goes back through the registry to the SAME entry the
1748
+ // unprefixed verb dispatches to, carrying `useUid` so the handler answers
1749
+ // in unique identifiers.
1747
1750
  //
1748
- // Re-open condition: operator surfaces a real IMAP client that
1749
- // refuses to fall back to seq-number variants (most modern
1750
- // clients mutt / Thunderbird / Apple Mail / Outlook — already
1751
- // use the seq-number forms when UID variants are unavailable).
1751
+ // The listener ships no search, copy or move of its own: those are
1752
+ // operator-domain, and the registry holds a "not configured" default that
1753
+ // a consumer replaces through `opts.overrides`. Dispatching the UID forms
1754
+ // here instead of through the registry made that seam reachable from one
1755
+ // side only — a consumer who supplied SEARCH got the sequence form served
1756
+ // and the UID form refused, and the only way to supply the UID form was to
1757
+ // replace the whole UID verb, taking the working UID FETCH and UID STORE
1758
+ // with it. A client that keeps a cross-session cache asks for the UID form
1759
+ // precisely because a sequence number does not survive the session, so the
1760
+ // refusal fell on the clients doing the durable thing.
1752
1761
  //
1753
- // Operator escape hatch today: clients that issue these UID
1754
- // sub-commands receive `BAD` and retry against the seq-number
1755
- // variant (SEARCH / COPY / MOVE / EXPUNGE) which the listener
1756
- // does serve.
1757
- _writeTagged(socket, tag, "BAD UID " + subVerb +
1758
- " is not yet implemented; client may retry with the seq-number form");
1762
+ // Going through the registry also keeps the tenant check, the guard
1763
+ // validation and the audit emission on the UID path, which a direct call
1764
+ // from here would have skipped.
1765
+ // An allowlist, not "whatever the registry holds". UID takes the five
1766
+ // sub-commands §6.4.9 names plus EXPUNGE from RFC 4315; sending anything
1767
+ // else through would dispatch a real handler that knows nothing about UIDs
1768
+ // — `UID SELECT INBOX` would select a mailbox, and `UID UID …` would
1769
+ // recurse.
1770
+ if (subVerb !== "SEARCH" && subVerb !== "COPY" &&
1771
+ subVerb !== "MOVE" && subVerb !== "EXPUNGE") {
1772
+ _writeTagged(socket, tag, "BAD UID " + subVerb + " is not a UID sub-command");
1773
+ return;
1774
+ }
1775
+ // EXPUNGE is the one that cannot be forwarded blind. RFC 4315 §2.1 makes
1776
+ // `UID EXPUNGE <uid-set>` expunge ONLY the named messages, while the
1777
+ // listener's own EXPUNGE expunges every message flagged \Deleted and takes
1778
+ // no set at all. Forwarding to it would delete messages the client did not
1779
+ // name, which is worse than not answering. A consumer who supplies their
1780
+ // own EXPUNGE reads `useUid` and the set, so theirs can serve it.
1781
+ if (subVerb === "EXPUNGE" && _registry.source("EXPUNGE") !== "operator-override") {
1782
+ _writeTagged(socket, tag,
1783
+ "NO UID EXPUNGE needs a uid-set-aware EXPUNGE handler; " +
1784
+ "the default expunges by \\Deleted flag and would exceed the set");
1785
+ return;
1786
+ }
1787
+ return _registry.dispatch(subVerb, state, socket,
1788
+ { tag: tag, args: subArgs, useUid: true });
1759
1789
  }
1760
1790
 
1761
1791
  function _handleIdle(state, socket, tag) {
@@ -1855,6 +1885,7 @@ function create(opts) {
1855
1885
  // ---- Lifecycle ----------------------------------------------------------
1856
1886
  return mailServerNet.createStoreServer(net, {
1857
1887
  defaultPort: 143, // RFC 9051 IMAP port (IANA)
1888
+ maxConnections: opts.maxConnections,
1858
1889
  handleConnection: _handleConnection,
1859
1890
  errorClass: MailServerImapError,
1860
1891
  errorCodePrefix: "mail-server-imap/",