@blamejs/core 0.8.52 → 0.8.57

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 (41) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/framework-error.js +55 -0
  9. package/lib/guard-cidr.js +2 -1
  10. package/lib/guard-jwt.js +2 -2
  11. package/lib/guard-oauth.js +2 -2
  12. package/lib/http-client-cache.js +916 -0
  13. package/lib/http-client.js +242 -0
  14. package/lib/mail-arf.js +343 -0
  15. package/lib/mail-auth.js +265 -40
  16. package/lib/mail-bimi.js +948 -33
  17. package/lib/mail-bounce.js +386 -4
  18. package/lib/mail-mdn.js +424 -0
  19. package/lib/mail-unsubscribe.js +265 -25
  20. package/lib/mail.js +403 -21
  21. package/lib/middleware/bearer-auth.js +1 -1
  22. package/lib/middleware/clear-site-data.js +122 -0
  23. package/lib/middleware/dpop.js +1 -1
  24. package/lib/middleware/index.js +9 -0
  25. package/lib/middleware/nel.js +214 -0
  26. package/lib/middleware/security-headers.js +56 -4
  27. package/lib/middleware/speculation-rules.js +323 -0
  28. package/lib/mime-parse.js +198 -0
  29. package/lib/network-dns.js +890 -27
  30. package/lib/network-tls.js +745 -0
  31. package/lib/object-store/sigv4.js +54 -0
  32. package/lib/public-suffix.js +414 -0
  33. package/lib/safe-buffer.js +7 -0
  34. package/lib/safe-json.js +1 -1
  35. package/lib/static.js +120 -0
  36. package/lib/storage.js +11 -0
  37. package/lib/vendor/MANIFEST.json +33 -0
  38. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  39. package/lib/vendor/public-suffix-list.dat +16376 -0
  40. package/package.json +1 -1
  41. package/sbom.cyclonedx.json +6 -6
@@ -732,6 +732,18 @@ function request(opts) {
732
732
  "jar must be a b.httpClient.cookieJar.create() instance", true));
733
733
  }
734
734
  }
735
+ // RFC 9111 outbound HTTP cache. Validate shape at the entry-point;
736
+ // the cache hot path itself is drop-silent (any failure falls back
737
+ // to the network so caching is never a request-failure surface).
738
+ if (opts.cache !== undefined && opts.cache !== null) {
739
+ if (typeof opts.cache !== "object" ||
740
+ typeof opts.cache._lookup !== "function" ||
741
+ typeof opts.cache._evaluateStorage !== "function" ||
742
+ typeof opts.cache._store !== "function") {
743
+ return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
744
+ "cache must be a b.httpClient.cache.create() instance", true));
745
+ }
746
+ }
735
747
 
736
748
  // before interceptors — run in array order. Each may return a modified
737
749
  // opts object (or return nothing to leave the running opts as-is).
@@ -808,6 +820,14 @@ function request(opts) {
808
820
  return res;
809
821
  }
810
822
 
823
+ // Cache layer wraps the redirect-aware path. Cache wiring is a no-op
824
+ // for non-GET/HEAD methods (per RFC 9111 §3) and bypassed entirely
825
+ // when the request opts include a body (the request mutates state on
826
+ // the upstream, can't be a cache hit).
827
+ if (opts.cache && _cacheEligibleMethod(opts.method) && opts.body == null) {
828
+ return _runWithCache(opts, maxRedirects, _runAfter);
829
+ }
830
+
811
831
  if (maxRedirects === null || maxRedirects === 0) {
812
832
  return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
813
833
  }
@@ -817,6 +837,228 @@ function request(opts) {
817
837
  });
818
838
  }
819
839
 
