@blamejs/core 0.14.18 → 0.14.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mail-auth.js CHANGED
@@ -13,21 +13,21 @@
13
13
  * b.mail.dmarc.evaluate({ from, spf, dkim, dnsLookup }) → result
14
14
  * b.mail.arc.verify(rfc822, opts) → chain status
15
15
  *
16
- * SPF (RFC 7208) — ip4 / ip6 / a / mx / include / all / redirect=
17
- * mechanisms.
16
+ * SPF (RFC 7208) — ip4 / ip6 / a / mx / include / exists / all /
17
+ * redirect= mechanisms, with macro-string expansion (§7).
18
18
  * Mechanism limit: 10 DNS lookups per RFC 7208 §4.6.4 (with the
19
19
  * void-lookup sub-limit at 2). The `a` and `mx` arms honor RFC
20
- * §5.3 / §5.4 dual-cidr-length syntax (`a:foo.com/24//64`).
20
+ * §5.3 / §5.4 dual-cidr-length syntax (`a:foo.com/24//64`). The
21
+ * `exists` mechanism (§5.7) and include / redirect targets honor
22
+ * §7 macro expansion (`%{i}` / `%{s}` / `%{l}` / `%{d}` / `%{o}` /
23
+ * `%{h}` / `%{v}` / `%{p}` plus the digit / `r` / delimiter
24
+ * transformers); the §4.6.4 lookup + void ceilings still bound the
25
+ * macro-driven exists / a / mx queries.
21
26
  *
22
- * Deferred mechanisms (each carries an explicit Re-open condition
23
- * in the dispatch arm in this file):
24
- * - exists: requires macro-string expansion (§7) to be useful;
25
- * re-opens when macros land OR an operator surfaces a
26
- * real macro-less `exists:` policy.
27
+ * Deferred mechanism (carries an explicit Re-open condition in the
28
+ * dispatch arm in this file):
27
29
  * - ptr: "strongly discouraged" by §5.5; re-opens when an
28
30
  * operator surfaces a legitimate ptr-only sender.
29
- * - macro-string expansion (§7) itself — separate slice tracked
30
- * under blamejs-roadmap.md.
31
31
  *
32
32
  * DMARC (RFC 7489) — TXT record at _dmarc.<domain>; alignment check
33
33
  * between From-header domain and DKIM-d / SPF-from-domain;
@@ -50,6 +50,7 @@ var validateOpts = require("./validate-opts");
50
50
  var bCrypto = require("./crypto");
51
51
  var C = require("./constants");
52
52
  var dkim = require("./mail-dkim");
53
+ var mimeParse = require("./mime-parse");
53
54
  var safeXml = require("./parsers/safe-xml");
54
55
  var ipUtils = require("./ip-utils");
55
56
  var publicSuffix = require("./public-suffix");
@@ -308,6 +309,214 @@ function _ipv4InCidr(ip, cidr) {
308
309
  return (BigInt(ipInt) & maskInt) === (BigInt(netInt) & maskInt);
309
310
  }
310
311
 
