@blamejs/core 0.18.51 → 0.18.54

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 (44) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/NOTICE +1 -1
  3. package/README.md +3 -3
  4. package/lib/ai-adverse-decision.js +18 -2
  5. package/lib/codepoint-class.js +72 -0
  6. package/lib/cookies.js +7 -10
  7. package/lib/credential-hash.js +8 -1
  8. package/lib/crypto.js +7 -5
  9. package/lib/guard-auth.js +34 -11
  10. package/lib/guard-filename.js +39 -16
  11. package/lib/guard-managesieve-command.js +49 -9
  12. package/lib/guard-regex.js +3 -5
  13. package/lib/guard-yaml.js +212 -31
  14. package/lib/mail-agent.js +23 -9
  15. package/lib/mail-arc-sign.js +40 -7
  16. package/lib/mail-auth.js +75 -20
  17. package/lib/mail-crypto-pgp.js +1 -1
  18. package/lib/mail-dkim.js +80 -11
  19. package/lib/mail-helo.js +10 -0
  20. package/lib/mail-rbl.js +10 -3
  21. package/lib/mail-send-deliver.js +151 -32
  22. package/lib/mail-server-imap.js +121 -55
  23. package/lib/mail-server-jmap.js +31 -4
  24. package/lib/mail-server-managesieve.js +168 -25
  25. package/lib/mail-server-mx.js +76 -4
  26. package/lib/mail-server-net.js +126 -0
  27. package/lib/mail-server-pop3.js +73 -22
  28. package/lib/mail-server-submission.js +21 -2
  29. package/lib/mail-store.js +33 -11
  30. package/lib/mail.js +355 -17
  31. package/lib/middleware/bearer-auth.js +6 -1
  32. package/lib/middleware/fetch-metadata.js +5 -1
  33. package/lib/middleware/headers.js +7 -10
  34. package/lib/network-dns-resolver.js +71 -8
  35. package/lib/network-dns.js +26 -0
  36. package/lib/network-smtp-policy.js +42 -10
  37. package/lib/parsers/safe-yaml.js +24 -3
  38. package/lib/redact.js +13 -3
  39. package/lib/retention.js +22 -2
  40. package/lib/vendor/MANIFEST.json +12 -12
  41. package/lib/vendor/blamejs-pki.cjs +278 -40
  42. package/lib/yaml-lex.js +587 -0
  43. package/package.json +1 -1
  44. package/sbom.cdx.json +6 -6
@@ -11,7 +11,7 @@
11
11
  * Turnkey outbound SMTP composer. Wraps the discovery chain
12
12
  * (MX-lookup → MTA-STS-fetch + MX-allowlist match → DANE TLSA query
13
13
  * → REQUIRETLS handshake hint) around the existing per-host
14
- * `b.mail.smtpTransport` wire-layer, plus deferred-retry scheduling
14
+ * `b.mail.transports.smtp` wire-layer, plus deferred-retry scheduling
15
15
  * for transient failures and RFC 3464 DSN generation for permanent
16
16
  * ones.
17
17
  *
@@ -32,14 +32,18 @@
32
32
  * requireTls: true,
33
33
  * });
34
34
  * // → { delivered: [{ recipient, mxHost, tlsProtocol, ... }],
35
- * // deferred: [{ recipient, reason, retryAfterMs }],
35
+ * // deferred: [{ recipient, mxHost, reason, retryAfterMs }],
36
36
  * // failed: [{ recipient, reason, dsnSent }] }
37
+ * // deferred.mxHost is the receiver that refused, or null when the
38
+ * // deferral happened before any host was reached (an MX lookup that
39
+ * // failed). A queue view without it cannot say which peer to chase.
37
40
  *
38
41
  * Composes:
39
42
  * - `b.network.smtp.policy.mtaSts.fetch` + `.matchMx` → RFC 8461 enforcement
40
- * - `b.network.smtp.policy.dane.tlsa` → RFC 7672 TLSA query
43
+ * - `b.network.smtp.policy.dane.tlsa` + `.verifyChain` → RFC 7672 TLSA query
44
+ * and peer authentication
41
45
  * - `b.network.dns.resolver` (operator-supplied) → caching + DoH posture
42
- * - `b.mail.smtpTransport` → SMTP wire layer
46
+ * - `b.mail.transports.smtp` → SMTP wire layer
43
47
  * - `b.mail.requireTls` → RFC 8689 REQUIRETLS
