@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
@@ -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");
@@ -147,6 +154,7 @@ var codepointClass = require("./codepoint-class");
147
154
  // Centralized so the marker lives in one place
148
155
  // and the per-call sites read cleanly.
149
156
  var ERR_CLAMP = 200; // protocol-reply error-message clamp
157
+ var CRLF_BYTES = Buffer.from("\r\n", "latin1"); // RFC 9051 §2.2 line terminator, as octets
150
158
  var LINE_PREVIEW = 80; // audit-line preview clamp
151
159
 
152
160
  // RFC 9051 §6.3.12 + RFC 5322 §3.3 date-time parser for IMAP APPEND.
@@ -228,6 +236,7 @@ function _validateMailboxName(name, opts) {
228
236
  * maxLineBytes: number, // default 8192
229
237
  * maxLiteralBytes: number, // default 64 MiB
230
238
  * idleTimeoutMs: number, // default 30 min
239
+ * maxConnections: number, // default 1024 — listener-wide ceiling
231
240
  * profile: "strict" | "balanced" | "permissive",
232
241
  * auth: {
233
242
  * mechanisms: ["PLAIN", "LOGIN", "SCRAM-SHA-256", "EXTERNAL", "XOAUTH2"],
@@ -237,6 +246,15 @@ function _validateMailboxName(name, opts) {
237
246
  * rateLimit: b.mail.server.rateLimit handle | opts | false,
238
247
  * audit: b.audit // optional
239
248
  *
249
+ * `fetchRange` may return each row's `payload` as a Buffer, and should whenever
250
+ * the row carries message content. RFC 9051 §4.3 makes a literal a counted
251
+ * sequence of octets, and a string cannot hold one: a message octet that is not
252
+ * valid UTF-8 does not survive being encoded on the way to the socket, and the
253
+ * count the response announced stops matching the number of octets it wrote —
254
+ * which is what tells a client where the response ends. A Buffer payload is
255
+ * framed and written as the octets it holds. A string payload is unchanged and
256
+ * remains right for the rows that carry only attributes, such as `FLAGS (\Seen)`.
257
+ *
240
258
  * @example
241
259
  * var imap = b.mail.server.imap.create({
242
260
  * tlsContext: b.mail.server.tls.context({ certFile, keyFile }).secureContext,
@@ -264,7 +282,7 @@ function create(opts) {
264
282
  "mail.server.imap.create: mailStore is required (compose b.mailStore.create({ backend: ... }))");
265
283
  }
266
284
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
267
- ["maxLineBytes", "maxLiteralBytes", "idleTimeoutMs"],
285
+ ["maxLineBytes", "maxLiteralBytes", "idleTimeoutMs", "maxConnections"],
268
286
  "mail.server.imap.", MailServerImapError, "mail-server-imap/bad-bound");
269
287
 
270
288
  var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
@@ -283,16 +301,18 @@ function create(opts) {
283
301
  var _emit = auditEmit.emit;
284
302
 
285
303
  function _handleConnection(rawSocket) {
286
- var remoteAddress = mailServerNet.admitConnection(rawSocket, rateLimit, _emit, {
304
+ var accepted = mailServerNet.acceptConnection(rawSocket, {
305
+ rateLimit: rateLimit,
306
+ connections: connections,
307
+ emit: _emit,
287
308
  refusedEvent: "mail.server.imap.rate_limit_refused",
288
309
  refusalLine: "* BAD Too many connections from your IP\r\n",
310
+ idPrefix: "imapconn-",
289
311
  });
290
- if (remoteAddress === null) return;
291
- rawSocket.once("close", function () { rateLimit.releaseConnection(remoteAddress); });
292
-
293
- var connectionId = "imapconn-" + bCrypto.generateToken(8); // connection-id length
294
- var socket = rawSocket;
295
- connections.add(socket);
312
+ if (accepted === null) return;
313
+ var remoteAddress = accepted.remoteAddress;
314
+ var connectionId = accepted.connectionId;
315
+ var socket = accepted.socket;
296
316
 
297
317
  var state = {
298
318
  id: connectionId,
@@ -450,7 +470,14 @@ function create(opts) {
450
470
  synchronizing: !parsed.literalNonSync,
451
471
  };
452
472
  if (!parsed.literalNonSync) {
453
- _writeUntagged(socket, "+ Ready for literal data");
473
+ // RFC 9051 §7.5 — a synchronizing literal is answered with a command
474
+ // continuation request: a line that BEGINS with `+`. Written as an
475
+ // untagged response, the wire carried `* + Ready for literal data`,
476
+ // which is not one, so a conforming client waited for a `+` line that
477
+ // never came and APPEND could not complete. The non-synchronizing
478
+ // route is no escape either: the strict profile's guard refuses
479
+ // LITERAL+ and CAPABILITY does not advertise it.
480
+ _writeContinuation(socket, "Ready for literal data");
454
481
  }
455
482
  return;
456
483
  }
@@ -731,7 +758,7 @@ function create(opts) {
731
758
  } else if (event.kind === "LIST") {
732
759
  _writeUntagged(socket, "LIST " + event.payload);
733
760
  } else if (event.kind === "FETCH") {
734
- _writeUntagged(socket, (event.seq || "") + " FETCH (" + (event.payload || "") + ")");
761
+ _writeUntagged(socket, _fetchResponse(event.seq || "", event.payload));
735
762
  }
736
763
  } catch (_e) { /* drop-silent — socket may already be closed */ }
737
764
  });
@@ -967,56 +994,49 @@ function create(opts) {
967
994
  }
968
995
  _emit("mail.server.imap.auth_attempt",
969
996
  { connectionId: state.id, mechanism: mechName, remoteAddress: state.remoteAddress });
970
- state.authPending = { mechanism: mechName, tag: tag, step: 0 };
997
+ state.authPending = { mech: mechName, tag: tag, step: 0 };
971
998
  _runAuthStep(state, socket, initialResp);
972
999
  }
973
1000
 
974
1001
  function _runAuthStep(state, socket, clientResp) {
975
1002
  var pending = state.authPending;
976
- Promise.resolve()
977
- .then(function () {
978
- return authConfig.verify(pending.mechanism, {
979
- step: pending.step,
980
- clientResponse: clientResp,
981
- tls: state.tls,
982
- remoteAddress: state.remoteAddress,
983
- });
984
- })
985
- .then(function (result) {
986
- pending.step += 1;
987
- if (result && result.pending && typeof result.challenge === "string") {
988
- // Server-side challenge — `+ <base64>` per RFC 9051 §6.2.2.
989
- _writeContinuation(socket, result.challenge);
990
- return;
991
- }
992
- if (result && result.ok === true && result.actor) {
993
- state.actor = result.actor;
994
- state.stage = "authenticated";
995
- var savedTag = pending.tag;
996
- state.authPending = null;
997
- _emit("mail.server.imap.auth_success",
998
- { connectionId: state.id, mechanism: pending.mechanism,
999
- tenantId: result.actor.tenantId || null });
1000
- _writeTagged(socket, savedTag, "OK [CAPABILITY " + _capabilityLine(state) + "] AUTHENTICATE completed");
1001
- return;
1002
- }
1003
- var failTag = pending.tag;
1004
- state.authPending = null;
1005
- rateLimit.noteAuthFailure(state.remoteAddress);
1006
- _emit("mail.server.imap.auth_failed",
1007
- { connectionId: state.id, mechanism: pending.mechanism,
1008
- reason: (result && result.reason) || "verify-returned-fail" }, "denied");
1009
- _writeTagged(socket, failTag, "NO Authentication credentials invalid");
1010
- })
1011
- .catch(function (err) {
1012
- var failTag = pending.tag;
1003
+ function _fail(reason, outcome, reply) {
1004
+ var failTag = pending.tag;
1005
+ state.authPending = null;
1006
+ rateLimit.noteAuthFailure(state.remoteAddress);
1007
+ _emit("mail.server.imap.auth_failed",
1008
+ { connectionId: state.id, mechanism: pending.mech, reason: reason }, outcome);
1009
+ _writeTagged(socket, failTag, reply);
1010
+ }
1011
+ mailServerNet.runSaslStep({
1012
+ exchange: pending,
1013
+ verify: authConfig.verify,
1014
+ credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
1015
+ clientResponse: clientResp,
1016
+ // Server-side challenge — `+ <base64>` per RFC 9051 §6.2.2.
1017
+ writeChallenge: function (ch) { return _writeContinuation(socket, ch); },
1018
+ onChallengeUnsafe: function () {
1019
+ _fail("challenge-contains-line-terminator", "denied", "NO Authentication failed");
1020
+ },
1021
+ onSuccess: function (result) {
1022
+ state.actor = result.actor;
1023
+ state.stage = "authenticated";
1024
+ var savedTag = pending.tag;
1013
1025
  state.authPending = null;
1014
- rateLimit.noteAuthFailure(state.remoteAddress);
1015
- _emit("mail.server.imap.auth_failed",
1016
- { connectionId: state.id, mechanism: pending.mechanism,
1017
- reason: (err && err.message) || String(err) }, "failure");
1018
- _writeTagged(socket, failTag, "NO Authentication failed");
1019
- });
1026
+ _emit("mail.server.imap.auth_success",
1027
+ { connectionId: state.id, mechanism: pending.mech,
1028
+ tenantId: result.actor.tenantId || null });
1029
+ _writeTagged(socket, savedTag,
1030
+ "OK [CAPABILITY " + _capabilityLine(state) + "] AUTHENTICATE completed");
1031
+ },
1032
+ onFailure: function (result) {
1033
+ _fail((result && result.reason) || "verify-returned-fail", "denied",
1034
+ "NO Authentication credentials invalid");
1035
+ },
1036
+ onError: function (err) {
1037
+ _fail((err && err.message) || String(err), "failure", "NO Authentication failed");
1038
+ },
1039
+ });
1020
1040
  }
1021
1041
 
1022
1042
  function _handleLogin(state, socket, tag, args) {
@@ -1582,10 +1602,20 @@ function create(opts) {
1582
1602
  for (var i = 0; i < rs.length; i += 1) {
1583
1603
  var r = rs[i];
1584
1604
  var payload = r.payload || "";
1585
- if (includeModseq && r.modseq !== undefined && !/MODSEQ\s*\(/.test(payload)) {
1586
- payload = (payload ? payload + " " : "") + "MODSEQ (" + r.modseq + ")";
1605
+ // A Buffer payload is the backend saying "these are the message's
1606
+ // own octets". It is assembled and written as octets end to end, so
1607
+ // the count in the literal header is the count that reaches the wire.
1608
+ // Concatenating it into a string instead re-encoded it as UTF-8, and
1609
+ // the response then announced one length and sent another.
1610
+ var octets = Buffer.isBuffer(payload);
1611
+ var asText = octets ? payload.toString("latin1") : String(payload);
1612
+ if (includeModseq && r.modseq !== undefined && !/MODSEQ\s*\(/.test(asText)) {
1613
+ var modseqAttr = (asText ? " " : "") + "MODSEQ (" + r.modseq + ")";
1614
+ payload = octets
1615
+ ? Buffer.concat([payload, Buffer.from(modseqAttr, "latin1")])
1616
+ : asText + modseqAttr;
1587
1617
  }
1588
- _writeUntagged(socket, r.seq + " FETCH (" + payload + ")");
1618
+ _writeUntagged(socket, _fetchResponse(r.seq, payload));
1589
1619
  }
1590
1620
  _writeTagged(socket, tag, "OK FETCH completed");
1591
1621
  })
@@ -1713,29 +1743,49 @@ function create(opts) {
1713
1743
  var subArgs = sub[2];
1714
1744
  if (subVerb === "FETCH") return _handleFetch(state, socket, tag, subArgs, true);
1715
1745
  if (subVerb === "STORE") return _handleStore(state, socket, tag, subArgs, true);
1716
- // RFC 9051 §6.4.9 also defines UID SEARCH / UID COPY / UID MOVE /
1717
- // UID EXPUNGE; deferred from the initial listener slice.
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.
1718
1750
  //
1719
- // SEARCH: composes with the existing _handleSearch path; needs
1720
- // the searchRange path threaded through `useUid: true`.
1721
- // COPY: composes with the existing _handleCopy path; needs
1722
- // the mailStore.copyRange opt accepted.
1723
- // MOVE: RFC 6851; same shape as COPY plus an atomic-delete
1724
- // step on the source mailbox.
1725
- // EXPUNGE: RFC 4315 UIDPLUS; expunges by uid-set instead of by
1726
- // \Deleted-flag scan.
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.
1727
1761
  //
1728
- // Re-open condition: operator surfaces a real IMAP client that
1729
- // refuses to fall back to seq-number variants (most modern
1730
- // clients mutt / Thunderbird / Apple Mail / Outlook — already
1731
- // use the seq-number forms when UID variants are unavailable).
1732
- //
1733
- // Operator escape hatch today: clients that issue these UID
1734
- // sub-commands receive `BAD` and retry against the seq-number
1735
- // variant (SEARCH / COPY / MOVE / EXPUNGE) which the listener
1736
- // does serve.
1737
- _writeTagged(socket, tag, "BAD UID " + subVerb +
1738
- " 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 });
1739
1789
  }
1740
1790
 
1741
1791
  function _handleIdle(state, socket, tag) {
@@ -1754,17 +1804,63 @@ function create(opts) {
1754
1804
  state.idle = { tag: tag, timer: timer };
1755
1805
  }
1756
1806
 
1807
+ // One line onto the socket, as OCTETS.
1808
+ //
1809
+ // RFC 9051 §4.3 makes a literal a counted sequence of octets, so a response
1810
+ // carrying message content has to reach the wire as the octets the backend
1811
+ // holds. `socket.write(string)` encodes as UTF-8, which replaces every
1812
+ // sequence that is not valid UTF-8 and changes the length — so the count the
1813
+ // response announced and the number of octets it wrote disagreed, and a
1814
+ // client, which uses that count to find the end of the response, read the
1815
+ // next response as part of this one.
1816
+ //
1817
+ // A Buffer `msg` is written through untouched. A string keeps its UTF-8
1818
+ // encoding, which is what RFC 9051 §5.1 asks for in the one place a response
1819
+ // string is not ASCII: a mailbox name, once the client has enabled UTF8=ACCEPT.
1820
+ // Encoding those latin1 instead would keep the low byte of each character and
1821
+ // corrupt every name outside Latin-1 — the same defect this fixes, moved.
1822
+ function _writeLine(socket, prefix, msg) {
1823
+ try {
1824
+ if (Buffer.isBuffer(msg)) {
1825
+ socket.write(Buffer.concat([Buffer.from(prefix, "latin1"), msg, CRLF_BYTES]));
1826
+ } else {
1827
+ socket.write(prefix + msg + "\r\n");
1828
+ }
1829
+ } catch (_e) { /* socket may be down */ }
1830
+ }
1831
+ // One untagged FETCH response. A Buffer payload is the backend saying "these
1832
+ // are the message's own octets", and the response is assembled as octets so
1833
+ // the count in the literal header is the count that reaches the wire; a
1834
+ // string payload is attributes and stays a string.
1835
+ //
1836
+ // Two places build one of these — the FETCH command and a NOTIFY push — and
1837
+ // a second copy of this is how the octet handling ends up right in one and
1838
+ // wrong in the other, which is what it was.
1839
+ function _fetchResponse(seq, payload) {
1840
+ if (Buffer.isBuffer(payload)) {
1841
+ return Buffer.concat([
1842
+ Buffer.from(seq + " FETCH (", "latin1"), payload, Buffer.from(")", "latin1"),
1843
+ ]);
1844
+ }
1845
+ return seq + " FETCH (" + (payload || "") + ")";
1846
+ }
1847
+
1757
1848
  function _writeTagged(socket, tag, msg) {
1758
- try { socket.write(tag + " " + msg + "\r\n"); }
1759
- catch (_e) { /* socket may be down */ }
1849
+ _writeLine(socket, tag + " ", msg);
1760
1850
  }
1761
1851
  function _writeUntagged(socket, msg) {
1762
- try { socket.write("* " + msg + "\r\n"); }
1763
- catch (_e) { /* socket may be down */ }
1852
+ _writeLine(socket, "* ", msg);
1764
1853
  }
1854
+ // RFC 9051 §7.5 continuation. Returns false when the operator's challenge
1855
+ // carries CR / LF / NUL: those end the line early and the remainder is read
1856
+ // by the client as a second server response. Not hypothetical for a SCRAM or
1857
+ // CRAM mechanism, whose challenge is composed partly from the client's own
1858
+ // nonce, so client bytes reach this write.
1765
1859
  function _writeContinuation(socket, msg) {
1766
- try { socket.write("+ " + msg + "\r\n"); }
1767
- catch (_e) { /* socket may be down */ }
1860
+ var safe = mailServerNet.saslChallengeOrNull(msg);
1861
+ if (safe === null) return false;
1862
+ _writeLine(socket, "+ ", safe);
1863
+ return true;
1768
1864
  }
1769
1865
  function _close(socket, state) {
1770
1866
  // The drain loop's `if (state.stage === "closed") return;` guard
@@ -1789,6 +1885,7 @@ function create(opts) {
1789
1885
  // ---- Lifecycle ----------------------------------------------------------
1790
1886
  return mailServerNet.createStoreServer(net, {
1791
1887
  defaultPort: 143, // RFC 9051 IMAP port (IANA)
1888
+ maxConnections: opts.maxConnections,
1792
1889
  handleConnection: _handleConnection,
1793
1890
  errorClass: MailServerImapError,
1794
1891
  errorCodePrefix: "mail-server-imap/",
@@ -158,6 +158,11 @@ void C;
158
158
  * // capabilities the server advertises beyond core
159
159
  * accountsFor: async function (actor) → { primaryAccounts, accounts },
160
160
  * // operator-supplied accountId enumeration
161
+ * webSocket: boolean, // default true — false stops advertising the
162
+ * // RFC 8887 WebSocket transport (capability and the
163
+ * // top-level webSocketUrl alias), for a deployment
164
+ * // that has not wired the upgrade handler
165
+ * webSocketUrl: string, // default "/jmap/ws" — where the upgrade lives
161
166
  * profile: "strict" | "balanced" | "permissive",
162
167
  * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
163
168
  * audit: b.audit // optional
@@ -199,6 +204,20 @@ function create(opts) {
199
204
  var profile = opts.profile || DEFAULT_PROFILE;
200
205
  var posture = opts.posture || null;
201
206
  var serverCapabilities = opts.serverCapabilities || {};
207
+ // RFC 8887 §3 — whether this deployment offers the WebSocket transport. It
208
+ // was advertised unconditionally: the capability was injected when the
209
+ // consumer omitted the key, and merged back in when they supplied one, so a
210
+ // deployment that had not wired the upgrade handler still sent conforming
211
+ // clients to an endpoint that could not upgrade. The only value that
212
+ // suppressed it was `undefined`, and only because JSON.stringify drops such
213
+ // keys — an accident of serialization rather than an answer. `false` is now
214
+ // that answer, and a value that is neither true nor false is a
215
+ // misconfiguration rather than a silent default back to advertising.
216
+ validateOpts.optionalBoolean(opts.webSocket,
217
+ "mail.server.jmap.create: opts.webSocket (false stops advertising the RFC 8887 " +
218
+ "WebSocket transport, for a deployment that has not wired the upgrade handler)",
219
+ MailServerJmapError, "mail-server-jmap/bad-websocket");
220
+ var webSocketEnabled = opts.webSocket !== false;
202
221
 
203
222
  // JMAP method registry. Wrap operator-supplied `opts.methods` map
204
223
  // through `b.mail.serverRegistry` so per-handler resource budgets
@@ -569,14 +588,19 @@ function create(opts) {
569
588
  var defaultCaps = { "urn:ietf:params:jmap:core": {} };
570
589
  var hasOperatorWsCap = Object.prototype.hasOwnProperty.call(
571
590
  serverCapabilities, "urn:ietf:params:jmap:websocket");
572
- if (!hasOperatorWsCap) {
591
+ if (webSocketEnabled && !hasOperatorWsCap) {
573
592
  defaultCaps["urn:ietf:params:jmap:websocket"] = {
574
593
  url: opts.webSocketUrl || "/jmap/ws",
575
594
  supportsPush: true,
576
595
  };
577
596
  }
597
+ var caps = Object.assign({}, defaultCaps, serverCapabilities);
598
+ // `webSocket: false` is the deployment's answer, so it outranks a
599
+ // capability the consumer also listed in serverCapabilities — otherwise
600
+ // the merge above would put back exactly what was declined.
601
+ if (!webSocketEnabled) delete caps["urn:ietf:params:jmap:websocket"];
578
602
  var session = {
579
- capabilities: Object.assign({}, defaultCaps, serverCapabilities),
603
+ capabilities: caps,
580
604
  accounts: info.accounts || {},
581
605
  primaryAccounts: info.primaryAccounts || {},
582
606
  username: actor.username || actor.id || "unknown",
@@ -587,10 +611,13 @@ function create(opts) {
587
611
  // RFC 8887 §3 — `webSocketUrl` advertises the JMAP WS
588
612
  // endpoint. Operator overrides via opts.webSocketUrl; default
589
613
  // mounts at `/jmap/ws`.
590
- urlEndpointResolution: serverCapabilities["urn:ietf:params:jmap:websocket"]
614
+ urlEndpointResolution: (webSocketEnabled && serverCapabilities["urn:ietf:params:jmap:websocket"])
591
615
  ? { useEndpoint: opts.webSocketUrl || "/jmap/ws", urlPrefix: "" }
592
616
  : undefined,
593
- webSocketUrl: opts.webSocketUrl || "/jmap/ws",
617
+ // The top-level alias goes with the capability. Leaving it behind
618
+ // would still point a client at an endpoint that cannot upgrade,
619
+ // which is the whole thing `webSocket: false` is declining.
620
+ webSocketUrl: webSocketEnabled ? (opts.webSocketUrl || "/jmap/ws") : undefined,
594
621
  state: sessionState,
595
622
  };
596
623
  res.statusCode = 200;