@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
package/lib/mail-helo.js CHANGED
@@ -370,6 +370,16 @@ async function _runFcrdns(ip, resolver) {
370
370
  if (!rev) {
371
371
  return result; // unparseable IP — caller already rejected
372
372
  }
373
+ // The resolver must be able to answer the question before the answer means
374
+ // anything. A missing method is not a DNS condition, and letting it fall into
375
+ // the catch below turned a broken call into a clean "no reverse name" for
376
+ // every address the check ever ran on — `passed` stayed false, which looks
377
+ // exactly like an address with no PTR record.
378
+ if (typeof resolver.queryPtr !== "function") {
379
+ throw new MailHeloError("mail-helo/resolver-missing-queryptr",
380
+ "fcrdns requires resolver.queryPtr(name); the supplied resolver has none, " +
381
+ "so reverse DNS cannot be checked and a pass cannot be claimed");
382
+ }
373
383
  try {
374
384
  var ptr = await resolver.queryPtr(rev);
375
385
  if (ptr && ptr.rrs) {
package/lib/mail-rbl.js CHANGED
@@ -253,9 +253,16 @@ function create(opts) {
253
253
  } catch (e) {
254
254
  // NXDOMAIN is the expected "not listed" response, not an error
255
255
  // condition. RFC 5782 §2.1.1 — absence of any A record means
256
- // "not in list". Resolver surfaces this as resolver/nxdomain-or-
257
- // error which we treat as the neutral verdict.
258
- if (e && e.code === "resolver/nxdomain-or-error") {
256
+ // "not in list".
257
+ //
258
+ // ONLY NXDOMAIN. The resolver used to report every non-zero RCODE under
259
+ // one code, so this branch also caught SERVFAIL and REFUSED and returned
260
+ // the same clean verdict — a blocklist lookup that failed read exactly
261
+ // like a host that is not on the list. Anyone able to break the query
262
+ // could therefore clear themselves, which is the wrong direction for a
263
+ // blocklist to fail in. A failure now falls through to `rv.error` below,
264
+ // where the caller can see it.
265
+ if (e && e.code === "resolver/nxdomain") {
259
266
  // Neutral — not listed; not an error.
260
267
  return rv;
261
268
  }
@@ -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,