312
+ // ---- SPF macro-string expansion (RFC 7208 §7) ----
313
+ //
314
+ // A macro-string is `*( macro-expand / macro-literal )`. A macro-expand
315
+ // is `"%{" macro-letter transformers *delimiter "}"` (RFC 7208 §7.1).
316
+ // The legacy `%%`, `%_`, `%-` escapes expand to "%", " ", "%20".
317
+ //
318
+ // Macro letters (RFC 7208 §7.2):
319
+ // s = <sender> (the MAIL FROM / HELO identity, localpart@domain)
320
+ // l = local-part of <sender>
321
+ // o = domain of <sender>
322
+ // d = <domain> (the SPF record's current domain)
323
+ // i = <ip> (dotted-decimal for IPv4; nibble-dotted-hex for IPv6)
324
+ // p = the validated domain name of <ip> (PTR — discouraged §5.5; "unknown"
325
+ // absent a validated name; the framework returns "unknown" rather
326
+ // than performing the discouraged reverse-lookup)
327
+ // v = "in-addr" for IPv4, "ip6" for IPv6
328
+ // h = HELO/EHLO domain
329
+ // c / r / t = SMTP-time-only macros (exp= text); not valid in a
330
+ // checked macro-string, so we expand them to empty in mechanism
331
+ // context per §7.3's split between "macro-string" and the
332
+ // exp-only letters.
333
+ //
334
+ // Transformers (RFC 7208 §7.1): an optional digit count limits the
335
+ // number of right-hand parts kept after a split; an optional `r`
336
+ // reverses the parts; an optional delimiter set (any of `.-+,/_=`)
337
+ // replaces "." as the split delimiter. After transforms, the parts are
338
+ // re-joined with ".".
339
+ //
340
+ // Length bound: the expanded macro-string is capped so a hostile policy
341
+ // can't inflate a DNS qname past the RFC 1035 §3.1 255-octet ceiling
342
+ // (the resulting name is used as a DNS query). RFC 7208 §7.1 mandates a
343
+ // 253-octet limit on the constructed domain-name; we cap the assembled
344
+ // string the same way.
345
+ var SPF_MACRO_MAX_EXPANDED_BYTES = 253; // RFC 1035 §3.1 / RFC 7208 §7.1 name ceiling
346
+ var SPF_MACRO_DELIMS = ".-+,/_="; // RFC 7208 §7.1 delimiter set
347
+
348
+ // IPv6 nibble-dotted form for the `i` macro (RFC 7208 §7.3): each of the
349
+ // 32 hex nibbles becomes its own "."-separated part. e.g.
350
+ // 2001:db8::1 → "2.0.0.1.0.d.b.8.0.…0.0.0.1".
351
+ function _ipv6Nibbles(ip) {
352
+ var groups = ipUtils.expandIpv6Groups(ip);
353
+ if (!groups) return null;
354
+ var nibbles = [];
355
+ for (var i = 0; i < groups.length; i += 1) {
356
+ var s = groups[i].toString(16); // hex radix
357
+ while (s.length < 4) s = "0" + s; // IPv6 group nibble count
358
+ for (var j = 0; j < 4; j += 1) nibbles.push(s.charAt(j)); // IPv6 group nibble count
359
+ }
360
+ return nibbles;
361
+ }
362
+
363
+ // Resolve a single macro letter to its base string value (pre-transform).
364
+ // `vars` carries { ip, isIpv6, sender, localPart, senderDomain, domain,
365
+ // helo }. Letters not meaningful in mechanism context expand to "".
366
+ function _spfMacroValue(letter, vars) {
367
+ var lower = letter.toLowerCase();
368
+ switch (lower) {
369
+ case "s": return vars.sender || "";
370
+ case "l": return vars.localPart || "";
371
+ case "o": return vars.senderDomain || "";
372
+ case "d": return vars.domain || "";
373
+ case "h": return vars.helo || "";
374
+ case "v": return vars.isIpv6 ? "ip6" : "in-addr";
375
+ case "i":
376
+ if (vars.isIpv6) {
377
+ var nib = _ipv6Nibbles(vars.ip);
378
+ return nib ? nib.join(".") : "";
379
+ }
380
+ return vars.ip || "";
381
+ // RFC 7208 §5.5 — `p` (validated domain name) is "strongly
382
+ // discouraged"; resolving it requires the reverse-DNS path the
383
+ // framework intentionally does not perform here. Expand to the
384
+ // RFC-mandated sentinel so an `exists:%{p}...` policy degrades to a
385
+ // deterministic miss rather than a forged match.
386
+ case "p": return "unknown";
387
+ // c / r / t are exp-text-only macros (RFC 7208 §7.3); empty in
388
+ // mechanism context.
389
+ default: return "";
390
+ }
391
+ }
392
+
393
+ // Split `value` on the active delimiter chars, optionally reverse, keep
394
+ // the rightmost `digits` parts, re-join with ".". RFC 7208 §7.1.
395
+ function _spfApplyTransform(value, digits, reverse, delims) {
396
+ if (value.length === 0) return "";
397
+ // Build a character class from the (validated) delimiter set. Each
398
+ // delim char is one of `.-+,/_=` — all regex-safe except none need
399
+ // escaping inside a class except `-` which we place last; the set is
400
+ // a fixed allowlist so no untrusted metacharacter reaches the class.
401
+ var splitParts;
402
+ if (delims === ".") {
403
+ splitParts = value.split(".");
404
+ } else {
405
+ var out = [];
406
+ var cur = "";
407
+ for (var ci = 0; ci < value.length; ci += 1) {
408
+ var ch = value.charAt(ci);
409
+ if (delims.indexOf(ch) !== -1) { out.push(cur); cur = ""; }
410
+ else cur += ch;
411
+ }
412
+ out.push(cur);
413
+ splitParts = out;
414
+ }
415
+ if (reverse) splitParts = splitParts.slice().reverse();
416
+ if (digits !== null && digits > 0 && digits < splitParts.length) {
417
+ splitParts = splitParts.slice(splitParts.length - digits);
418
+ }
419
+ return splitParts.join(".");
420
+ }
421
+
422
+ // Expand an SPF macro-string (RFC 7208 §7.1). `vars` is the macro
423
+ // variable bag. Throws MailAuthError on malformed `%` syntax (a bare
424
+ // `%` not followed by `{`, `%`, `_`, or `-` is a syntax error per
425
+ // §7.1 — receivers MUST permerror, mirrored by the caller catching the
426
+ // throw). The expanded result is byte-capped (§7.1 / RFC 1035 §3.1).
427
+ //
428
+ // The scanner is a single linear left-to-right pass (no backtracking
429
+ // regex over untrusted input): each `%{...}` token is matched by an
430
+ // index walk to the closing `}`, bounding work at O(n) in the macro
431
+ // length.
432
+ function _spfExpandMacros(macroString, vars) {
433
+ if (typeof macroString !== "string" || macroString.indexOf("%") === -1) {
434
+ return macroString;
435
+ }
436
+ var out = "";
437
+ var n = macroString.length;
438
+ var i = 0;
439
+ while (i < n) {
440
+ var ch = macroString.charAt(i);
441
+ if (ch !== "%") { out += ch; i += 1; continue; }
442
+ // ch === "%": peek the next char.
443
+ if (i + 1 >= n) {
444
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
445
+ "SPF macro-string ends with a bare '%' (RFC 7208 §7.1)");
446
+ }
447
+ var next = macroString.charAt(i + 1);
448
+ if (next === "%") { out += "%"; i += 2; continue; }
449
+ if (next === "_") { out += " "; i += 2; continue; }
450
+ if (next === "-") { out += "%20"; i += 2; continue; }
451
+ if (next !== "{") {
452
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
453
+ "SPF macro escape '%" + next + "' is invalid (RFC 7208 §7.1 allows %%, %_, %-, %{...})");
454
+ }
455
+ // next === "{": find the closing "}".
456
+ var close = macroString.indexOf("}", i + 2);
457
+ if (close === -1) {
458
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
459
+ "SPF macro '%{' has no closing '}' (RFC 7208 §7.1)");
460
+ }
461
+ var body = macroString.slice(i + 2, close);
462
+ // body = macro-letter [ digits ] [ "r" ] *delimiter (RFC 7208 §7.1)
463
+ if (body.length === 0) {
464
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
465
+ "SPF macro '%{}' is empty (RFC 7208 §7.1)");
466
+ }
467
+ var letter = body.charAt(0);
468
+ if (!/^[slodiphcrtv]$/i.test(letter)) {
469
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
470
+ "SPF macro letter " + JSON.stringify(letter) + " is not a valid macro-letter (RFC 7208 §7.2)");
471
+ }
472
+ var rest = body.slice(1);
473
+ var digits = null;
474
+ var di = 0;
475
+ while (di < rest.length && rest.charAt(di) >= "0" && rest.charAt(di) <= "9") di += 1;
476
+ if (di > 0) {
477
+ digits = parseInt(rest.slice(0, di), 10);
478
+ if (!isFinite(digits) || digits < 1) {
479
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
480
+ "SPF macro transformer digit count must be >= 1 (RFC 7208 §7.1): " + JSON.stringify(body));
481
+ }
482
+ }
483
+ rest = rest.slice(di);
484
+ var reverse = false;
485
+ if (rest.length > 0 && (rest.charAt(0) === "r" || rest.charAt(0) === "R")) {
486
+ reverse = true;
487
+ rest = rest.slice(1);
488
+ }
489
+ // Remaining chars are the optional delimiter set; each MUST be one
490
+ // of the RFC 7208 §7.1 delimiters. Anything else is malformed.
491
+ var delims = "";
492
+ for (var ri = 0; ri < rest.length; ri += 1) {
493
+ var dch = rest.charAt(ri);
494
+ if (SPF_MACRO_DELIMS.indexOf(dch) === -1) {
495
+ throw new MailAuthError("mail-auth/spf-macro-bad-syntax",
496
+ "SPF macro delimiter " + JSON.stringify(dch) + " is not in the RFC 7208 §7.1 set " +
497
+ JSON.stringify(SPF_MACRO_DELIMS));
498
+ }
499
+ if (delims.indexOf(dch) === -1) delims += dch;
500
+ }
501
+ if (delims.length === 0) delims = ".";
502
+ var base = _spfMacroValue(letter, vars);
503
+ out += _spfApplyTransform(base, digits, reverse, delims);
504
+ i = close + 1;
505
+ }
506
+ if (out.length > SPF_MACRO_MAX_EXPANDED_BYTES) {
507
+ // RFC 7208 §7.1 — the constructed domain-name is left-truncated to
508
+ // fit the 253-octet ceiling: leading labels are discarded until the
509
+ // remainder fits. This keeps the trailing (more-significant) labels
510
+ // the policy author intends as the lookup target.
511
+ while (out.length > SPF_MACRO_MAX_EXPANDED_BYTES) {
512
+ var dot = out.indexOf(".");
513
+ if (dot === -1) { out = out.slice(out.length - SPF_MACRO_MAX_EXPANDED_BYTES); break; }
514
+ out = out.slice(dot + 1);
515
+ }
516
+ }
517
+ return out;
518
+ }
519
+
311
520
  // Parse an SPF record into mechanisms.
