@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/CHANGELOG.md +20 -0
- package/index.js +2 -0
- package/lib/auth/aal.js +149 -0
- package/lib/auth/dpop.js +512 -0
- package/lib/auth/jwt.js +67 -0
- package/lib/auth/oauth.js +13 -6
- package/lib/cookies.js +2 -1
- package/lib/mail-auth.js +356 -2
- package/lib/mail-unsubscribe.js +160 -0
- package/lib/mail.js +135 -9
- package/lib/middleware/dpop.js +173 -0
- package/lib/middleware/gpc.js +120 -0
- package/lib/middleware/index.js +8 -0
- package/lib/middleware/require-aal.js +107 -0
- package/lib/middleware/security-headers.js +29 -1
- package/lib/network-dns.js +131 -12
- package/lib/network-smtp-policy.js +118 -3
- package/lib/vendor/MANIFEST.json +21 -5
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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:
|
|
754
|
-
parseRecord:
|
|
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
|
+
};
|