44
48
  * - `b.mailBounce`-style RFC 3464 DSN generation → permanent-failure
45
49
  * report-mail
@@ -104,7 +108,16 @@ function _classifySmtpOutcome(err, response) {
104
108
  if (err) {
105
109
  var code = err.code || "";
106
110
  if (/^(ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND)$/.test(code)) return "transient";
107
- if (/mta-sts|tls-policy|dane|requiretls/i.test((err.code || "") + " " + (err.message || ""))) return "permanent";
111
+ // A DANE failure is about THIS HOST, not this recipient: the certificate
112
+ // did not match the records, or the records could not be used. The ordinary
113
+ // cause is a rollover part-way through DNS propagation, which resolves
114
+ // itself — and a DSN does not. Classified permanent it also skipped every
115
+ // remaining MX, so one host mid-rollover bounced mail the next host would
116
+ // have taken. RFC 7672 §2.2: try the other hosts, defer if none
117
+ // authenticates. Matched before the policy class below, which keeps its
118
+ // permanent verdict.
119
+ if (/\bdane\b/i.test((err.code || "") + " " + (err.message || ""))) return "transient";
120
+ if (/mta-sts|tls-policy|requiretls/i.test((err.code || "") + " " + (err.message || ""))) return "permanent";
108
121
  }
109
122
  return "transient";
110
123
  }