312
521
  function _parseSpfRecord(text) {
313
522
  var trimmed = text.trim();
@@ -503,11 +712,25 @@ function _parseADualCidr(raw, mech, defaultDomain) {
503
712
  // { match: false } — no IP matched / record absent
504
713
  // { error: "temperror", reason: "..." } — transient DNS failure
505
714
  // { error: "permerror", reason: "..." } — over-limit / bad CIDR / bad MX count
506
- async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups) {
715
+ async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, lookups, macroVars) {
507
716
  var parsed;
508
717
  try { parsed = _parseADualCidr(raw, mech, defaultDomain); }
509
718
  catch (e) { return { error: "permerror", reason: e.message }; }
510
719
 
720
+ // RFC 7208 §5.3 / §5.4 — the domain-spec after `a:` / `mx:` is a
721
+ // macro-string (§7). Expand it before resolving so policies like
722
+ // `a:%{i}._ah.example.com` evaluate correctly. The default-domain
723
+ // case (`a` / `mx` with no `:domain`) carries no `%` and passes
724
+ // through untouched.
725
+ if (macroVars && parsed.domain.indexOf("%") !== -1) {
726
+ try { parsed.domain = _spfExpandMacros(parsed.domain, macroVars).toLowerCase(); }
727
+ catch (e) { return { error: "permerror", reason: e.message }; }
728
+ if (!parsed.domain || parsed.domain.length === 0) {
729
+ return { error: "permerror",
730
+ reason: "SPF " + mech + ": domain-spec expanded to empty (RFC 7208 §7)" };
731
+ }
732
+ }
733
+
511
734
  var mask = isIpv6 ? parsed.v6Mask : parsed.v4Mask;
512
735
  var family = isIpv6 ? 6 : 4; // IP family marker
513
736
 
@@ -580,9 +803,9 @@ async function _spfMatchAMx(mech, raw, ip, isIpv6, defaultDomain, dnsLookup, loo
580
803
  }
581
804
 
582
805
  // SPF verify — recursive include resolution + ip4 / ip6 / a / mx /
583
- // include / all / redirect=. The `exists` and `ptr` mechanisms +
584
- // macro-string expansion remain deferred (see the mechanism dispatch
585
- // arm for the Re-open condition + operator escape hatch).
806
+ // include / exists / all / redirect=, with RFC 7208 §7 macro expansion.
807
+ // The `ptr` mechanism remains deferred (see the dispatch arm for the
808
+ // Re-open condition + operator escape hatch via b.mail.iprev.verify).
586
809
  async function spfVerify(opts) {
587
810
  opts = opts || {};
588
811
  validateOpts(opts, ["ip", "mailFrom", "helo", "dnsLookup"], "mail.spf.verify");
@@ -599,6 +822,29 @@ async function spfVerify(opts) {
599
822
  }
600
823
 
601
824
  var lookups = { count: 0, limit: SPF_DNS_LOOKUP_LIMIT, void: 0 };
825
+ // RFC 7208 §7 macro variable bag. `<sender>` is the MAIL FROM identity
826
+ // when present, else `postmaster@<helo>` per §4.3 (the localpart
827
+ // defaults to "postmaster" when the reverse-path is empty / HELO is
828
+ // the checked identity). `<domain>` (%{d}) tracks the SPF record's
829
+ // current domain and is rebound at each include/redirect re-entry.
830
+ var senderIdentity = opts.mailFrom
831
+ ? String(opts.mailFrom)
832
+ : ("postmaster@" + String(opts.helo || domain));
833
+ var senderLocal = senderIdentity.indexOf("@") !== -1
834
+ ? senderIdentity.slice(0, senderIdentity.indexOf("@"))
835
+ : "postmaster";
836
+ var senderDomain = senderIdentity.indexOf("@") !== -1
837
+ ? senderIdentity.slice(senderIdentity.indexOf("@") + 1)
838
+ : String(opts.helo || domain);
839
+ var macroVars = {
840
+ ip: opts.ip,
841
+ isIpv6: opts.ip.indexOf(":") !== -1,
842
+ sender: senderIdentity,
843
+ localPart: senderLocal,
844
+ senderDomain: senderDomain,
845
+ domain: domain.toLowerCase(),
846
+ helo: typeof opts.helo === "string" ? opts.helo : "",
847
+ };
602
848
  // RFC 7208 §4.6.4 — the initial query for the sender domain's SPF
603
849
  // record itself does NOT count toward the 10-lookup limit. Only
604
850
  // include / a / mx / ptr / exists / redirect mechanisms count.
@@ -606,7 +852,7 @@ async function spfVerify(opts) {
606
852
  // got false permerror.
607
853
  var result = await _spfEvaluateDomain(domain.toLowerCase(), opts.ip,
608
854
  opts.dnsLookup, lookups,
609
- { isInitial: true });
855
+ { isInitial: true, macroVars: macroVars });
610
856
  return {
611
857
  result: result.verdict, // pass | fail | softfail | neutral | none | temperror | permerror
612
858
  domain: domain,
@@ -660,6 +906,12 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
660
906
  return { verdict: "permerror", explanation: e.message };
661
907
  }
662
908
 
909
+ // RFC 7208 §7.2 — `%{d}` is the SPF record's CURRENT domain, which is
910
+ // rebound at each include / redirect re-entry. Clone the inherited
911
+ // macro bag with `domain` pinned to the domain we're evaluating now.
912
+ var baseMacroVars = ctx.macroVars || {};
913
+ var macroVars = Object.assign({}, baseMacroVars, { domain: domain });
914
+
663
915
  var isIpv6 = ip.indexOf(":") !== -1;
664
916
  for (var i = 0; i < mechanisms.length; i += 1) {
665
917
  var m = mechanisms[i];
@@ -671,7 +923,15 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
671
923
  if (m.arg && _ipv6InCidr(ip, m.arg)) match = true;
672
924
  } else if (m.mechanism === "include") {
673
925
  if (!m.arg) continue;
674
- var inner = await _spfEvaluateDomain(m.arg.toLowerCase(), ip, dnsLookup, lookups);
926
+ // RFC 7208 §7 the include target may itself be a macro-string
927
+ // (e.g. `include:%{d}.spf.example.net`). Expand against the
928
+ // current macro bag before recursing.
929
+ var includeTarget;
930
+ try { includeTarget = _spfExpandMacros(m.arg, macroVars); }
931
+ catch (e) { return { verdict: "permerror", explanation: e.message }; }
932
+ var inner = await _spfEvaluateDomain(includeTarget.toLowerCase(), ip,
933
+ dnsLookup, lookups,
934
+ { macroVars: macroVars });
675
935
  if (inner.verdict === "pass") match = true;
676
936
  else if (inner.verdict === "permerror" || inner.verdict === "temperror") {
677
937
  return inner;
@@ -701,7 +961,7 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
701
961
  m.mechanism };
702
962
  }
703
963
  var amRes = await _spfMatchAMx(m.mechanism, m.raw, ip, isIpv6,
704
- domain, dnsLookup, lookups);
964
+ domain, dnsLookup, lookups, macroVars);
705
965
  if (amRes.error === "permerror") {
706
966
  return { verdict: "permerror", explanation: amRes.reason };
707
967
  }
@@ -709,45 +969,69 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
709
969
  return { verdict: "temperror", explanation: amRes.reason };
710
970
  }
711
971
  if (amRes.match) match = true;
712
- } else if (m.mechanism === "exists" || m.mechanism === "ptr") {
713
- // RFC 7208 §5.7 (exists) + §5.5 (ptr) — deferred from v0.11.3.
714
- //
715
- // exists: requires macro-string expansion (RFC 7208 §7) to be
716
- // useful in practice; almost every published `exists:` policy
717
- // uses macros like `exists:%{l}.%{d}._spf.example.com` to do
718
- // per-recipient or per-IP lookups. A non-macro `exists:` is
719
- // technically valid but vanishingly rare in published policies.
720
- //
721
- // ptr: RFC 7208 §5.5 explicitly says "use of this mechanism
722
- // is strongly discouraged" the receiver does reverse-DNS +
723
- // forward-confirm per query, doubling DNS load and tying the
724
- // sender's authz to whoever controls their PTR zone. Despite
725
- // this discouragement, a small minority of legacy senders
726
- // still publish `+ptr -all` policies as their only SPF stance.
972
+ } else if (m.mechanism === "exists") {
973
+ // RFC 7208 §5.7 — `exists:<domain-spec>`. The domain-spec is
974
+ // macro-expanded (§7) and an A query is performed; the mechanism
975
+ // matches when ANY A record exists (the address is irrelevant
976
+ // existence alone is the signal, so an AAAA-only target does NOT
977
+ // match per the spec's "A query" wording). Published policies use
978
+ // it for per-IP / per-recipient lookups like
979
+ // `exists:%{ir}.%{v}._spf.example.com`.
980
+ if (!m.arg) continue;
981
+ var existsTarget;
982
+ try { existsTarget = _spfExpandMacros(m.arg, macroVars); }
983
+ catch (e) { return { verdict: "permerror", explanation: e.message }; }
984
+ if (!existsTarget || existsTarget.length === 0) {
985
+ return { verdict: "permerror",
986
+ explanation: "SPF exists: expanded to an empty domain (RFC 7208 §5.7)" };
987
+ }
988
+ // §4.6.4 — the exists A query counts as one DNS-touching lookup.
989
+ lookups.count += 1;
990
+ if (lookups.count > lookups.limit) {
991
+ return { verdict: "permerror",
992
+ explanation: "DNS lookup limit exceeded (RFC 7208 §4.6.4) at exists:" +
993
+ existsTarget };
994
+ }
995
+ var existsHit = false;
996
+ try {
997
+ var existsIps = await _safeResolveA(existsTarget.toLowerCase(), 4, dnsLookup);
998
+ existsHit = Array.isArray(existsIps) && existsIps.length > 0;
999
+ } catch (e) {
1000
+ var ecode = e && e.code;
1001
+ if (ecode === "ENOTFOUND" || ecode === "ENODATA") {
1002
+ // Void lookup — RFC 7208 §4.6.4 ceiling. A non-existent target
1003
+ // is a miss, not an error, but charges the void slot so a
1004
+ // chain of exists: misses can't amplify resolver work.
1005
+ lookups.void = (lookups.void || 0) + 1;
1006
+ if (lookups.void > SPF_VOID_LOOKUP_LIMIT) {
1007
+ return { verdict: "permerror",
1008
+ explanation: "SPF void-lookup limit exceeded (RFC 7208 §4.6.4) during exists: evaluation" };
1009
+ }
1010
+ existsHit = false;
1011
+ } else {
1012
+ return { verdict: "temperror",
1013
+ explanation: "SPF exists:" + existsTarget + " lookup failed: " +
1014
+ ((e && e.message) || String(e)) };
1015
+ }
1016
+ }
1017
+ if (existsHit) match = true;
1018
+ } else if (m.mechanism === "ptr") {
1019
+ // RFC 7208 §5.5 — `ptr` is "strongly discouraged": it ties the
1020
+ // sender's authorization to whoever controls the connecting IP's
1021
+ // PTR zone and doubles DNS load (reverse + forward-confirm per
1022
+ // query). A small minority of legacy senders still publish
1023
+ // `+ptr -all` as their only stance.
727
1024
  //
728
- // Re-open conditions:
729
- // - exists: macro-string expansion lands in the framework (a
730
- // standalone slice; tracked under blamejs-roadmap.md), OR an
731
- // operator surfaces a real `exists:` policy without macros
732
- // and asks for the simple A-existence form.
733
- // - ptr: an operator surfaces a legitimate sender whose
734
- // ONLY SPF stance is `ptr` and needs the framework to
735
- // evaluate it (rather than the operator's MTA already doing
736
- // iprev via `b.mail.auth.iprev`).
1025
+ // Re-open condition: an operator surfaces a legitimate sender
1026
+ // whose ONLY SPF stance is `ptr` and needs the framework to
1027
+ // evaluate it rather than the MTA already doing iprev.
737
1028
  //
738
- // Operator escape hatch today:
739
- // - exists: senders almost universally have a non-`exists:`
740
- // mechanism alongside; the framework returns "permerror"
741
- // here, surfacing the gap, but legitimate mail flow that
742
- // ALSO carries a passing ip4/ip6/include path is unaffected.
743
- // - ptr: operators evaluating a ptr-only sender wire
744
- // `b.mail.auth.iprev(ip)` and treat fcrdns=true the same as
745
- // SPF pass for that domain.
1029
+ // Operator escape hatch today: wire `b.mail.iprev.verify(ip)` and
1030
+ // treat fcrdns=true the same as an SPF pass for that domain.
746
1031
  return {
747
1032
  verdict: "permerror",
748
- explanation: "SPF mechanism '" + m.mechanism + "' is not yet implemented (RFC 7208 §" +
749
- (m.mechanism === "exists" ? "5.7 + §7 macros" : "5.5") +
750
- "); senders typically publish ip4 / ip6 / a / mx / include alongside",
1033
+ explanation: "SPF mechanism 'ptr' is not implemented (RFC 7208 §5.5 — strongly " +
1034
+ "discouraged); use b.mail.iprev.verify for forward-confirmed reverse DNS",
751
1035
  };
752
1036
  }