840
+ // Cache-method gate. RFC 9111 §3 — method must be GET or HEAD for the
841
+ // outbound cache to consider a response. Any other method shortcircuits
842
+ // straight to the network path (and operator code that mistakenly
843
+ // passed a cache instance to a POST sees the same network behaviour as
844
+ // without the cache, no surprise).
845
+ function _cacheEligibleMethod(method) {
846
+ var m = String(method || "GET").toUpperCase();
847
+ return m === "GET" || m === "HEAD";
848
+ }
849
+
850
+ // Wrap an outbound headers object with the framework's cache-decision
851
+ // markers. Mutates a copy; never the original.
852
+ function _withCacheHeaders(res, status, ageSeconds) {
853
+ var headers = Object.assign({}, res.headers || {});
854
+ headers["x-blamejs-cache"] = status;
855
+ if (typeof ageSeconds === "number" && ageSeconds >= 0) {
856
+ headers["age"] = String(Math.floor(ageSeconds));
857
+ }
858
+ return Object.assign({}, res, { headers: headers });
859
+ }
860
+
861
+ function _runWithCache(opts, maxRedirects, runAfter) {
862
+ var cache = opts.cache;
863
+ var method = String(opts.method || "GET").toUpperCase();
864
+ var requestHeaders = opts.headers || {};
865
+ var nowMs = Date.now();
866
+
867
+ // 1. Lookup. Cache lookups themselves are drop-silent; on store
868
+ // failure we treat the call as a miss.
869
+ var got = null;
870
+ try { got = cache._lookup(method, opts.url, requestHeaders); }
871
+ catch (_e) { got = null; }
872
+
873
+ function _doNetwork(extraReqHeaders) {
874
+ var nextOpts = opts;
875
+ if (extraReqHeaders) {
876
+ nextOpts = Object.assign({}, opts, {
877
+ headers: Object.assign({}, opts.headers || {}, extraReqHeaders),
878
+ });
879
+ }
880
+ if (maxRedirects === null || maxRedirects === 0) {
881
+ return _requestSingle(nextOpts).then(function (res) {
882
+ return { finalOpts: nextOpts, res: res };
883
+ });
884
+ }
885
+ return _requestWithRedirects(nextOpts, maxRedirects);
886
+ }
887
+
888
+ // 2. Miss → network → maybe store.
889
+ if (!got) {
890
+ try { cache._emit("httpclient.cache.miss", "allowed", { url: String(opts.url), method: method }); }
891
+ catch (_e) { /* drop-silent */ }
892
+ try { cache._obsEvent("httpclient.cache.miss", 1, { method: method }); }
893
+ catch (_e) { /* drop-silent */ }
894
+ return _doNetwork(null).then(function (boxed) {
895
+ _maybeStore(cache, method, opts.url, requestHeaders, boxed.res);
896
+ return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS"));
897
+ });
898
+ }
899
+
900
+ // 3. Hit. Decide fresh / stale / revalidate.
901
+ var entry = got.entry;
902
+ var evaluation;
903
+ try { evaluation = cache._evaluateStored(entry, nowMs); }
904
+ catch (_e) {
905
+ // Malformed entry — drop it, treat as miss.
906
+ try { cache.invalidate(method, opts.url, requestHeaders); }
907
+ catch (_e2) { /* drop-silent */ }
908
+ return _doNetwork(null).then(function (boxed) {
909
+ _maybeStore(cache, method, opts.url, requestHeaders, boxed.res);
910
+ return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS"));
911
+ });
912
+ }
913
+
914
+ if (evaluation.fresh && !evaluation.mustRevalidate) {
915
+ var age = cache._serveAgeSeconds(entry, nowMs);
916
+ try { cache._emit("httpclient.cache.hit", "allowed", { url: String(opts.url), method: method, ageMs: evaluation.ageMs }); }
917
+ catch (_e) { /* drop-silent */ }
918
+ try { cache._obsEvent("httpclient.cache.hit", 1, { method: method }); }
919
+ catch (_e) { /* drop-silent */ }
920
+ var hitRes = {
921
+ statusCode: entry.statusCode,
922
+ headers: Object.assign({}, entry.headers),
923
+ body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
924
+ cacheStatus: "HIT",
925
+ };
926
+ return Promise.resolve(runAfter(opts, _withCacheHeaders(hitRes, "HIT", age)));
927
+ }
928
+
929
+ // 4. Stale or must-revalidate. Within stale-while-revalidate or
930
+ // defaultMaxStale grace, we serve stale + kick off background
931
+ // revalidation. Otherwise we revalidate inline.
932
+ var ageOverFresh = Math.max(0, evaluation.ageMs - evaluation.freshnessMs);
933
+ var swrApplies = !evaluation.mustRevalidate &&
934
+ ageOverFresh < Math.max(evaluation.swrWindowMs, evaluation.defaultStaleMs);
935
+
936
+ if (swrApplies && cache.revalidateInBackground) {
937
+ // Serve stale immediately, kick off background revalidation. We
938
+ // explicitly DON'T await the background revalidation Promise so
939
+ // the caller gets the stale response immediately. We also catch
940
+ // its error so an unhandled rejection doesn't escape.
941
+ var ageStale = cache._serveAgeSeconds(entry, nowMs);
942
+ try { cache._emit("httpclient.cache.stale", "allowed", { url: String(opts.url), method: method, ageMs: evaluation.ageMs, mode: "swr" }); }
943
+ catch (_e) { /* drop-silent */ }
944
+ try { cache._obsEvent("httpclient.cache.stale", 1, { method: method, mode: "swr" }); }
945
+ catch (_e) { /* drop-silent */ }
946
+ var staleRes = {
947
+ statusCode: entry.statusCode,
948
+ headers: Object.assign({}, entry.headers),
949
+ body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
950
+ cacheStatus: "STALE",
951
+ };
952
+ // Background revalidation — fire-and-forget, errors swallowed (the
953
+ // next caller observes the stale entry until either the upstream
954
+ // recovers or stale-if-error / s-w-r windows expire).
955
+ setImmediate(function () {
956
+ _revalidate(cache, method, opts, entry, requestHeaders).catch(function () {
957
+ /* background revalidation best-effort; swallow */
958
+ });
959
+ });
960
+ return Promise.resolve(runAfter(opts, _withCacheHeaders(staleRes, "STALE", ageStale)));
961
+ }
962
+
963
+ // 5. Inline conditional revalidation. Build If-None-Match /
964
+ // If-Modified-Since from the stored entry, fire the network
965
+ // request, branch on 304 vs anything-else.
966
+ return _revalidate(cache, method, opts, entry, requestHeaders).then(function (rev) {
967
+ if (rev.kind === "not-modified") {
968
+ var ageRev = cache._serveAgeSeconds(rev.refreshed || entry, Date.now());
969
+ var revRes = {
970
+ statusCode: (rev.refreshed || entry).statusCode,
971
+ headers: Object.assign({}, (rev.refreshed || entry).headers),
972
+ body: Buffer.isBuffer((rev.refreshed || entry).body)
973
+ ? Buffer.from((rev.refreshed || entry).body)
974
+ : (rev.refreshed || entry).body,
975
+ cacheStatus: "REVALIDATED",
976
+ };
977
+ return runAfter(opts, _withCacheHeaders(revRes, "REVALIDATED", ageRev));
978
+ }
979
+ if (rev.kind === "fresh-response") {
980
+ _maybeStore(cache, method, opts.url, requestHeaders, rev.res);
981
+ return runAfter(rev.finalOpts || opts, _withCacheHeaders(rev.res, "MISS"));
982
+ }
983
+ // rev.kind === "error" — try stale-if-error.
984
+ var sieMs = (evaluation.sieWindowMs || 0);
985
+ if (sieMs > 0 && ageOverFresh < sieMs) {
986
+ var ageErr = cache._serveAgeSeconds(entry, Date.now());
987
+ try { cache._emit("httpclient.cache.stale", "allowed", { url: String(opts.url), method: method, ageMs: evaluation.ageMs, mode: "sie", error: rev.error && rev.error.message }); }
988
+ catch (_e) { /* drop-silent */ }
989
+ try { cache._obsEvent("httpclient.cache.stale", 1, { method: method, mode: "sie" }); }
990
+ catch (_e) { /* drop-silent */ }
991
+ var sieRes = {
992
+ statusCode: entry.statusCode,
993
+ headers: Object.assign({}, entry.headers),
994
+ body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
995
+ cacheStatus: "STALE",
996
+ };
997
+ return runAfter(opts, _withCacheHeaders(sieRes, "STALE", ageErr));
998
+ }
999
+ return Promise.reject(rev.error);
1000
+ });
1001
+ }
1002
+
1003
+ // Build conditional headers for revalidation per RFC 9110 §13.
1004
+ function _conditionalHeaders(entry) {
1005
+ var out = {};
1006
+ if (entry.etag) out["If-None-Match"] = entry.etag;
1007
+ if (entry.lastModified) out["If-Modified-Since"] = entry.lastModified;
1008
+ return out;
1009
+ }
1010
+
1011
+ // Run a revalidation request. Returns one of:
1012
+ // { kind: "not-modified", refreshed } — upstream returned 304
1013
+ // { kind: "fresh-response", res, finalOpts } — upstream returned 2xx/...
1014
+ // { kind: "error", error } — network or upstream error
1015
+ function _revalidate(cache, method, opts, entry, requestHeaders) {
1016
+ var conditional = _conditionalHeaders(entry);
1017
+ var nextOpts = Object.assign({}, opts, {
1018
+ headers: Object.assign({}, requestHeaders, conditional),
1019
+ // Stream-mode bypass: revalidation always uses buffer mode so the
1020
+ // 304 / fresh-response branches both have buffered body in hand
1021
+ // ready to merge / store.
1022
+ responseMode: "always-resolve",
1023
+ // Ensure we don't recurse into the cache layer on the revalidation
1024
+ // request itself. Pass cache as null/undefined.
1025
+ cache: undefined,
1026
+ });
1027
+ var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
1028
+ ? null : opts.maxRedirects;
1029
+ var p = (maxRedirects === null || maxRedirects === 0)
1030
+ ? _requestSingle(nextOpts).then(function (res) { return { finalOpts: nextOpts, res: res }; })
1031
+ : _requestWithRedirects(nextOpts, maxRedirects);
1032
+
1033
+ return p.then(function (boxed) {
1034
+ var res = boxed.res;
1035
+ if (res.statusCode === 304) { // allow:raw-byte-literal — HTTP 304 Not Modified status code, not bytes
1036
+ // Merge 304 headers into the stored entry.
1037
+ var refreshed;
1038
+ try { refreshed = cache._refreshFrom304(entry, res.headers); }
1039
+ catch (_e) { refreshed = entry; }
1040
+ try { cache._emit("httpclient.cache.revalidated", "allowed", { url: String(opts.url), method: method }); }
1041
+ catch (_e) { /* drop-silent */ }
1042
+ try { cache._obsEvent("httpclient.cache.revalidated", 1, { method: method }); }
1043
+ catch (_e) { /* drop-silent */ }
1044
+ return { kind: "not-modified", refreshed: refreshed };
1045
+ }
1046
+ return { kind: "fresh-response", res: res, finalOpts: boxed.finalOpts };
1047
+ }, function (err) {
1048
+ return { kind: "error", error: err };
1049
+ });
1050
+ }
1051
+
1052
+ // Decide whether to store, then store. Drop-silent on any internal
1053
+ // throw so caching cannot surface as a request failure.
1054
+ function _maybeStore(cache, method, url, requestHeaders, res) {
1055
+ try {
1056
+ var evaluation = cache._evaluateStorage(method, res.statusCode, res.headers || {});
1057
+ if (!evaluation.cacheable) return;
1058
+ cache._store(method, url, requestHeaders, res.statusCode, res.headers || {}, res.body, evaluation);
1059
+ } catch (_e) { /* drop-silent — caching never breaks the request */ }
1060
+ }
1061
+
820
1062
  function _requestWithRedirects(opts, hopsLeft) {
821
1063
  var originalUrl = opts.url;
822
1064
  var originalOrigin = null;
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mailArf
4
+ * @nav Communication
5
+ * @title Mail ARF
6
+ *
7
+ * @intro
8
+ * RFC 5965 Abuse Reporting Format ingest. ESPs (Yahoo, AOL,
9
+ * Microsoft, Google, etc.) post these via webhook when a user marks
10
+ * one of the operator's messages as spam. The format is
11
+ * multipart/report with three required parts:
12
+ *
13
+ * 1. text/plain — human-readable description (ignored)
14
+ * 2. message/feedback-report — the structured report itself, a
15
+ * block of header:value lines (Feedback-Type, User-Agent,
16
+ * Original-Mail-From, Source-IP, Reported-Domain, Arrival-Date,
17
+ * Authentication-Results, Auth-Failure, etc.)
18
+ * 3. message/rfc822 (or text/rfc822-headers) — the original message
19
+ * being reported, in full or just the headers
20
+ *
21
+ * `b.mailArf.parse` consumes the raw multipart/report bytes and
22
+ * returns a normalized event shape suitable for an
23
+ * abuse-reconciliation pipeline (suppression list, abuse-score
24
+ * tracking, complaint-rate dashboards). Required fields per
25
+ * RFC 5965 §3.1 are Feedback-Type and User-Agent — `parse` refuses
26
+ * anything missing them. Reports without a `message/feedback-report`
27
+ * subpart are also refused.
28
+ *
29
+ * This is a parse-only primitive — operators wire it into their
30
+ * own webhook endpoint and emit the audit trail / suppression-list
31
+ * updates from there. The framework's `b.mailBounce.handler` is the
32
+ * reference shape for the surrounding plumbing; ARF rides
33
+ * alongside it because the wire format and lifecycle differ
34
+ * (multipart/report vs JSON; no vendor-specific parser needed).
35
+ *
36
+ * @card
37
+ * RFC 5965 ARF (Abuse Reporting Format) ingest — parse a message/feedback-report multipart payload from an ESP's user-marked-as-spam webhook into a structured event for the suppression pipeline.
38
+ */
39
+
40
+ var lazyRequire = require("./lazy-require");
41
+ var mimeParse = require("./mime-parse");
42
+ var C = require("./constants");
43
+ var { MailArfError } = require("./framework-error");
44
+
45
+ var audit = lazyRequire(function () { return require("./audit"); });
46
+
47
+ // RFC 5965 ceilings — a feedback report is small in practice (a few
48
+ // kilobytes plus the original message, which is itself capped by the
49
+ // MTA's max-message-size). 8 MiB matches the b.mail.dmarc aggregate
50
+ // report cap so operators have one mental model for "what fits".
51
+ var ARF_MAX_REPORT_BYTES = C.BYTES.mib(8);
52
+
53
+ // RFC 5965 §3.1 — required fields. The spec lists Feedback-Type +
54
+ // User-Agent + Version as REQUIRED; we match major ESPs by also
55
+ // requiring Feedback-Type and User-Agent (Version defaults to 1 when
56
+ // omitted, which is what every real-world report sends).
57
+ var ARF_REQUIRED_FIELDS = ["feedback-type", "user-agent"];
58
+
59
+ // RFC 5965 §3.1 — Feedback-Type registry. Unknown values pass through
60
+ // (the IANA registry grows; this list documents the spec's launch
61
+ // vocabulary so operators can route on it).
62
+ var ARF_KNOWN_FEEDBACK_TYPES = {
63
+ abuse: 1,
64
+ "auth-failure": 1,
65
+ fraud: 1,
66
+ "not-spam": 1,
67
+ other: 1,
68
+ virus: 1,
69
+ // RFC 6650 (post-launch) — list-unsubscribe complaint feedback loop.
70
+ "opt-out": 1,
71
+ "opt-out-list": 1,
72
+ };
73
+ void ARF_KNOWN_FEEDBACK_TYPES;
74
+
75
+ /**
76
+ * @primitive b.mailArf.parse
77
+ * @signature b.mailArf.parse(rawMessage, opts)
78
+ * @since 0.8.53
79
+ * @status stable
80
+ * @related b.mailBounce.parse, b.mailBounce.handler
81
+ *
82
+ * Parse an RFC 5965 Abuse Reporting Format multipart/report payload
83
+ * into a normalized abuse-event shape. Refuses on missing
84
+ * `message/feedback-report` subpart, missing required `Feedback-Type`
85
+ * or `User-Agent` fields, or report bytes exceeding the 8 MiB ceiling.
86
+ *
87
+ * Returns:
88
+ *
89
+ * {
90
+ * feedbackType, // "abuse" | "auth-failure" | "fraud" | …
91
+ * userAgent, // "SomeESP-Feedback/1.0"
92
+ * version, // "1" (default) — per RFC 5965 §3.1
93
+ * originalFrom, // string — Original-Mail-From
94
+ * originalRcptTo, // [string] — every Original-Rcpt-To
95
+ * arrivalDate, // ISO 8601 string when parseable, else raw
96
+ * reportedDomain, // string — Reported-Domain
97
+ * sourceIp, // string — Source-IP
98
+ * authenticationResults, // string — verbatim Authentication-Results
99
+ * authFailure, // "dkim" | "spf" | "dmarc" | … (optional)
100
+ * reportedUri, // string — Reported-URI (phishing reports)
101
+ * incidents, // number — Incidents (when present)
102
+ * originalMessage, // string — the message/rfc822 part body
103
+ * extraFields, // { [name: string]: string } — operator-
104
+ * // visible fields the spec doesn't
105
+ * // normalize
106
+ * }
107
+ *
108
+ * Audit emission: the framework emits `system.mailarf.parsed` on
109
+ * success and `system.mailarf.malformed` on refusal. Operators wire
110
+ * `audit: false` to suppress when the upstream webhook handler emits
111
+ * its own audit row.
112
+ *
113
+ * @opts
114
+ * maxBytes: number, // default: 8 MiB
115
+ * audit: boolean, // default: true
116
+ *
117
+ * @example
118
+ * var b = require("@blamejs/core");
119
+ * var event = b.mailArf.parse(rawWebhookBody);
120
+ * if (event.feedbackType === "abuse") suppressionList.add(event.originalFrom);
121
+ * // → typeof event.userAgent === "string"
122
+ */
123
+ function parse(rawMessage, opts) {
124
+ opts = opts || {};
125
+ var auditOn = opts.audit !== false;
126
+ var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) &&
127
+ opts.maxBytes > 0)
128
+ ? opts.maxBytes
129
+ : ARF_MAX_REPORT_BYTES;
130
+
131
+ if (typeof rawMessage !== "string" && !Buffer.isBuffer(rawMessage)) {
132
+ _emitMalformed(auditOn, "input must be string or Buffer");
133
+ throw new MailArfError("mailarf/parse-failed",
134
+ "mailArf.parse: rawMessage must be a string or Buffer");
135
+ }
136
+
137
+ var asString = Buffer.isBuffer(rawMessage)
138
+ ? rawMessage.toString("utf8")
139
+ : rawMessage;
140
+
141
+ if (asString.length > maxBytes) {
142
+ _emitMalformed(auditOn, "report exceeds maxBytes");
143
+ throw new MailArfError("mailarf/parse-failed",
144
+ "mailArf.parse: report exceeds " + maxBytes + " bytes (got " + asString.length + ")");
145
+ }
146
+
147
+ // 1. Bisect headers / body, parse Content-Type for boundary.
148
+ var top;
149
+ try { top = mimeParse.splitHeadersAndBody(asString); }
150
+ catch (e) {
151
+ _emitMalformed(auditOn, "split-failed");
152
+ throw new MailArfError("mailarf/parse-failed",
153
+ "mailArf.parse: header/body split failed: " + ((e && e.message) || String(e)));
154
+ }
155
+ var ctRaw = mimeParse.findHeader(top.headers, "Content-Type") || "";
156
+ var ct = mimeParse.parseContentType(ctRaw);
157
+ if (ct.type !== "multipart/report") {
158
+ _emitMalformed(auditOn, "wrong-content-type");
159
+ throw new MailArfError("mailarf/parse-failed",
160
+ "mailArf.parse: top-level Content-Type must be multipart/report (got '" + ct.type + "')");
161
+ }
162
+ // RFC 5965 §2 — multipart/report must carry report-type=feedback-report.
163
+ // Tolerate omitted report-type for shipping ESPs that send a plain
164
+ // multipart/report; refuse mismatched values.
165
+ if (ct.params["report-type"] && ct.params["report-type"].toLowerCase() !== "feedback-report") {
166
+ _emitMalformed(auditOn, "wrong-report-type");
167
+ throw new MailArfError("mailarf/parse-failed",
168
+ "mailArf.parse: report-type must be feedback-report (got '" +
169
+ ct.params["report-type"] + "')");
170
+ }
171
+ if (!ct.params.boundary) {
172
+ _emitMalformed(auditOn, "missing-boundary");
173
+ throw new MailArfError("mailarf/parse-failed",
174
+ "mailArf.parse: multipart/report Content-Type lacks boundary parameter");
175
+ }
176
+
177
+ // 2. Walk the multipart body. Find the message/feedback-report part
178
+ // and (optionally) the message/rfc822 / text/rfc822-headers part.
179
+ var parts = mimeParse.splitMimeParts(top.body, ct.params.boundary);
180
+ if (parts.length === 0) {
181
+ _emitMalformed(auditOn, "no-parts");
182
+ throw new MailArfError("mailarf/parse-failed",
183
+ "mailArf.parse: multipart/report body contains no parts");
184
+ }
185
+
186
+ var feedbackPart = null;
187
+ var originalPart = null;
188
+ for (var pi = 0; pi < parts.length; pi += 1) {
189
+ var partRaw = parts[pi];
190
+ var split;
191
+ try { split = mimeParse.splitHeadersAndBody(partRaw); }
192
+ catch (_e) { continue; }
193
+ var partCt = mimeParse.parseContentType(
194
+ mimeParse.findHeader(split.headers, "Content-Type") || ""
195
+ );
196
+ if (partCt.type === "message/feedback-report" && !feedbackPart) {
197
+ feedbackPart = split;
198
+ } else if ((partCt.type === "message/rfc822" ||
199
+ partCt.type === "text/rfc822-headers") && !originalPart) {
200
+ originalPart = split;
201
+ }
202
+ }
203
+
204
+ if (!feedbackPart) {
205
+ _emitMalformed(auditOn, "missing-feedback-report-part");
206
+ throw new MailArfError("mailarf/parse-failed",
207
+ "mailArf.parse: missing message/feedback-report subpart (RFC 5965 §2)");
208
+ }
209
+
210
+ // 3. Parse the feedback-report body — header:value lines per
211
+ // RFC 5965 §3.1. The body itself is structured headers, NOT
212
+ // HTML / JSON / free-form prose.
213
+ var reportFields = mimeParse.parseHeaderBlock(feedbackPart.body);
214
+ var fieldMap = {};
215
+ var extraFields = {};
216
+ // RFC 5965 §3.1 normalizes some fields; everything else passes
217
+ // through as extraFields so operators with vendor-specific tags
218
+ // (X-HmailServer-…, X-Yahoo-Newman-Property) don't lose them.
219
+ var KNOWN_FIELDS = {
220
+ "feedback-type": "feedbackType",
221
+ "user-agent": "userAgent",
222
+ "version": "version",
223
+ "original-mail-from": "originalFrom",
224
+ "original-rcpt-to": "originalRcptTo",
225
+ "arrival-date": "arrivalDate",
226
+ "reported-domain": "reportedDomain",
227
+ "source-ip": "sourceIp",
228
+ "authentication-results":"authenticationResults",
229
+ "auth-failure": "authFailure",
230
+ "reported-uri": "reportedUri",
231
+ "incidents": "incidents",
232
+ "delivery-result": "deliveryResult",
233
+ "original-envelope-id": "originalEnvelopeId",
234
+ };
235
+ for (var fi = 0; fi < reportFields.length; fi += 1) {
236
+ var f = reportFields[fi];
237
+ if (!f || !f.name) continue;
238
+ var lcName = f.name.toLowerCase();
239
+ fieldMap[lcName] = f.value;
240
+ }
241
+
242
+ // 4. Required fields (RFC 5965 §3.1).
243
+ for (var ri = 0; ri < ARF_REQUIRED_FIELDS.length; ri += 1) {
244
+ var req = ARF_REQUIRED_FIELDS[ri];
245
+ if (typeof fieldMap[req] !== "string" || fieldMap[req].length === 0) {
246
+ _emitMalformed(auditOn, "missing-" + req);
247
+ throw new MailArfError("mailarf/missing-required-field",
248
+ "mailArf.parse: required field '" + req + "' is missing");
249
+ }
250
+ }
251
+
252
+ // 5. Build the normalized shape.
253
+ var rcptToList = [];
254
+ for (var di = 0; di < reportFields.length; di += 1) {
255
+ var df = reportFields[di];
256
+ if (df && df.name && df.name.toLowerCase() === "original-rcpt-to") {
257
+ rcptToList.push(df.value);
258
+ }
259
+ }
260
+ var arrivalRaw = fieldMap["arrival-date"] || null;
261
+ var arrivalIso = null;
262
+ if (arrivalRaw) {
263
+ var d = new Date(arrivalRaw);
264
+ if (!isNaN(d.getTime())) arrivalIso = d.toISOString();
265
+ }
266
+ var incidentsRaw = fieldMap.incidents;
267
+ var incidents = null;
268
+ if (typeof incidentsRaw === "string") {
269
+ var parsed = parseInt(incidentsRaw, 10);
270
+ if (isFinite(parsed) && parsed >= 0) incidents = parsed;
271
+ }
272
+
273
+ // Surface non-normalized fields under extraFields for operator
274
+ // visibility (X-* tags ESPs add for routing diagnostics, etc.).
275
+ Object.keys(fieldMap).forEach(function (k) {
276
+ if (!KNOWN_FIELDS[k]) extraFields[k] = fieldMap[k];
277
+ });
278
+
279
+ var event = {
280
+ feedbackType: fieldMap["feedback-type"],
281
+ userAgent: fieldMap["user-agent"],
282
+ version: fieldMap.version || "1",
283
+ originalFrom: fieldMap["original-mail-from"] || null,
284
+ originalRcptTo: rcptToList,
285
+ arrivalDate: arrivalIso || arrivalRaw,
286
+ reportedDomain: fieldMap["reported-domain"] || null,
287
+ sourceIp: fieldMap["source-ip"] || null,
288
+ authenticationResults: fieldMap["authentication-results"] || null,
289
+ authFailure: fieldMap["auth-failure"] || null,
290
+ reportedUri: fieldMap["reported-uri"] || null,
291
+ incidents: incidents,
292
+ originalMessage: originalPart
293
+ ? (originalPart.body || _reassemblePart(originalPart))
294
+ : null,
295
+ extraFields: extraFields,
296
+ };
297
+
298
+ if (auditOn) {
299
+ try {
300
+ audit().safeEmit({
301
+ action: "system.mailarf.parsed",
302
+ outcome: "success",
303
+ metadata: {
304
+ feedbackType: event.feedbackType,
305
+ userAgent: event.userAgent,
306
+ reportedDomain: event.reportedDomain,
307
+ sourceIp: event.sourceIp,
308
+ authFailure: event.authFailure,
309
+ },
310
+ });
311
+ } catch (_e) { /* drop-silent — by design */ }
312
+ }
313
+
314
+ return event;
315
+ }
316
+
317
+ function _reassemblePart(part) {
318
+ // Reassemble headers + body for callers that want the full original
319
+ // message bytes (some ESPs strip the body and ship just headers).
320
+ var hdrs = "";
321
+ for (var i = 0; i < part.headers.length; i += 1) {
322
+ hdrs += part.headers[i].name + ": " + part.headers[i].value + "\r\n";
323
+ }
324
+ return hdrs + "\r\n" + (part.body || "");
325
+ }
326
+
327
+ function _emitMalformed(auditOn, reason) {
328
+ if (!auditOn) return;
329
+ try {
330
+ audit().safeEmit({
331
+ action: "system.mailarf.malformed",
332
+ outcome: "denied",
333
+ metadata: { reason: reason },
334
+ });
335
+ } catch (_e) { /* drop-silent — by design */ }
336
+ }
337
+
338
+ module.exports = {
339
+ parse: parse,
340
+ MailArfError: MailArfError,
341
+ ARF_MAX_REPORT_BYTES: ARF_MAX_REPORT_BYTES,
342
+ ARF_REQUIRED_FIELDS: ARF_REQUIRED_FIELDS,
343
+ };