@@ -202,8 +215,22 @@ async function _resolveMx(domain, resolver, timeoutMs) {
202
215
  // .create()` wraps DoH and returns `{ rrs: [{ exchange, priority }],
203
216
  // ttl, ... }` — the wrapper carries TTL + provenance metadata.
204
217
  // Accept both shapes; refuse anything else.
218
+ //
219
+ // The resolver's records are NOT flat. b.network.dns decodes an MX RR into
220
+ // `{ name, type, typeName, ttl, decoded: { preference, exchange } }`, so
221
+ // reading `.exchange` off the record itself yields undefined — every MX
222
+ // host became undefined and the first thing to touch one failed. The
223
+ // comment above this normalisation asserted the flat shape, and no test
224
+ // caught the disagreement because every one of them passes a hand-written
225
+ // resolver returning exactly what the comment claimed. Only a query against
226
+ // a real DNS response shows the difference.
205
227
  if (mxs && !Array.isArray(mxs) && Array.isArray(mxs.rrs)) {
206
- mxs = mxs.rrs;
228
+ mxs = mxs.rrs.map(function (rr) {
229
+ if (rr && rr.decoded && typeof rr.decoded.exchange === "string") {
230
+ return { exchange: rr.decoded.exchange, priority: rr.decoded.preference };
231
+ }
232
+ return rr; // already flat
233
+ });
207
234
  }
208
235
  if (!Array.isArray(mxs) || mxs.length === 0) {
209
236
  throw new DeliverError("deliver/no-mx",
@@ -277,15 +304,26 @@ async function _applyMtaStsPolicy(domain, mxs, policyMode, auditEmit) {
277
304
  return filtered;
278
305
  }
279
306
 
280
- // Apply DANE TLSA query per RFC 7672. Returns array of TLSA records
281
- // for the MX host, OR null when DANE is off / no records published.
282
- // The primitive composes the lookup; per-cert chain verification is
283
- // the operator's responsibility (or future b.network.smtp.policy.dane.
284
- // verifyChain extension).
285
- async function _fetchDaneTlsa(mxHost, port, daneMode, auditEmit) {
307
+ // Apply DANE TLSA query per RFC 7672. Returns the array of TLSA records for
308
+ // the MX host, or null when DANE is off, the records are unusable, or the peer
309
+ // publishes none. The records are handed to the transport, which authenticates
310
+ // the peer's certificate chain against them via dane.verifyChain — the lookup
311
+ // on its own is discovery, and discovery is not what "enforce" promises.
312
+ async function _fetchDaneTlsa(mxHost, port, daneMode, dnssecValidated, resolver, auditEmit) {
286
313
  if (daneMode === "off") return null;
314
+ // RFC 7672 §1.3: records that were not DNSSEC-validated MUST NOT be used.
315
+ // Under opportunistic with no assertion they are therefore unusable before
316
+ // they are fetched, so asking for them buys nothing and costs a DNS round
317
+ // trip per MX plus a warn on every delivery. `enforce` cannot reach here
318
+ // without the assertion — create() refuses that combination outright.
319
+ if (!dnssecValidated) return null;
287
320
  try {
288
- var tlsa = await smtpPolicy().dane.tlsa(mxHost, port || DEFAULT_PORT_SMTP);
321
+ // The operator's resolver goes with the assertion: `dnssecValidated` is a
322
+ // statement about THEIR resolver, so the records have to come from it. A
323
+ // lookup that quietly used node:dns instead would let a non-validating
324
+ // system resolver supply spoofed TLSA data under a validated banner.
325
+ var tlsa = await smtpPolicy().dane.tlsa(mxHost, port || DEFAULT_PORT_SMTP,
326
+ { dnssecValidated: true, resolver: resolver || undefined });
289
327
  return tlsa && tlsa.length > 0 ? tlsa : null;
290
328
  } catch (e) {
291
329
  auditEmit("mail.send.deliver.dane.skip", "warn",
@@ -304,19 +342,25 @@ async function _fetchDaneTlsa(mxHost, port, daneMode, auditEmit) {
304
342
  // outbound relay) can wrap the wire-layer surface without monkey-
305
343
  // patching the framework's mail module.
306
344
  async function _tryHost(envelope, mxHost, hostnameLocal, opts) {
307
- var factory = opts.transportFactory || mailModule().smtpTransport;
345
+ // `transports.smtp`, which is the name `lib/mail.js` actually exports. It was
346
+ // reached for as `smtpTransport` — a name nothing defines — so the default was
347
+ // `undefined`, calling it threw, the throw was classified as a transient peer
348
+ // problem, and an operator who did not pass `transportFactory` got every
349
+ // recipient deferred 4.4.4 forever with no socket ever opened. The option is
350
+ // documented as an override, so the default is the path most callers take.
351
+ var factory = opts.transportFactory || mailModule().transports.smtp;
308
352
  var transport = factory({
309
353
  host: mxHost,
310
354
  port: opts.port || DEFAULT_PORT_SMTP,
311
355
  ehloName: hostnameLocal,
312
356
  timeoutMs: opts.perHostTimeoutMs || DEFAULT_PER_HOST_TIMEOUT_MS,
313
357
  requireTls: envelope.requireTls === true,
314
- // tls / dane verification is handed off to smtpTransport when
315
- // the operator wires opts.dane (TLSA pinning) via the message
316
- // shape; v1 of deliver doesn't auto-pin from the TLSA record set
317
- // because chain-verification needs the cert byte-level surface
318
- // smtpTransport doesn't expose yet. Operators with strict DANE
319
- // posture pass dane: tlsa[] into smtpTransport directly.
358
+ // The TLSA records fetched for THIS MX host. The transport matches the
359
+ // peer's certificate chain against them once the handshake completes and
360
+ // refuses the send on a mismatch (RFC 7672 §2.2). Absent when the peer
361
+ // publishes none, or when DANE is off — the transport then behaves as it
362
+ // always did.
363
+ dane: envelope.tlsa || undefined,
320
364
  });
321
365
  return transport.send({
322
366
  from: envelope.from,
@@ -356,14 +400,21 @@ async function _deliverOne(envelope, recipient, ctx) {
356
400
  }
357
401
  var lastErr = null;
358
402
  var lastResponse = null;
403
+ // The host the deferral is about. Null until one is tried, so a recipient
404
+ // deferred before any peer was reached reports "no host" rather than
405
+ // omitting the field — a consumer can then tell that apart from a host that
406
+ // simply was not recorded.
407
+ var lastMxHost = null;
359
408
  for (var i = 0; i < mxs.length; i += 1) {
360
409
  var mx = mxs[i];
361
- // DANE per-MX lookup. Skipped today for verification (operator
362
- // composes directly into smtpTransport.dane); this branch carries
363
- // the discovery so the audit chain records the policy posture
364
- // applied to each delivery attempt.
410
+ lastMxHost = mx.exchange;
411
+ // DANE per-MX lookup. The records go to the transport, which matches the
412
+ // peer's certificate chain against them during the handshake.
413
+ var mxTlsa = null;
365
414
  try {
366
- await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane, ctx.auditEmit);
415
+ mxTlsa = await _fetchDaneTlsa(mx.exchange, ctx.port, ctx.policy.dane,
416
+ ctx.policy.dnssecValidated, ctx.resolver,
417
+ ctx.auditEmit);
367
418
  } catch (daneErr) {
368
419
  // DANE "enforce": a TLSA lookup failure means this MX host cannot
369
420
  // be used for authenticated delivery (RFC 7672 §2.2). Fail this
@@ -385,6 +436,7 @@ async function _deliverOne(envelope, recipient, ctx) {
385
436
  recipient: recipient,
386
437
  rfc822: envelope.rfc822,
387
438
  requireTls: envelope.requireTls,
439
+ tlsa: mxTlsa,
388
440
  }, mx.exchange, ctx.hostname, ctx);
389
441
  ctx.auditEmit("mail.send.deliver.delivered", "success", {
390
442
  recipient: recipient, mxHost: mx.exchange, mxPriority: mx.priority,
@@ -411,8 +463,13 @@ async function _deliverOne(envelope, recipient, ctx) {
411
463
  }
412
464
  }
413
465
  // All MX hosts returned transient — overall outcome is transient
414
- // (defer + retry).
466
+ // (defer + retry). `mxHost` names the last host tried, because a queue an
467
+ // operator reads on a bad day has to answer WHICH receiver refused: a domain
468
+ // with several MX hosts defers on one of them, and that decides whether to
469
+ // wait, to contact that receiver, or to look at one's own transport security
470
+ // against that host.
415
471
  return { recipient: recipient, outcome: "transient",
472
+ mxHost: lastMxHost,
416
473
  reason: (lastErr && lastErr.message) || "all MX hosts failed transiently",
417
474
  reasonCode: (lastResponse && lastResponse.code) || "4.4.4" };
418
475
  }
@@ -428,7 +485,7 @@ async function _deliverOne(envelope, recipient, ctx) {
428
485
  * Build a turnkey delivery handle. Returns a `deliver(envelope)`
429
486
  * function that takes a single multi-recipient envelope, resolves
430
487
  * MX records per recipient domain, applies the operator's configured
431
- * MTA-STS / DANE policy, attempts delivery via `b.mail.smtpTransport`,
488
+ * MTA-STS / DANE policy, attempts delivery via `b.mail.transports.smtp`,
432
489
  * and returns a per-recipient outcome split into `delivered` /
433
490
  * `deferred` / `failed` arrays.
434
491
  *
@@ -437,6 +494,23 @@ async function _deliverOne(envelope, recipient, ctx) {
437
494
  * after the budget elapses. The primitive does not own a background
438
495
  * scheduler — operator job-runner owns the retry lifecycle.
439
496
  *
497
+ * DANE (RFC 7672) authenticates the peer, not just its DNS. When the peer
498
+ * publishes TLSA records, they are fetched and the certificate chain it
499
+ * presents during the handshake is matched against them; a peer whose chain
500
+ * matches none of its own records is refused and the next MX is tried.
501
+ *
502
+ * The TLSA lookup goes through `opts.resolver` when one is supplied, because
503
+ * the DNSSEC assertion below is a statement about THAT resolver. A resolver
504
+ * that cannot answer TLSA is refused rather than bypassed.
505
+ *
506
+ * Because RFC 7672 §1.3 forbids using records that were not DNSSEC-validated
507
+ * and node:dns does not expose the AD bit, only the operator can say whether
508
+ * their resolver validates. `policy.dnssecValidated: true` is that statement,
509
+ * and `dane: "enforce"` requires it: without it every peer that publishes TLSA
510
+ * would be refused while every peer that publishes none was delivered to.
511
+ * Under `"opportunistic"` the records are unusable without it, so none are
512
+ * fetched.
513
+ *
440
514
  * Failed recipients trigger DSN composition: a RFC 3464 multipart/
441
515
  * report message is built per failed recipient and handed to the
442
516
  * operator-supplied `dsn.onPermanentFailure(envelope, recipientResult,
@@ -452,6 +526,7 @@ async function _deliverOne(envelope, recipient, ctx) {
452
526
  * policy: {
453
527
  * mtaSts: "enforce" | "testing" | "off", // default "enforce" — RFC 8461 posture
454
528
  * dane: "opportunistic" | "enforce" | "off", // default "opportunistic" — RFC 7672
529
+ * dnssecValidated: boolean, // default false — assert the resolver DNSSEC-validates; required by dane "enforce"
455
530
  * },
456
531
  * retry: {
457
532
  * maxAttempts: number, // default 5
@@ -521,7 +596,7 @@ function create(opts) {
521
596
  var port = opts.port || DEFAULT_PORT_SMTP;
522
597
 
523
598
  var policy = opts.policy || {};
524
- validateOpts(policy, ["mtaSts", "dane"], "mail.send.deliver.create.policy");
599
+ validateOpts(policy, ["mtaSts", "dane", "dnssecValidated"], "mail.send.deliver.create.policy");
525
600
  var policyMtaSts = policy.mtaSts || "enforce";
526
601
  if (["enforce", "testing", "off"].indexOf(policyMtaSts) === -1) {
527
602
  throw new DeliverError("deliver/bad-policy-mtaSts",
@@ -532,6 +607,25 @@ function create(opts) {
532
607
  throw new DeliverError("deliver/bad-policy-dane",
533
608
  "mail.send.deliver.create.policy.dane must be opportunistic|enforce|off");
534
609
  }
610
+ // RFC 7672 §1.3 forbids using TLSA records that were not DNSSEC-validated,
611
+ // and node:dns does not surface the AD bit, so only the operator can say
612
+ // whether their resolver validates. Without that assertion `enforce` refuses
613
+ // every peer that publishes TLSA while delivering normally to every peer that
614
+ // publishes none — the sender's misconfiguration charged to the recipient who
615
+ // did the work. It is a property of this sender's resolver, so it is settled
616
+ // once at create() rather than surfacing per-peer as a delivery failure.
617
+ if (policy.dnssecValidated !== undefined && typeof policy.dnssecValidated !== "boolean") {
618
+ throw new DeliverError("deliver/bad-policy-dnssec",
619
+ "mail.send.deliver.create.policy.dnssecValidated must be a boolean when set");
620
+ }
621
+ var daneDnssecValidated = policy.dnssecValidated === true;
622
+ if (policyDane === "enforce" && !daneDnssecValidated) {
623
+ throw new DeliverError("deliver/dane-no-dnssec",
624
+ "policy.dane \"enforce\" requires policy.dnssecValidated: true — TLSA records " +
625
+ "must be DNSSEC-validated before use (RFC 7672 §1.3), and without that " +
626
+ "assertion enforce can only refuse the peers that publish them. Set it when " +
627
+ "the resolver validates DNSSEC; otherwise use \"opportunistic\" or \"off\"");
628
+ }
535
629
 
536
630
  var retryOpts = opts.retry || {};
537
631
  validateOpts(retryOpts, ["maxAttempts", "backoffMs"], "mail.send.deliver.create.retry");
@@ -578,8 +672,22 @@ function create(opts) {
578
672
  throw new DeliverError("deliver/bad-envelope",
579
673
  "deliver: envelope is required");
580
674
  }
581
- validateOpts.requireNonEmptyString(envelope.from,
582
- "deliver.envelope.from", DeliverError, "deliver/bad-envelope-from");
675
+ // The EMPTY string is a value here, not a missing one: RFC 5321 §4.5.5
676
+ // requires the null reverse path `MAIL FROM:<>` for a delivery status
677
+ // notification, and `b.mail.transports.smtp` already turns `""` into
678
+ // exactly that. Refusing it as "non-empty required" meant this composer
679
+ // could not send the one message shape the spec reserves a syntax for, and
680
+ // its own documented DSN example put a real address in the reverse path —
681
+ // so a bounce that failed would bounce back to the bounce's sender.
682
+ //
683
+ // The framework's other readers of an envelope sender already know this:
684
+ // `b.mail.spf.verify` treats an empty `mailFrom` as the null sender and
685
+ // falls back to the HELO identity, which is RFC 7208 §2.4 exactly.
686
+ if (typeof envelope.from !== "string") {
687
+ throw new DeliverError("deliver/bad-envelope-from",
688
+ "deliver.envelope.from must be a string — use \"\" for the null " +
689
+ "reverse path (MAIL FROM:<>) that RFC 5321 requires of a DSN");
690
+ }
583
691
  if (!Array.isArray(envelope.to) || envelope.to.length === 0) {
584
692
  throw new DeliverError("deliver/bad-envelope-to",
585
693
  "deliver.envelope.to must be a non-empty array");
@@ -596,7 +704,8 @@ function create(opts) {
596
704
 
597
705
  var ctx = {
598
706
  resolver: opts.resolver || null,
599
- policy: { mtaSts: policyMtaSts, dane: policyDane },
707
+ policy: { mtaSts: policyMtaSts, dane: policyDane,
708
+ dnssecValidated: daneDnssecValidated },
600
709
  hostname: opts.hostname,
601
710
  port: port,
602
711
  mxLookupTimeoutMs: mxLookupTimeoutMs,
@@ -638,6 +747,10 @@ function create(opts) {
638
747
  var idx = Math.min(attempts - 1, backoffMs.length - 1);
639
748
  deferred.push({
640
749
  recipient: res.recipient,
750
+ // The receiver the deferral is about, or null when none was
751
+ // reached. A queue that records the reason and the retry time and
752
+ // not the peer cannot answer the first question an operator asks.
753
+ mxHost: res.mxHost === undefined ? null : res.mxHost,
641
754
  reason: res.reason,
642
755
  reasonCode: res.reasonCode,
643
756
  attempt: attempts,
@@ -648,7 +761,13 @@ function create(opts) {
648
761
  }
649
762
  // permanent (either direct or transient-converted-to-permanent)
650
763
  var dsnSent = false;
651
- if (dsnOpts) {
764
+ // A message sent with the NULL reverse path gets no DSN. RFC 5321 §4.5.5
765
+ // is explicit, and the reason is the whole point of the null sender: the
766
+ // DSN's `To:` is the original `from`, so bouncing a bounce addresses it
767
+ // to nobody and, where the peer is less careful, to itself. This is the
768
+ // loop the empty reverse path exists to stop, and it is reachable the
769
+ // moment `""` is accepted above.
770
+ if (dsnOpts && envelope.from !== "") {
652
771
  try {
653
772
  var dsnMessage = _buildDsnMessage({
654
773
  dsnFrom: dsnOpts.from,
@@ -147,6 +147,7 @@ var codepointClass = require("./codepoint-class");
147
147
  // Centralized so the marker lives in one place
148
148
  // and the per-call sites read cleanly.
149
149
  var ERR_CLAMP = 200; // protocol-reply error-message clamp
150
+ var CRLF_BYTES = Buffer.from("\r\n", "latin1"); // RFC 9051 §2.2 line terminator, as octets
150
151
  var LINE_PREVIEW = 80; // audit-line preview clamp
151
152
 
152
153
  // RFC 9051 §6.3.12 + RFC 5322 §3.3 date-time parser for IMAP APPEND.
@@ -237,6 +238,15 @@ function _validateMailboxName(name, opts) {
237
238
  * rateLimit: b.mail.server.rateLimit handle | opts | false,
238
239
  * audit: b.audit // optional
239
240
  *
241
+ * `fetchRange` may return each row's `payload` as a Buffer, and should whenever
242
+ * the row carries message content. RFC 9051 §4.3 makes a literal a counted
243
+ * sequence of octets, and a string cannot hold one: a message octet that is not
244
+ * valid UTF-8 does not survive being encoded on the way to the socket, and the
245
+ * count the response announced stops matching the number of octets it wrote —
246
+ * which is what tells a client where the response ends. A Buffer payload is
247
+ * framed and written as the octets it holds. A string payload is unchanged and
248
+ * remains right for the rows that carry only attributes, such as `FLAGS (\Seen)`.
249
+ *
240
250
  * @example
241
251
  * var imap = b.mail.server.imap.create({
242
252
  * tlsContext: b.mail.server.tls.context({ certFile, keyFile }).secureContext,
@@ -450,7 +460,14 @@ function create(opts) {
450
460
  synchronizing: !parsed.literalNonSync,
451
461
  };
452
462
  if (!parsed.literalNonSync) {
453
- _writeUntagged(socket, "+ Ready for literal data");
463
+ // RFC 9051 §7.5 — a synchronizing literal is answered with a command
464
+ // continuation request: a line that BEGINS with `+`. Written as an
465
+ // untagged response, the wire carried `* + Ready for literal data`,
466
+ // which is not one, so a conforming client waited for a `+` line that
467
+ // never came and APPEND could not complete. The non-synchronizing
468
+ // route is no escape either: the strict profile's guard refuses
469
+ // LITERAL+ and CAPABILITY does not advertise it.
470
+ _writeContinuation(socket, "Ready for literal data");
454
471
  }
455
472
  return;
456
473
  }
@@ -731,7 +748,7 @@ function create(opts) {
731
748
  } else if (event.kind === "LIST") {
732
749
  _writeUntagged(socket, "LIST " + event.payload);
733
750
  } else if (event.kind === "FETCH") {
734
- _writeUntagged(socket, (event.seq || "") + " FETCH (" + (event.payload || "") + ")");
751
+ _writeUntagged(socket, _fetchResponse(event.seq || "", event.payload));
735
752
  }
736
753
  } catch (_e) { /* drop-silent — socket may already be closed */ }
737
754
  });
@@ -967,56 +984,49 @@ function create(opts) {
967
984
  }
968
985
  _emit("mail.server.imap.auth_attempt",
969
986
  { connectionId: state.id, mechanism: mechName, remoteAddress: state.remoteAddress });
970
- state.authPending = { mechanism: mechName, tag: tag, step: 0 };
987
+ state.authPending = { mech: mechName, tag: tag, step: 0 };
971
988
  _runAuthStep(state, socket, initialResp);
972
989
  }
973
990
 
974
991
  function _runAuthStep(state, socket, clientResp) {
975
992
  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;
993
+ function _fail(reason, outcome, reply) {
994
+ var failTag = pending.tag;
995
+ state.authPending = null;
996
+ rateLimit.noteAuthFailure(state.remoteAddress);
997
+ _emit("mail.server.imap.auth_failed",
998
+ { connectionId: state.id, mechanism: pending.mech, reason: reason }, outcome);
999
+ _writeTagged(socket, failTag, reply);
1000
+ }
1001
+ mailServerNet.runSaslStep({
1002
+ exchange: pending,
1003
+ verify: authConfig.verify,
1004
+ credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
1005
+ clientResponse: clientResp,
1006
+ // Server-side challenge — `+ <base64>` per RFC 9051 §6.2.2.
1007
+ writeChallenge: function (ch) { return _writeContinuation(socket, ch); },
1008
+ onChallengeUnsafe: function () {
1009
+ _fail("challenge-contains-line-terminator", "denied", "NO Authentication failed");
1010
+ },
1011
+ onSuccess: function (result) {
1012
+ state.actor = result.actor;
1013
+ state.stage = "authenticated";
1014
+ var savedTag = pending.tag;
1013
1015
  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
- });
1016
+ _emit("mail.server.imap.auth_success",
1017
+ { connectionId: state.id, mechanism: pending.mech,
1018
+ tenantId: result.actor.tenantId || null });
1019
+ _writeTagged(socket, savedTag,
1020
+ "OK [CAPABILITY " + _capabilityLine(state) + "] AUTHENTICATE completed");
1021
+ },
1022
+ onFailure: function (result) {
1023
+ _fail((result && result.reason) || "verify-returned-fail", "denied",
1024
+ "NO Authentication credentials invalid");
1025
+ },
1026
+ onError: function (err) {
1027
+ _fail((err && err.message) || String(err), "failure", "NO Authentication failed");
1028
+ },
1029
+ });
1020
1030
  }
1021
1031
 
1022
1032
  function _handleLogin(state, socket, tag, args) {
@@ -1582,10 +1592,20 @@ function create(opts) {
1582
1592
  for (var i = 0; i < rs.length; i += 1) {
1583
1593
  var r = rs[i];
1584
1594
  var payload = r.payload || "";
1585
- if (includeModseq && r.modseq !== undefined && !/MODSEQ\s*\(/.test(payload)) {
1586
- payload = (payload ? payload + " " : "") + "MODSEQ (" + r.modseq + ")";
1595
+ // A Buffer payload is the backend saying "these are the message's
1596
+ // own octets". It is assembled and written as octets end to end, so
1597
+ // the count in the literal header is the count that reaches the wire.
1598
+ // Concatenating it into a string instead re-encoded it as UTF-8, and
1599
+ // the response then announced one length and sent another.
1600
+ var octets = Buffer.isBuffer(payload);
1601
+ var asText = octets ? payload.toString("latin1") : String(payload);
1602
+ if (includeModseq && r.modseq !== undefined && !/MODSEQ\s*\(/.test(asText)) {
1603
+ var modseqAttr = (asText ? " " : "") + "MODSEQ (" + r.modseq + ")";
1604
+ payload = octets
1605
+ ? Buffer.concat([payload, Buffer.from(modseqAttr, "latin1")])
1606
+ : asText + modseqAttr;
1587
1607
  }
1588
- _writeUntagged(socket, r.seq + " FETCH (" + payload + ")");
1608
+ _writeUntagged(socket, _fetchResponse(r.seq, payload));
1589
1609
  }
1590
1610
  _writeTagged(socket, tag, "OK FETCH completed");
1591
1611
  })
@@ -1754,17 +1774,63 @@ function create(opts) {
1754
1774
  state.idle = { tag: tag, timer: timer };
1755
1775
  }
1756
1776
 
1777
+ // One line onto the socket, as OCTETS.
1778
+ //
1779
+ // RFC 9051 §4.3 makes a literal a counted sequence of octets, so a response
1780
+ // carrying message content has to reach the wire as the octets the backend
1781
+ // holds. `socket.write(string)` encodes as UTF-8, which replaces every
1782
+ // sequence that is not valid UTF-8 and changes the length — so the count the
1783
+ // response announced and the number of octets it wrote disagreed, and a
1784
+ // client, which uses that count to find the end of the response, read the
1785
+ // next response as part of this one.
1786
+ //
1787
+ // A Buffer `msg` is written through untouched. A string keeps its UTF-8
1788
+ // encoding, which is what RFC 9051 §5.1 asks for in the one place a response
1789
+ // string is not ASCII: a mailbox name, once the client has enabled UTF8=ACCEPT.
1790
+ // Encoding those latin1 instead would keep the low byte of each character and
1791
+ // corrupt every name outside Latin-1 — the same defect this fixes, moved.
1792
+ function _writeLine(socket, prefix, msg) {
1793
+ try {
1794
+ if (Buffer.isBuffer(msg)) {
1795
+ socket.write(Buffer.concat([Buffer.from(prefix, "latin1"), msg, CRLF_BYTES]));
1796
+ } else {
1797
+ socket.write(prefix + msg + "\r\n");
1798
+ }
1799
+ } catch (_e) { /* socket may be down */ }
1800
+ }
1801
+ // One untagged FETCH response. A Buffer payload is the backend saying "these
1802
+ // are the message's own octets", and the response is assembled as octets so
1803
+ // the count in the literal header is the count that reaches the wire; a
1804
+ // string payload is attributes and stays a string.
1805
+ //
1806
+ // Two places build one of these — the FETCH command and a NOTIFY push — and
1807
+ // a second copy of this is how the octet handling ends up right in one and
1808
+ // wrong in the other, which is what it was.
1809
+ function _fetchResponse(seq, payload) {
1810
+ if (Buffer.isBuffer(payload)) {
1811
+ return Buffer.concat([
1812
+ Buffer.from(seq + " FETCH (", "latin1"), payload, Buffer.from(")", "latin1"),
1813
+ ]);
1814
+ }
1815
+ return seq + " FETCH (" + (payload || "") + ")";
1816
+ }
1817
+
1757
1818
  function _writeTagged(socket, tag, msg) {
1758
- try { socket.write(tag + " " + msg + "\r\n"); }
1759
- catch (_e) { /* socket may be down */ }
1819
+ _writeLine(socket, tag + " ", msg);
1760
1820
  }
1761
1821
  function _writeUntagged(socket, msg) {
1762
- try { socket.write("* " + msg + "\r\n"); }
1763
- catch (_e) { /* socket may be down */ }
1822
+ _writeLine(socket, "* ", msg);
1764
1823
  }
1824
+ // RFC 9051 §7.5 continuation. Returns false when the operator's challenge
1825
+ // carries CR / LF / NUL: those end the line early and the remainder is read
1826
+ // by the client as a second server response. Not hypothetical for a SCRAM or
1827
+ // CRAM mechanism, whose challenge is composed partly from the client's own
1828
+ // nonce, so client bytes reach this write.
1765
1829
  function _writeContinuation(socket, msg) {
1766
- try { socket.write("+ " + msg + "\r\n"); }
1767
- catch (_e) { /* socket may be down */ }
1830
+ var safe = mailServerNet.saslChallengeOrNull(msg);
1831
+ if (safe === null) return false;
1832
+ _writeLine(socket, "+ ", safe);
1833
+ return true;
1768
1834
  }
1769
1835
  function _close(socket, state) {
1770
1836
  // The drain loop's `if (state.stage === "closed") return;` guard
@@ -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;