753
1037
  if (match) {
@@ -772,10 +1056,14 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
772
1056
  var mods = mechanisms.modifiers || [];
773
1057
  for (var rmi = 0; rmi < mods.length; rmi += 1) {
774
1058
  if (mods[rmi].name === "redirect" && mods[rmi].value) {
775
- // Redirect counts as one DNS-mechanism per §4.6.4.
1059
+ // Redirect counts as one DNS-mechanism per §4.6.4. RFC 7208 §7 —
1060
+ // the redirect target may be a macro-string; expand it first.
1061
+ var redirectTarget;
1062
+ try { redirectTarget = _spfExpandMacros(mods[rmi].value, macroVars); }
1063
+ catch (e) { return { verdict: "permerror", explanation: e.message }; }
776
1064
  var redirected = await _spfEvaluateDomain(
777
- mods[rmi].value.toLowerCase(), ip, dnsLookup, lookups,
778
- { redirectDepth: (ctx.redirectDepth || 0) + 1 });
1065
+ redirectTarget.toLowerCase(), ip, dnsLookup, lookups,
1066
+ { redirectDepth: (ctx.redirectDepth || 0) + 1, macroVars: macroVars });
779
1067
  // RFC 7208 §6.1 — if the redirect target has no SPF record,
780
1068
  // permerror (the operator's intent is unverifiable).
781
1069
  if (redirected.verdict === "none") {
@@ -1991,6 +2279,217 @@ function _shapeAggregateReport(parsed) {
1991
2279
  return shaped;
1992
2280
  }
1993
2281
 
2282
+ // ---- DMARC aggregate (RUA) report builder/serializer (RFC 7489 Appendix C) ----
2283
+ //
2284
+ // The inverse of dmarcParseAggregateReport: an MTA acting as the
2285
+ // REPORTING side (it received mail under another domain's DMARC policy
2286
+ // and now owes that domain an aggregate report) serializes its
2287
+ // observation rows into the RFC 7489 Appendix C `<feedback>` XML.
2288
+ //
2289
+ // The builder accepts the SAME shaped object dmarcParseAggregateReport
2290
+ // returns (reportMetadata / policyPublished / records[...]), so a parsed
2291
+ // report round-trips back to identical structure. Operators may also
2292
+ // hand-assemble the shape directly.
2293
+ //
2294
+ // var xml = b.mail.dmarc.buildAggregateReport({
2295
+ // reportMetadata: { orgName, email, reportId, dateRange: { begin, end } },
2296
+ // policyPublished: { domain, adkim, aspf, p, sp, pct },
2297
+ // records: [{ sourceIp, count,
2298
+ // dispositions: { disposition, dkim, spf, reasons },
2299
+ // identifiers: { headerFrom, envelopeFrom, envelopeTo },
2300
+ // authResults: { dkim: [...], spf: [...] } }],
2301
+ // });
2302
+ // // → "<?xml version=\"1.0\" ...?>\n<feedback>...</feedback>"
2303
+ //
2304
+ // Validation tier: config-time/entry-point — the report shape is
2305
+ // operator-assembled structured data, so a malformed shape (missing
2306
+ // reportMetadata / policyPublished / non-array records) THROWS so the
2307
+ // operator catches the mistake before the report is mailed to a peer.
2308
+ //
2309
+ // XML safety: every emitted text node and the (rare) attribute-free
2310
+ // element bodies are escaped through _xmlEscapeText, which neutralizes
2311
+ // `& < > " '`. Source IPs, domains, and identifiers can carry
2312
+ // attacker-influenced bytes (a spoofed envelope-from observed in the
2313
+ // wild); escaping prevents a crafted observation from injecting markup
2314
+ // into the report a peer will parse.
2315
+
2316
+ // RFC 7489 Appendix C — the report is plain-element XML (no attributes
2317
+ // in the schema), so only the five XML text-content metacharacters need
2318
+ // neutralizing. Numeric / enum fields are coerced and range-checked
2319
+ // before they reach here, but escaping is applied uniformly so a future
2320
+ // caller can't bypass it.
2321
+ function _xmlEscapeText(value) {
2322
+ return String(value)
2323
+ .replace(/&/g, "&amp;")
2324
+ .replace(/</g, "&lt;")
2325
+ .replace(/>/g, "&gt;")
2326
+ .replace(/"/g, "&quot;")
2327
+ .replace(/'/g, "&apos;");
2328
+ }
2329
+
2330
+ // Emit `<tag>escaped-text</tag>` when value is non-null/defined; emit
2331
+ // nothing when the field is absent (RFC 7489 Appendix C marks many
2332
+ // child elements optional — omitting is correct, emitting an empty
2333
+ // element changes the parsed shape).
2334
+ function _xmlLeaf(tag, value) {
2335
+ if (value === undefined || value === null || value === "") return "";
2336
+ return "<" + tag + ">" + _xmlEscapeText(value) + "</" + tag + ">";
2337
+ }
2338
+
2339
+ // Integer leaf — coerce, refuse non-finite (a NaN count would serialize
2340
+ // as the string "NaN" and corrupt the peer's parse).
2341
+ function _xmlIntLeaf(tag, value) {
2342
+ if (value === undefined || value === null) return "";
2343
+ var n = typeof value === "number" ? value : parseInt(value, 10);
2344
+ if (!isFinite(n)) {
2345
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-int",
2346
+ "dmarc.buildAggregateReport: " + tag + " must be a finite integer, got " + JSON.stringify(value));
2347
+ }
2348
+ return "<" + tag + ">" + String(Math.trunc(n)) + "</" + tag + ">";
2349
+ }
2350
+
2351
+ function _buildAuthResultsXml(authResults) {
2352
+ var ar = authResults || {};
2353
+ var parts = [];
2354
+ var dkimRows = Array.isArray(ar.dkim) ? ar.dkim : [];
2355
+ for (var i = 0; i < dkimRows.length; i += 1) {
2356
+ var d = dkimRows[i] || {};
2357
+ parts.push(
2358
+ "<dkim>" +
2359
+ _xmlLeaf("domain", d.domain) +
2360
+ _xmlLeaf("selector", d.selector) +
2361
+ _xmlLeaf("result", d.result) +
2362
+ _xmlLeaf("human_result", d.humanResult) +
2363
+ "</dkim>");
2364
+ }
2365
+ var spfRows = Array.isArray(ar.spf) ? ar.spf : [];
2366
+ for (var j = 0; j < spfRows.length; j += 1) {
2367
+ var s = spfRows[j] || {};
2368
+ parts.push(
2369
+ "<spf>" +
2370
+ _xmlLeaf("domain", s.domain) +
2371
+ _xmlLeaf("scope", s.scope) +
2372
+ _xmlLeaf("result", s.result) +
2373
+ "</spf>");
2374
+ }
2375
+ return "<auth_results>" + parts.join("") + "</auth_results>";
2376
+ }
2377
+
2378
+ function dmarcBuildAggregateReport(report, opts) {
2379
+ opts = opts || {};
2380
+ if (!report || typeof report !== "object") {
2381
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2382
+ "dmarc.buildAggregateReport: report must be an object");
2383
+ }
2384
+ var rm = report.reportMetadata;
2385
+ var pp = report.policyPublished;
2386
+ if (!rm || typeof rm !== "object") {
2387
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2388
+ "dmarc.buildAggregateReport: report.reportMetadata is required (RFC 7489 Appendix C)");
2389
+ }
2390
+ if (!pp || typeof pp !== "object") {
2391
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2392
+ "dmarc.buildAggregateReport: report.policyPublished is required (RFC 7489 Appendix C)");
2393
+ }
2394
+ var records = report.records;
2395
+ if (!Array.isArray(records)) {
2396
+ throw new MailAuthError("mail-auth/dmarc-rua-build-bad-input",
2397
+ "dmarc.buildAggregateReport: report.records must be an array");
2398
+ }
2399
+ if (records.length > DMARC_RUA_MAX_RECORDS_PER_REPORT) {
2400
+ throw new MailAuthError("mail-auth/dmarc-rua-build-too-many-records",
2401
+ "dmarc.buildAggregateReport: " + records.length + " records exceeds cap " +
2402
+ DMARC_RUA_MAX_RECORDS_PER_REPORT);
2403
+ }
2404
+
2405
+ // report_metadata (RFC 7489 Appendix C). date_range is two epoch
2406
+ // seconds; org_name + report_id are mandatory per the schema.
2407
+ var dateRange = rm.dateRange || {};
2408
+ var metaXml =
2409
+ "<report_metadata>" +
2410
+ _xmlLeaf("org_name", rm.orgName) +
2411
+ _xmlLeaf("email", rm.email) +
2412
+ _xmlLeaf("extra_contact_info", rm.extraContact) +
2413
+ _xmlLeaf("report_id", rm.reportId) +
2414
+ "<date_range>" +
2415
+ _xmlIntLeaf("begin", dateRange.begin) +
2416
+ _xmlIntLeaf("end", dateRange.end) +
2417
+ "</date_range>" +
2418
+ "</report_metadata>";
2419
+
2420
+ // policy_published (RFC 7489 Appendix C).
2421
+ var policyXml =
2422
+ "<policy_published>" +
2423
+ _xmlLeaf("domain", pp.domain) +
2424
+ _xmlLeaf("adkim", pp.adkim) +
2425
+ _xmlLeaf("aspf", pp.aspf) +
2426
+ _xmlLeaf("p", pp.p) +
2427
+ _xmlLeaf("sp", pp.sp) +
2428
+ (pp.pct === undefined || pp.pct === null ? "" : _xmlIntLeaf("pct", pp.pct)) +
2429
+ _xmlLeaf("fo", pp.fo) +
2430
+ "</policy_published>";
2431
+
2432
+ // record[] rows. Each row: source_ip + count + policy_evaluated +
2433
+ // identifiers + auth_results.
2434
+ var recordXml = "";
2435
+ for (var i = 0; i < records.length; i += 1) {
2436
+ var rec = records[i] || {};
2437
+ var disp = rec.dispositions || {};
2438
+ var ids = rec.identifiers || {};
2439
+ var reasonRows = Array.isArray(disp.reasons) ? disp.reasons : [];
2440
+ var reasonXml = "";
2441
+ for (var ri = 0; ri < reasonRows.length; ri += 1) {
2442
+ var rs = reasonRows[ri] || {};
2443
+ reasonXml +=
2444
+ "<reason>" +
2445
+ _xmlLeaf("type", rs.type) +
2446
+ _xmlLeaf("comment", rs.comment) +
2447
+ "</reason>";
2448
+ }
2449
+ recordXml +=
2450
+ "<record>" +
2451
+ "<row>" +
2452
+ _xmlLeaf("source_ip", rec.sourceIp) +
2453
+ _xmlIntLeaf("count", rec.count) +
2454
+ "<policy_evaluated>" +
2455
+ _xmlLeaf("disposition", disp.disposition) +
2456
+ _xmlLeaf("dkim", disp.dkim) +
2457
+ _xmlLeaf("spf", disp.spf) +
2458
+ reasonXml +
2459
+ "</policy_evaluated>" +
2460
+ "</row>" +
2461
+ "<identifiers>" +
2462
+ _xmlLeaf("envelope_to", ids.envelopeTo) +
2463
+ _xmlLeaf("envelope_from", ids.envelopeFrom) +
2464
+ _xmlLeaf("header_from", ids.headerFrom) +
2465
+ "</identifiers>" +
2466
+ _buildAuthResultsXml(rec.authResults) +
2467
+ "</record>";
2468
+ }
2469
+
2470
+ // RFC 7489 §7.2.1.1 — report-format version is "1.0" (the `version`
2471
+ // element under <feedback>). Emit the XML declaration + a single
2472
+ // <feedback> root so the output round-trips through safeXml.parse.
2473
+ var version = _xmlLeaf("version", opts.version || "1.0");
2474
+ var doc =
2475
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
2476
+ "<feedback>" +
2477
+ version +
2478
+ metaXml +
2479
+ policyXml +
2480
+ recordXml +
2481
+ "</feedback>";
2482
+
2483
+ // Optional gzip per the same transport convention the parser accepts
2484
+ // (RFC 1952). Default is raw XML; operators opt into compression for
2485
+ // the mail attachment. Back-compat: default behavior is unchanged
2486
+ // (raw string out) — gzip is strictly opt-in.
2487
+ if (opts.gzip === true) {
2488
+ return zlib.gzipSync(Buffer.from(doc, "utf8"));
2489
+ }
2490
+ return doc;
2491
+ }
2492
+
1994
2493
  // ---- iprev (RFC 8601 §3) — Forward-Confirmed Reverse DNS verifier ----
1995
2494
  //
1996
2495
  // The receiving SMTP server reverse-resolves the connecting peer's IP
@@ -2117,6 +2616,337 @@ async function iprevVerify(ip) {
2117
2616
  };
2118
2617
  }
