@blamejs/core 0.7.64 → 0.7.74

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
@@ -34,10 +34,12 @@
34
34
 
35
35
  var dns = require("node:dns");
36
36
  var dnsPromises = dns.promises;
37
+ var zlib = require("node:zlib");
37
38
  var lazyRequire = require("./lazy-require");
38
39
  var validateOpts = require("./validate-opts");
39
40
  var C = require("./constants");
40
41
  var dkim = require("./mail-dkim");
42
+ var safeXml = require("./parsers/safe-xml");
41
43
  var { MailAuthError } = require("./framework-error");
42
44
 
43
45
  var observability = lazyRequire(function () { return require("./observability"); });
@@ -744,17 +746,369 @@ function _runVerify(signedString, sigB64, algorithm, keyB64, label) {
744
746
 
745
747
  void C; // C is imported for future TIME constants in policy fetchers.
746
748
 
749
+ // ---- ARC receiver-side trust evaluation (RFC 8617 §6) ----
750
+ //
751
+ // arc.verify confirms the cryptographic chain validates; arc.evaluate
752
+ // is the operator-side trust decision: given a passing chain, did any
753
+ // hop in the chain belong to a sealer the operator trusts? The trust
754
+ // list is operator policy — typically the operator's own domain plus
755
+ // upstream relays the operator has agreed to honor (mailing list
756
+ // operators, MX-vendor middleware).
757
+ //
758
+ // var rv = await b.mail.arc.evaluate(rfc822, {
759
+ // trustedSealers: ["example.com", "mailgun.net"],
760
+ // });
761
+ // // → { chainStatus: "pass", trusted: true, trustedHop: 2,
762
+ // // trustedDomain: "mailgun.net" }
763
+
764
+ async function arcEvaluate(rfc822, opts) {
765
+ if (typeof rfc822 !== "string" || rfc822.length === 0) {
766
+ throw new MailAuthError("mail-auth/arc-bad-input",
767
+ "arc.evaluate: rfc822 must be a non-empty string");
768
+ }
769
+ opts = opts || {};
770
+ if (!Array.isArray(opts.trustedSealers)) {
771
+ throw new MailAuthError("mail-auth/arc-bad-trusted-sealers",
772
+ "arc.evaluate: opts.trustedSealers must be an array of domain strings");
773
+ }
774
+ var trusted = {};
775
+ for (var ti = 0; ti < opts.trustedSealers.length; ti += 1) {
776
+ var d = opts.trustedSealers[ti];
777
+ if (typeof d === "string" && d.length > 0) trusted[d.toLowerCase()] = true;
778
+ }
779
+
780
+ var verdict = await arcVerify(rfc822, opts);
781
+ var out = {
782
+ chainStatus: verdict.chainStatus,
783
+ hopCount: verdict.hopCount,
784
+ trusted: false,
785
+ trustedHop: null,
786
+ trustedDomain: null,
787
+ };
788
+ if (verdict.reason) out.reason = verdict.reason;
789
+
790
+ if (verdict.chainStatus !== "pass" || !Array.isArray(verdict.hops)) return out;
791
+
792
+ // Re-extract the d= of each hop's AS from the original headers — the
793
+ // verify-result shape doesn't carry it. Walk hops most-recent-first
794
+ // so we attribute the trust decision to the deepest trusted sealer.
795
+ var headers = _parseHeaderLines(_splitHeaders(rfc822));
796
+ var hopDomains = {};
797
+ for (var hi = 0; hi < headers.length; hi += 1) {
798
+ var line = headers[hi];
799
+ var colonAt = line.indexOf(":");
800
+ if (colonAt === -1) continue;
801
+ var name = line.slice(0, colonAt).trim().toLowerCase();
802
+ if (name !== "arc-seal") continue;
803
+ var value = line.slice(colonAt + 1).trim();
804
+ var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
805
+ var dMatch = value.match(/(?:^|[;,\s])d=([^\s;]+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
806
+ if (iMatch && dMatch) hopDomains[parseInt(iMatch[1], 10)] = dMatch[1].toLowerCase();
807
+ }
808
+
809
+ for (var ri2 = verdict.hops.length - 1; ri2 >= 0; ri2 -= 1) {
810
+ var hop = verdict.hops[ri2];
811
+ if (!hop || hop.amsResult !== "pass" || hop.asResult !== "pass") continue;
812
+ var domain = hopDomains[hop.instance];
813
+ if (domain && trusted[domain]) {
814
+ out.trusted = true;
815
+ out.trustedHop = hop.instance;
816
+ out.trustedDomain = domain;
817
+ break;
818
+ }
819
+ }
820
+ return out;
821
+ }
822
+
823
+ // ---- Authentication-Results header (RFC 8601) builder ----
824
+ //
825
+ // Build the A-R header value the receiving MTA prepends to the message
826
+ // before delivery. Operators consume per-method results from
827
+ // b.mail.spf.verify / b.mail.dmarc.evaluate / b.mail.arc.verify (or
828
+ // .evaluate) and pass them to .emit; the framework formats the RFC
829
+ // 8601-conformant header string.
830
+ //
831
+ // var hdr = b.mail.authResults.emit({
832
+ // authservId: "mx.example.com",
833
+ // results: [
834
+ // { method: "spf", result: "pass", smtpMailfrom: "user@sender.example" },
835
+ // { method: "dkim", result: "pass", domain: "sender.example" },
836
+ // { method: "dmarc", result: "pass", from: "user@sender.example" },
837
+ // { method: "arc", result: "pass" },
838
+ // ],
839
+ // });
840
+ // // → "Authentication-Results: mx.example.com;\r\n spf=pass smtp.mailfrom=user@sender.example;\r\n dkim=pass header.d=sender.example;\r\n dmarc=pass header.from=user@sender.example;\r\n arc=pass"
841
+
842
+ var AR_VALID_RESULTS = {
843
+ pass: 1, fail: 1, neutral: 1, none: 1, softfail: 1, policy: 1,
844
+ permerror: 1, temperror: 1, hardfail: 1, bestguesspass: 1,
845
+ };
846
+ var AR_VALID_METHODS = {
847
+ auth: 1, dkim: 1, "dkim-adsp": 1, dmarc: 1, "domainkeys": 1,
848
+ "iprev": 1, "sender-id": 1, spf: 1, arc: 1, "smime": 1, dane: 1,
849
+ "vbr": 1, "dnswl": 1, "x-original-authentication-results": 1,
850
+ };
851
+
852
+ function authResultsEmit(opts) {
853
+ validateOpts.requireObject(opts, "authResults.emit", MailAuthError, "mail-auth/ar-bad-input");
854
+ validateOpts(opts, ["authservId", "results", "version", "fold"], "authResults.emit");
855
+ validateOpts.requireNonEmptyString(opts.authservId,
856
+ "authResults.emit: authservId", MailAuthError, "mail-auth/ar-bad-authserv-id");
857
+ if (/[\r\n\0]/.test(opts.authservId)) {
858
+ throw new MailAuthError("mail-auth/ar-bad-authserv-id",
859
+ "authResults.emit: authservId contains forbidden control characters");
860
+ }
861
+ if (!Array.isArray(opts.results)) {
862
+ throw new MailAuthError("mail-auth/ar-bad-results",
863
+ "authResults.emit: results must be an array");
864
+ }
865
+
866
+ var version = (opts.version === undefined || opts.version === null)
867
+ ? "1" : String(opts.version);
868
+ var head = opts.authservId + (version === "1" ? "" : " " + version);
869
+
870
+ if (opts.results.length === 0) {
871
+ // RFC 8601 §2.2 — when no methods evaluated, emit `none`.
872
+ return "Authentication-Results: " + head + "; none";
873
+ }
874
+
875
+ var clauses = [];
876
+ for (var i = 0; i < opts.results.length; i += 1) {
877
+ var r = opts.results[i];
878
+ if (!r || typeof r !== "object") {
879
+ throw new MailAuthError("mail-auth/ar-bad-result-entry",
880
+ "authResults.emit: results[" + i + "] must be an object");
881
+ }
882
+ var method = String(r.method || "").toLowerCase();
883
+ var result = String(r.result || "").toLowerCase();
884
+ if (!AR_VALID_METHODS[method]) {
885
+ throw new MailAuthError("mail-auth/ar-bad-method",
886
+ "authResults.emit: unknown method '" + r.method + "'");
887
+ }
888
+ if (!AR_VALID_RESULTS[result]) {
889
+ throw new MailAuthError("mail-auth/ar-bad-result",
890
+ "authResults.emit: unknown result '" + r.result + "' for method '" + method + "'");
891
+ }
892
+ var clause = method + "=" + result;
893
+ if (r.reason && typeof r.reason === "string" && !/[\r\n\0;]/.test(r.reason)) {
894
+ clause += ' reason="' + r.reason.replace(/"/g, "'") + '"';
895
+ }
896
+ // Method-specific properties (ptype.property=value triples per
897
+ // RFC 8601 §2.3). Operators pass them as flat object keys.
898
+ var props = {
899
+ smtpMailfrom: "smtp.mailfrom",
900
+ smtpHelo: "smtp.helo",
901
+ domain: "header.d",
902
+ selector: "header.s",
903
+ from: "header.from",
904
+ iprev: "policy.iprev",
905
+ ip: "policy.ip",
906
+ tls: "policy.tls",
907
+ };
908
+ var propKeys = Object.keys(props);
909
+ for (var pk = 0; pk < propKeys.length; pk += 1) {
910
+ var k = propKeys[pk];
911
+ if (typeof r[k] === "string" && r[k].length > 0 && !/[\r\n\0;]/.test(r[k])) {
912
+ clause += " " + props[k] + "=" + r[k];
913
+ }
914
+ }
915
+ clauses.push(clause);
916
+ }
917
+
918
+ var fold = opts.fold !== false;
919
+ var sep = fold ? ";\r\n " : "; ";
920
+ return "Authentication-Results: " + head + ";\r\n " + clauses.join(sep);
921
+ }
922
+
923
+ // ---- DMARC aggregate (RUA) report parser (RFC 7489 §7.2 / draft-ietf-dmarc-aggregate-reporting) ----
924
+ //
925
+ // MTAs that publish a DMARC `rua=` policy receive aggregate reports
926
+ // from peers — XML attached to a multipart/report mail body, often
927
+ // gzip-compressed. This primitive accepts the report bytes (raw XML,
928
+ // gzipped XML, or a parsed object) and returns a structured shape
929
+ // with the metadata, published policy, and per-record evaluation
930
+ // results.
931
+ //
932
+ // var rv = b.mail.dmarc.parseAggregateReport(xmlBytes);
933
+ // // → {
934
+ // // reportMetadata: { orgName, email, reportId, dateRange },
935
+ // // policyPublished: { domain, adkim, aspf, p, sp, pct, ... },
936
+ // // records: [{ sourceIp, count, dispositions, identifiers, authResults }]
937
+ // // }
938
+
939
+ var DMARC_RUA_MAX_REPORT_BYTES = C.BYTES.mib(8);
940
+ var DMARC_RUA_MAX_RECORDS_PER_REPORT = 10000; // allow:raw-byte-literal allow:raw-time-literal — record cap, not seconds
941
+
942
+ function _arrayOf(value) {
943
+ if (value === undefined || value === null) return [];
944
+ return Array.isArray(value) ? value : [value];
945
+ }
946
+
947
+ function dmarcParseAggregateReport(input, opts) {
948
+ opts = opts || {};
949
+ var bytes;
950
+ if (Buffer.isBuffer(input)) bytes = input;
951
+ else if (typeof input === "string") bytes = Buffer.from(input, "utf8");
952
+ else if (input && typeof input === "object" && input.feedback) {
953
+ // operator already pre-parsed via safeXml; skip the parse step.
954
+ return _shapeAggregateReport(input);
955
+ }
956
+ else {
957
+ throw new MailAuthError("mail-auth/dmarc-rua-bad-input",
958
+ "dmarc.parseAggregateReport: input must be a Buffer, string, or pre-parsed object");
959
+ }
960
+ if (bytes.length > DMARC_RUA_MAX_REPORT_BYTES) {
961
+ throw new MailAuthError("mail-auth/dmarc-rua-too-large",
962
+ "dmarc.parseAggregateReport: report exceeds " + DMARC_RUA_MAX_REPORT_BYTES + " bytes");
963
+ }
964
+
965
+ // Auto-detect gzip via magic 0x1f 0x8b (RFC 1952). DMARC RUA reports
966
+ // are commonly zip- or gzip-compressed; the gzip magic check covers
967
+ // the bulk of real-world reports. ZIP archives need operator-side
968
+ // unzip first (the framework doesn't ship a ZIP primitive yet).
969
+ var contentType = (opts.contentType || "").toLowerCase();
970
+ var looksGzip = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
971
+ if (contentType.indexOf("gzip") !== -1 || looksGzip) {
972
+ try { bytes = zlib.gunzipSync(bytes, { maxOutputLength: DMARC_RUA_MAX_REPORT_BYTES }); }
973
+ catch (e) {
974
+ throw new MailAuthError("mail-auth/dmarc-rua-gunzip-failed",
975
+ "dmarc.parseAggregateReport: gunzip failed: " + ((e && e.message) || String(e)));
976
+ }
977
+ }
978
+
979
+ var parsed;
980
+ try { parsed = safeXml.parse(bytes.toString("utf8"), { maxBytes: DMARC_RUA_MAX_REPORT_BYTES }); }
981
+ catch (e) {
982
+ throw new MailAuthError("mail-auth/dmarc-rua-bad-xml",
983
+ "dmarc.parseAggregateReport: XML parse failed: " + ((e && e.message) || String(e)));
984
+ }
985
+ return _shapeAggregateReport(parsed);
986
+ }
987
+
988
+ function _shapeAggregateReport(parsed) {
989
+ if (!parsed || typeof parsed !== "object" || !parsed.feedback) {
990
+ throw new MailAuthError("mail-auth/dmarc-rua-no-feedback",
991
+ "dmarc.parseAggregateReport: report root must be <feedback>");
992
+ }
993
+ var feedback = parsed.feedback;
994
+ var rmRaw = feedback.report_metadata || {};
995
+ var ppRaw = feedback.policy_published || {};
996
+ var records = _arrayOf(feedback.record);
997
+ if (records.length > DMARC_RUA_MAX_RECORDS_PER_REPORT) {
998
+ throw new MailAuthError("mail-auth/dmarc-rua-too-many-records",
999
+ "dmarc.parseAggregateReport: report has " + records.length +
1000
+ " records (cap " + DMARC_RUA_MAX_RECORDS_PER_REPORT + ")");
1001
+ }
1002
+
1003
+ var dateRange = rmRaw.date_range || {};
1004
+ var beginSec = parseInt(dateRange.begin, 10);
1005
+ var endSec = parseInt(dateRange.end, 10);
1006
+
1007
+ var shaped = {
1008
+ reportMetadata: {
1009
+ orgName: rmRaw.org_name || null,
1010
+ email: rmRaw.email || null,
1011
+ reportId: rmRaw.report_id || null,
1012
+ extraContact: rmRaw.extra_contact_info || null,
1013
+ dateRange: {
1014
+ begin: isFinite(beginSec) ? beginSec : null,
1015
+ end: isFinite(endSec) ? endSec : null,
1016
+ },
1017
+ },
1018
+ policyPublished: {
1019
+ domain: ppRaw.domain || null,
1020
+ adkim: ppRaw.adkim || null,
1021
+ aspf: ppRaw.aspf || null,
1022
+ p: ppRaw.p || null,
1023
+ sp: ppRaw.sp || null,
1024
+ pct: ppRaw.pct === undefined ? null : parseInt(ppRaw.pct, 10),
1025
+ fo: ppRaw.fo || null,
1026
+ },
1027
+ records: records.map(function (rec) {
1028
+ var row = rec.row || {};
1029
+ var pe = row.policy_evaluated || {};
1030
+ var ids = rec.identifiers || {};
1031
+ var ar = rec.auth_results || {};
1032
+ var dkimResults = _arrayOf(ar.dkim).map(function (d) {
1033
+ return {
1034
+ domain: d.domain || null,
1035
+ selector: d.selector || null,
1036
+ result: d.result || null,
1037
+ humanResult: d.human_result || null,
1038
+ };
1039
+ });
1040
+ var spfResults = _arrayOf(ar.spf).map(function (s) {
1041
+ return {
1042
+ domain: s.domain || null,
1043
+ result: s.result || null,
1044
+ scope: s.scope || null,
1045
+ };
1046
+ });
1047
+ var reasons = _arrayOf(pe.reason).map(function (r) {
1048
+ return { type: r.type || null, comment: r.comment || null };
1049
+ });
1050
+ var count = parseInt(row.count, 10);
1051
+ return {
1052
+ sourceIp: row.source_ip || null,
1053
+ count: isFinite(count) ? count : null,
1054
+ dispositions: {
1055
+ disposition: pe.disposition || null,
1056
+ dkim: pe.dkim || null,
1057
+ spf: pe.spf || null,
1058
+ reasons: reasons,
1059
+ },
1060
+ identifiers: {
1061
+ headerFrom: ids.header_from || null,
1062
+ envelopeFrom: ids.envelope_from || null,
1063
+ envelopeTo: ids.envelope_to || null,
1064
+ },
1065
+ authResults: {
1066
+ dkim: dkimResults,
1067
+ spf: spfResults,
1068
+ },
1069
+ };
1070
+ }),
1071
+ };
1072
+
1073
+ // Convenience aggregates — most operators want the totals up front.
1074
+ var totalCount = 0;
1075
+ var passCount = 0;
1076
+ var failCount = 0;
1077
+ for (var i = 0; i < shaped.records.length; i += 1) {
1078
+ var r = shaped.records[i];
1079
+ if (typeof r.count === "number") totalCount += r.count;
1080
+ var dispDkim = r.dispositions.dkim;
1081
+ var dispSpf = r.dispositions.spf;
1082
+ if (dispDkim === "pass" || dispSpf === "pass") {
1083
+ if (typeof r.count === "number") passCount += r.count;
1084
+ } else {
1085
+ if (typeof r.count === "number") failCount += r.count;
1086
+ }
1087
+ }
1088
+ shaped.totals = {
1089
+ messages: totalCount,
1090
+ aligned: passCount,
1091
+ notAligned: failCount,
1092
+ };
1093
+ return shaped;
1094
+ }
1095
+
747
1096
  module.exports = {
748
1097
  spf: Object.freeze({
749
1098
  verify: spfVerify,
750
1099
  parseRecord: _parseSpfRecord,
751
1100
  }),
752
1101
  dmarc: Object.freeze({
753
- evaluate: dmarcEvaluate,
754
- parseRecord: _parseDmarcRecord,
1102
+ evaluate: dmarcEvaluate,
1103
+ parseRecord: _parseDmarcRecord,
1104
+ parseAggregateReport: dmarcParseAggregateReport,
755
1105
  }),
756
1106
  arc: Object.freeze({
757
1107
  verify: arcVerify,
1108
+ evaluate: arcEvaluate,
1109
+ }),
1110
+ authResults: Object.freeze({
1111
+ emit: authResultsEmit,
758
1112
  }),
759
1113
  MailAuthError: MailAuthError,
760
1114
  SPF_DNS_LOOKUP_LIMIT: SPF_DNS_LOOKUP_LIMIT,
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ /**
3
+ * mail-unsubscribe — RFC 8058 / RFC 2369 List-Unsubscribe support.
4
+ *
5
+ * Two pieces:
6
+ * 1. buildHeaders({ url, mailto, oneClick }) — produces the
7
+ * `List-Unsubscribe` and (when oneClick) `List-Unsubscribe-Post`
8
+ * header values that get merged into the outbound message.
9
+ * 2. handler({ onUnsubscribe }) — request-lifecycle middleware that
10
+ * validates the RFC 8058 one-click POST body
11
+ * (`List-Unsubscribe=One-Click`) and dispatches to the operator's
12
+ * onUnsubscribe callback. Returns 200 OK with empty body on
13
+ * success per RFC 8058 §3.1.
14
+ *
15
+ * Compliance context: Gmail + Yahoo bulk-sender requirements (Feb 2024)
16
+ * mandate one-click List-Unsubscribe for senders >= 5k/day. Microsoft
17
+ * 365 followed in 2025. Operators sending bulk transactional or
18
+ * marketing mail without these headers see escalating spam-folder /
19
+ * outright-reject rates.
20
+ *
21
+ * var headers = b.mail.unsubscribe.buildHeaders({
22
+ * url: "https://example.com/u?token=...",
23
+ * mailto: "unsubscribe@example.com?subject=unsub-...",
24
+ * oneClick: true,
25
+ * });
26
+ * // → {
27
+ * // "List-Unsubscribe": "<https://...>, <mailto:...>",
28
+ * // "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
29
+ * // }
30
+ *
31
+ * var unsubMw = b.mail.unsubscribe.handler({
32
+ * onUnsubscribe: async function (req, res) {
33
+ * // Operator extracts the token from req.url / req.body and
34
+ * // performs the unsubscribe. Returning resolves the request.
35
+ * var token = new URL(req.url, "https://h").searchParams.get("token");
36
+ * await db.markUnsubscribed(token);
37
+ * },
38
+ * });
39
+ * app.post("/email/unsubscribe", unsubMw);
40
+ */
41
+
42
+ var lazyRequire = require("./lazy-require");
43
+ var safeUrl = require("./safe-url");
44
+
45
+ var observability = lazyRequire(function () { return require("./observability"); });
46
+ void observability;
47
+
48
+ // Build the List-Unsubscribe + List-Unsubscribe-Post headers per
49
+ // RFC 8058 + RFC 2369. Returns a headers object suitable for merging
50
+ // into `b.mail.send({ headers })`.
51
+ function buildHeaders(opts) {
52
+ if (!opts || typeof opts !== "object") {
53
+ throw new Error("buildHeaders: opts object required " +
54
+ "({ url?, mailto?, oneClick? })");
55
+ }
56
+ var parts = [];
57
+ if (typeof opts.url === "string" && opts.url.length > 0) {
58
+ // Validate URL — refuse non-https / non-http schemes.
59
+ var parsed = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
60
+ if (!parsed) {
61
+ throw new Error("buildHeaders: opts.url must be a valid http(s) URL");
62
+ }
63
+ parts.push("<" + parsed.href + ">");
64
+ }
65
+ if (typeof opts.mailto === "string" && opts.mailto.length > 0) {
66
+ // mailto: is `mailto:addr` or `mailto:addr?subject=...&body=...`.
67
+ // Don't run safeUrl on it (mailto isn't in ALLOW_HTTP_TLS); just
68
+ // do a minimal shape check.
69
+ if (opts.mailto.indexOf("mailto:") === 0) {
70
+ parts.push("<" + opts.mailto + ">");
71
+ } else {
72
+ parts.push("<mailto:" + opts.mailto + ">");
73
+ }
74
+ }
75
+ if (parts.length === 0) {
76
+ throw new Error("buildHeaders: at least one of opts.url / opts.mailto required");
77
+ }
78
+ var headers = { "List-Unsubscribe": parts.join(", ") };
79
+ if (opts.oneClick === true) {
80
+ // RFC 8058 §2 — exact byte sequence required for one-click.
81
+ headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
82
+ }
83
+ return headers;
84
+ }
85
+
86
+ // RFC 8058 §3.1 one-click handler middleware.
87
+ //
88
+ // On POST, the body MUST contain `List-Unsubscribe=One-Click` (case-
89
+ // sensitive, exact byte sequence). On match, the operator's
90
+ // onUnsubscribe callback runs — the operator extracts the
91
+ // per-recipient token from the URL or body and performs the
92
+ // unsubscribe. Returning resolves the request with 200 OK.
93
+ //
94
+ // On non-POST or wrong body, the middleware refuses with 400.
95
+ function handler(opts) {
96
+ opts = opts || {};
97
+ if (typeof opts.onUnsubscribe !== "function") {
98
+ throw new Error("mail.unsubscribe.handler: opts.onUnsubscribe " +
99
+ "must be a function (req, res) → Promise");
100
+ }
101
+ return async function unsubscribeMiddleware(req, res) {
102
+ if ((req.method || "").toUpperCase() !== "POST") {
103
+ res.statusCode = 405;
104
+ res.setHeader("Allow", "POST");
105
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
106
+ res.end("RFC 8058 one-click unsubscribe requires POST");
107
+ return;
108
+ }
109
+ var bodyChunks = [];
110
+ var totalLen = 0;
111
+ var maxBodyBytes = opts.maxBodyBytes || 4096; // allow:raw-byte-literal — RFC 8058 §3.1 body is short — `List-Unsubscribe=One-Click` plus operator additions
112
+ var bodyComplete = await new Promise(function (resolve) {
113
+ req.on("data", function (chunk) {
114
+ totalLen += chunk.length;
115
+ if (totalLen > maxBodyBytes) {
116
+ // Stop reading; we'll respond 413 below.
117
+ resolve(false);
118
+ return;
119
+ }
120
+ bodyChunks.push(chunk);
121
+ });
122
+ req.on("end", function () { resolve(true); });
123
+ req.on("error", function () { resolve(false); });
124
+ });
125
+ if (!bodyComplete) {
126
+ res.statusCode = 413;
127
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
128
+ res.end("body exceeds max bytes for one-click unsubscribe");
129
+ return;
130
+ }
131
+ var body = Buffer.concat(bodyChunks).toString("utf8");
132
+ if (body.indexOf("List-Unsubscribe=One-Click") === -1) {
133
+ res.statusCode = 400;
134
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
135
+ res.end("RFC 8058 §3.1: body must contain `List-Unsubscribe=One-Click`");
136
+ return;
137
+ }
138
+ try {
139
+ await opts.onUnsubscribe(req, res);
140
+ // If the operator didn't end the response, send 200 OK with
141
+ // empty body per RFC 8058 §3.1.
142
+ if (!res.writableEnded) {
143
+ res.statusCode = 200;
144
+ res.end();
145
+ }
146
+ } catch (err) {
147
+ if (!res.writableEnded) {
148
+ res.statusCode = 500;
149
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
150
+ res.end("unsubscribe failed");
151
+ }
152
+ throw err;
153
+ }
154
+ };
155
+ }
156
+
157
+ module.exports = {
158
+ buildHeaders: buildHeaders,
159
+ handler: handler,
160
+ };