2119
2618
 
2619
+ // ---- DMARC forensic (RUF) failure-report parser (RFC 6591 + RFC 7489 §7.3) ----
2620
+ //
2621
+ // A domain publishing a DMARC `ruf=` policy receives per-message
2622
+ // failure reports when an authentication check fails. RFC 7489 §7.3
2623
+ // specifies the Authentication Failure Reporting Format (AFRF) of
2624
+ // RFC 6591 for these: a multipart/report (report-type=feedback-report)
2625
+ // carrying a `message/feedback-report` part whose header block adds the
2626
+ // DMARC-specific fields (Auth-Failure, Delivery-Result, Identity-
2627
+ // Alignment, DKIM-*/SPF-* result fields) on top of the RFC 5965 base
2628
+ // fields, plus a third part (message/rfc822 or text/rfc822-headers)
2629
+ // with the reported message (in full or headers-only).
2630
+ //
2631
+ // Composes the shared lib/mime-parse.js substrate (the same MIME walker
2632
+ // the RFC 5965 ARF ingest in lib/mail-arf.js uses) for the
2633
+ // multipart/report bisection + message/feedback-report extraction, then
2634
+ // shapes the full RFC 6591 §3.1 forensic field set (which the abuse-
2635
+ // report profile does not model) plus the reported message's headers.
2636
+ //
2637
+ // var rv = b.mail.dmarc.parseForensicReport(rawMessageBytes);
2638
+ // if (!rv.ok) { /* rv.error.code / rv.error.message */ }
2639
+ // else { /* rv.report.feedbackType / .authFailure / … */ }
2640
+ //
2641
+ // Validation tier: DEFENSIVE READER. The input is hostile-by-default
2642
+ // (a per-message failure report arrives at an operator endpoint from an
2643
+ // arbitrary reporting peer). The parser RETURNS a typed error object on
2644
+ // any malformed / over-cap / wrong-shape input — it does NOT throw in
2645
+ // the hot path, so a crafted report can't crash the request that
2646
+ // ingested it. Bytes + part-count + reported-header counts are bounded
2647
+ // (CWE-400 resource-exhaustion class) like the sibling aggregate-report
2648
+ // parser.
2649
+ //
2650
+ // { ok: true, report: { … } }
2651
+ // { ok: false, error: { code: "<slug>", message: "<reason>" } }
2652
+
2653
+ // RFC 6591 §3.2 — the report is small in practice; cap at 8 MiB to match
2654
+ // the sibling DMARC aggregate-report ceiling so operators have one
2655
+ // mental model for "what fits".
2656
+ var DMARC_RUF_MAX_REPORT_BYTES = C.BYTES.mib(8);
2657
+
2658
+ // RFC 2046 §5.1 — a multipart/report failure report has a handful of
2659
+ // parts (text/plain + message/feedback-report + the reported message);
2660
+ // bound the part count so a hostile report with thousands of empty
2661
+ // boundary delimiters can't force unbounded walk work.
2662
+ var DMARC_RUF_MAX_PARTS = 64; // resource-exhaustion bound (CWE-400)
2663
+
2664
+ // RFC 6591 §3.1 — required forensic fields. Feedback-Type and Auth-
2665
+ // Failure are the two that make an auth-failure report a DMARC forensic
2666
+ // report (RFC 7489 §7.3). User-Agent / Version are advisory in practice.
2667
+ var DMARC_RUF_REQUIRED_FIELDS = ["feedback-type", "auth-failure"];
2668
+
2669
+ // RFC 6591 §3.1 — Auth-Failure registry values. Unknown values pass
2670
+ // through (the IANA "Authentication Failure" registry grows); this set
2671
+ // documents the launch vocabulary so operators can route on it.
2672
+ var DMARC_RUF_AUTH_FAILURE_TYPES = Object.freeze({
2673
+ adsp: 1, // RFC 6591 §3.1 (historic ADSP)
2674
+ "bodyhash": 1, // DKIM body-hash mismatch
2675
+ dkim: 1, // DKIM signature failure
2676
+ dmarc: 1, // RFC 7489 §7.3 — DMARC evaluation failure
2677
+ revoked: 1, // signing key revoked
2678
+ signature: 1, // DKIM signature syntactically invalid
2679
+ spf: 1, // SPF check failure
2680
+ });
2681
+ void DMARC_RUF_AUTH_FAILURE_TYPES;
2682
+
2683
+ // RFC 6591 §3.2 — the reported message's header section can be large but
2684
+ // is bounded; cap the number of reported headers we normalize so a
2685
+ // crafted report can't force unbounded work. The full reported message
2686
+ // text is still surfaced verbatim under `reportedMessage` (bounded by
2687
+ // the overall byte cap), but the parsed `reportedHeaders` list is
2688
+ // header-count-capped.
2689
+ var DMARC_RUF_MAX_REPORTED_HEADERS = 256; // resource-exhaustion bound (CWE-400)
2690
+
2691
+ function _rufError(code, message) {
2692
+ return { ok: false, error: { code: code, message: message } };
2693
+ }
2694
+
2695
+ // Parse the reported message's headers (RFC 6591 §3.2 — the third part
2696
+ // of the report carries the message that failed authentication, in full
2697
+ // or headers-only). Returns an own-keys-only map (null-prototype) of
2698
+ // header-name → value (last-wins for duplicate single-valued lookups;
2699
+ // the full ordered list is also returned) so a header named
2700
+ // `__proto__` / `constructor` in a hostile report can't pollute the
2701
+ // prototype chain (prototype-pollution class).
2702
+ function _parseReportedHeaders(reportedMessage) {
2703
+ var ordered = [];
2704
+ var map = Object.create(null);
2705
+ if (typeof reportedMessage !== "string" || reportedMessage.length === 0) {
2706
+ return { headers: ordered, map: map, truncated: false };
2707
+ }
2708
+ var split;
2709
+ try { split = mimeParse.splitHeadersAndBody(reportedMessage); }
2710
+ catch (_e) { return { headers: ordered, map: map, truncated: false }; }
2711
+ var hdrs = Array.isArray(split.headers) ? split.headers : [];
2712
+ var truncated = false;
2713
+ for (var i = 0; i < hdrs.length; i += 1) {
2714
+ if (ordered.length >= DMARC_RUF_MAX_REPORTED_HEADERS) { truncated = true; break; }
2715
+ var h = hdrs[i];
2716
+ if (!h || typeof h.name !== "string") continue;
2717
+ var name = h.name;
2718
+ var value = typeof h.value === "string" ? h.value : "";
2719
+ ordered.push({ name: name, value: value });
2720
+ // Own-key assignment on a null-prototype object: a reported header
2721
+ // named __proto__ / constructor / prototype is stored as data, not
2722
+ // walked up the chain.
2723
+ map[name.toLowerCase()] = value;
2724
+ }
2725
+ return { headers: ordered, map: map, truncated: truncated };
2726
+ }
2727
+
2728
+ // Reassemble a MIME part's headers + body so a reported message that
2729
+ // ships as text/rfc822-headers (no separate body) still round-trips its
2730
+ // header bytes (RFC 6591 §3.2 permits headers-only).
2731
+ function _reassembleRufPart(part) {
2732
+ var hdrs = "";
2733
+ var ph = Array.isArray(part.headers) ? part.headers : [];
2734
+ for (var i = 0; i < ph.length; i += 1) {
2735
+ hdrs += ph[i].name + ": " + ph[i].value + "\r\n";
2736
+ }
2737
+ return hdrs + "\r\n" + (part.body || "");
2738
+ }
2739
+
2740
+ function dmarcParseForensicReport(input, opts) {
2741
+ opts = opts || {};
2742
+
2743
+ // ---- Coerce + byte-cap (defensive: typed error, never throw) ----
2744
+ var asString;
2745
+ if (typeof input === "string") asString = input;
2746
+ else if (Buffer.isBuffer(input)) asString = input.toString("utf8");
2747
+ else {
2748
+ return _rufError("mail-auth/dmarc-ruf-bad-input",
2749
+ "dmarc.parseForensicReport: input must be a string or Buffer");
2750
+ }
2751
+ var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
2752
+ ? opts.maxBytes
2753
+ : DMARC_RUF_MAX_REPORT_BYTES;
2754
+ if (asString.length > maxBytes) {
2755
+ return _rufError("mail-auth/dmarc-ruf-too-large",
2756
+ "dmarc.parseForensicReport: report exceeds " + maxBytes + " bytes (got " + asString.length + ")");
2757
+ }
2758
+
2759
+ // ---- Bisect top-level headers / body; require multipart/report ----
2760
+ var top;
2761
+ try { top = mimeParse.splitHeadersAndBody(asString); }
2762
+ catch (e) {
2763
+ return _rufError("mail-auth/dmarc-ruf-bad-report",
2764
+ "dmarc.parseForensicReport: header/body split failed: " + ((e && e.message) || String(e)));
2765
+ }
2766
+ var ct = mimeParse.parseContentType(mimeParse.findHeader(top.headers, "Content-Type") || "");
2767
+ if (ct.type !== "multipart/report") {
2768
+ return _rufError("mail-auth/dmarc-ruf-bad-report",
2769
+ "dmarc.parseForensicReport: top-level Content-Type must be multipart/report (got '" + ct.type + "')");
2770
+ }
2771
+ // RFC 6591 §2 / RFC 5965 §2 — report-type=feedback-report. Tolerate an
2772
+ // omitted report-type (shipping reporters sometimes drop it); refuse a
2773
+ // mismatched value.
2774
+ if (ct.params["report-type"] && ct.params["report-type"].toLowerCase() !== "feedback-report") {
2775
+ return _rufError("mail-auth/dmarc-ruf-bad-report",
2776
+ "dmarc.parseForensicReport: report-type must be feedback-report (got '" +
2777
+ ct.params["report-type"] + "')");
2778
+ }
2779
+ if (!ct.params.boundary) {
2780
+ return _rufError("mail-auth/dmarc-ruf-bad-report",
2781
+ "dmarc.parseForensicReport: multipart/report Content-Type lacks boundary parameter");
2782
+ }
2783
+
2784
+ // ---- Walk the parts; find message/feedback-report + reported msg ----
2785
+ var parts = mimeParse.splitMimeParts(top.body, ct.params.boundary);
2786
+ if (parts.length === 0) {
2787
+ return _rufError("mail-auth/dmarc-ruf-bad-report",
2788
+ "dmarc.parseForensicReport: multipart/report body contains no parts");
2789
+ }
2790
+ if (parts.length > DMARC_RUF_MAX_PARTS) {
2791
+ return _rufError("mail-auth/dmarc-ruf-too-many-parts",
2792
+ "dmarc.parseForensicReport: report has " + parts.length + " parts (cap " +
2793
+ DMARC_RUF_MAX_PARTS + ")");
2794
+ }
2795
+
2796
+ var feedbackPart = null;
2797
+ var reportedPart = null;
2798
+ for (var pi = 0; pi < parts.length; pi += 1) {
2799
+ var split;
2800
+ try { split = mimeParse.splitHeadersAndBody(parts[pi]); }
2801
+ catch (_e) { continue; }
2802
+ var partCt = mimeParse.parseContentType(
2803
+ mimeParse.findHeader(split.headers, "Content-Type") || "");
2804
+ if (partCt.type === "message/feedback-report" && !feedbackPart) {
2805
+ feedbackPart = split;
2806
+ } else if ((partCt.type === "message/rfc822" ||
2807
+ partCt.type === "text/rfc822-headers") && !reportedPart) {
2808
+ reportedPart = split;
2809
+ }
2810
+ }
2811
+ if (!feedbackPart) {
2812
+ return _rufError("mail-auth/dmarc-ruf-no-feedback-report",
2813
+ "dmarc.parseForensicReport: missing message/feedback-report subpart (RFC 6591 §3)");
2814
+ }
2815
+
2816
+ // ---- Parse the feedback-report header block (RFC 6591 §3.1) ----
2817
+ // Field names are stored own-key on a null-prototype map so a hostile
2818
+ // field named __proto__ / constructor can't pollute the prototype.
2819
+ var fields;
2820
+ try { fields = mimeParse.parseHeaderBlock(feedbackPart.body); }
2821
+ catch (e) {
2822
+ return _rufError("mail-auth/dmarc-ruf-bad-report",
2823
+ "dmarc.parseForensicReport: feedback-report field parse failed: " + ((e && e.message) || String(e)));
2824
+ }
2825
+ var fieldMap = Object.create(null);
2826
+ var rcptToList = [];
2827
+ for (var fi = 0; fi < fields.length; fi += 1) {
2828
+ var f = fields[fi];
2829
+ if (!f || typeof f.name !== "string") continue;
2830
+ var lc = f.name.toLowerCase();
2831
+ var val = typeof f.value === "string" ? f.value : "";
2832
+ fieldMap[lc] = val;
2833
+ if (lc === "original-rcpt-to") rcptToList.push(val);
2834
+ }
2835
+ function _field(name) {
2836
+ return Object.prototype.hasOwnProperty.call(fieldMap, name) ? fieldMap[name] : null;
2837
+ }
2838
+
2839
+ // ---- Required fields (RFC 6591 §3.1 / RFC 7489 §7.3) ----
2840
+ for (var ri = 0; ri < DMARC_RUF_REQUIRED_FIELDS.length; ri += 1) {
2841
+ var req = DMARC_RUF_REQUIRED_FIELDS[ri];
2842
+ var rv = _field(req);
2843
+ if (typeof rv !== "string" || rv.length === 0) {
2844
+ if (req === "auth-failure") {
2845
+ return _rufError("mail-auth/dmarc-ruf-missing-auth-failure",
2846
+ "dmarc.parseForensicReport: required field 'Auth-Failure' is missing (RFC 6591 §3.1)");
2847
+ }
2848
+ return _rufError("mail-auth/dmarc-ruf-missing-field",
2849
+ "dmarc.parseForensicReport: required field '" + req + "' is missing (RFC 6591 §3.1)");
2850
+ }
2851
+ }
2852
+
2853
+ // RFC 7489 §7.3 — a DMARC forensic report carries Feedback-Type:
2854
+ // auth-failure (the AFRF profile of RFC 6591). A report whose Feedback-
2855
+ // Type is another ARF class (e.g. plain "abuse") is a valid feedback
2856
+ // report but NOT a DMARC forensic report; surface the mismatch rather
2857
+ // than mislabeling it. Field values are case-insensitive tokens.
2858
+ var feedbackType = String(_field("feedback-type")).toLowerCase();
2859
+ if (feedbackType !== "auth-failure") {
2860
+ return _rufError("mail-auth/dmarc-ruf-not-auth-failure",
2861
+ "dmarc.parseForensicReport: Feedback-Type must be 'auth-failure' for a " +
2862
+ "DMARC forensic report (RFC 7489 §7.3 / RFC 6591), got " +
2863
+ JSON.stringify(_field("feedback-type")));
2864
+ }
2865
+
2866
+ // ---- RFC 6591 §3.2 reported message ----
2867
+ var reportedMessage = null;
2868
+ if (reportedPart) {
2869
+ reportedMessage = (reportedPart.body && reportedPart.body.length > 0)
2870
+ ? reportedPart.body
2871
+ : _reassembleRufPart(reportedPart);
2872
+ }
2873
+ var reported = _parseReportedHeaders(reportedMessage);
2874
+
2875
+ // ---- Normalize Arrival-Date / Incidents (RFC 5965 §3.1) ----
2876
+ var arrivalRaw = _field("arrival-date") || _field("received-date") || null;
2877
+ var arrivalIso = null;
2878
+ if (arrivalRaw) {
2879
+ var d = new Date(arrivalRaw);
2880
+ if (!isNaN(d.getTime())) arrivalIso = d.toISOString();
2881
+ }
2882
+ var incidentsRaw = _field("incidents");
2883
+ var incidents = null;
2884
+ if (typeof incidentsRaw === "string") {
2885
+ var inc = parseInt(incidentsRaw, 10);
2886
+ if (isFinite(inc) && inc >= 0) incidents = inc;
2887
+ }
2888
+
2889
+ // Surface unmodeled fields under extraFields for operator visibility
2890
+ // (vendor X-* tags). Own-key copy off the null-prototype fieldMap onto
2891
+ // a null-prototype target so a field named __proto__ / constructor in a
2892
+ // hostile report is stored as data, not as a prototype mutation.
2893
+ var KNOWN = Object.create(null);
2894
+ ["feedback-type", "user-agent", "version", "auth-failure",
2895
+ "delivery-result", "identity-alignment", "dkim-domain",
2896
+ "dkim-identity", "dkim-selector", "dkim-canonicalized-header",
2897
+ "dkim-canonicalized-body", "spf-dns", "original-mail-from",
2898
+ "original-rcpt-to", "arrival-date", "received-date",
2899
+ "reported-domain", "source-ip", "authentication-results",
2900
+ "reported-uri", "incidents", "original-envelope-id"
2901
+ ].forEach(function (k) { KNOWN[k] = 1; });
2902
+ var extraFields = Object.create(null);
2903
+ Object.keys(fieldMap).forEach(function (k) {
2904
+ if (!Object.prototype.hasOwnProperty.call(KNOWN, k)) extraFields[k] = fieldMap[k];
2905
+ });
2906
+
2907
+ var report = {
2908
+ // ---- RFC 5965 base fields ----
2909
+ feedbackType: _field("feedback-type"),
2910
+ userAgent: _field("user-agent"),
2911
+ version: _field("version") || "1",
2912
+ arrivalDate: arrivalIso || arrivalRaw,
2913
+ reportedDomain: _field("reported-domain"),
2914
+ sourceIp: _field("source-ip"),
2915
+ originalFrom: _field("original-mail-from"),
2916
+ originalRcptTo: rcptToList,
2917
+ originalEnvelopeId: _field("original-envelope-id"),
2918
+ authenticationResults: _field("authentication-results"),
2919
+ incidents: incidents,
2920
+ reportedUri: _field("reported-uri"),
2921
+
2922
+ // ---- RFC 6591 §3.1 / RFC 7489 §7.3 forensic-specific fields ----
2923
+ authFailure: _field("auth-failure"), // RFC 6591 §3.1 — "dkim" | "spf" | "dmarc" | "bodyhash" | …
2924
+ deliveryResult: _field("delivery-result"), // RFC 6591 §3.1 — "delivered" | "spam" | "policy" | "reject" | "other"
2925
+ identityAlignment: _field("identity-alignment"), // RFC 7489 §7.3 — "none" | "spf" | "dkim" | "dkim spf"
2926
+ dkim: {
2927
+ domain: _field("dkim-domain"), // RFC 6591 §3.1
2928
+ identity: _field("dkim-identity"),
2929
+ selector: _field("dkim-selector"),
2930
+ canonicalizedHeader: _field("dkim-canonicalized-header"),
2931
+ canonicalizedBody: _field("dkim-canonicalized-body"),
2932
+ },
2933
+ spf: {
2934
+ dns: _field("spf-dns"), // RFC 6591 §3.1 — the SPF DNS record at evaluation time
2935
+ },
2936
+
2937
+ // ---- RFC 6591 §3.2 reported message ----
2938
+ reportedMessage: reportedMessage,
2939
+ reportedHeaders: reported.headers, // [ { name, value }, … ] — order preserved
2940
+ reportedHeaderMap: reported.map, // null-prototype lower-cased-name → value
2941
+ reportedHeadersTruncated: reported.truncated, // true when the §3.2 header cap clipped the list
2942
+
2943
+ // ---- operator-visible passthrough for unmodeled fields ----
2944
+ extraFields: extraFields,
2945
+ };
2946
+
2947
+ return { ok: true, report: report };
2948
+ }
2949
+
2120
2950
  module.exports = {
2121
2951
  spf: Object.freeze({
2122
2952
  verify: spfVerify,
@@ -2126,6 +2956,8 @@ module.exports = {
2126
2956
  evaluate: dmarcEvaluate,
2127
2957
  parseRecord: _parseDmarcRecord,
2128
2958
  parseAggregateReport: dmarcParseAggregateReport,
2959
+ buildAggregateReport: dmarcBuildAggregateReport,
2960
+ parseForensicReport: dmarcParseForensicReport,
2129
2961
  }),
2130
2962
  arc: Object.freeze({
2131
2963
  verify: arcVerify,