@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
@@ -27,22 +27,28 @@
27
27
  * `onBounce(event)` so the suppression list can be updated before
28
28
  * the 200 response goes back.
29
29
  *
30
- * Generic RFC 3464 DSN is intentionally NOT a built-in vendor —
31
- * parsing arbitrary MTA reports needs a full email parser the
32
- * framework does not vendor for this surface. Operators with raw
33
- * DSN inflow supply `{ parser }` to plug a custom normalizer.
30
+ * Generic RFC 3464 / RFC 3461 / RFC 6533 DSN is wired in as
31
+ * `b.mailBounce.dsn.parse` / `b.mailBounce.dsn.build` a parser for
32
+ * raw multipart/report message/delivery-status MIME bounces (the
33
+ * shape any spec-conforming MTA returns) and a generator that builds
34
+ * the same shape for operators that need to issue bounces from their
35
+ * own MTA. Operators with bespoke vendor inflow can still supply
36
+ * `{ parser }` to plug a custom normalizer onto `parse` / `handler`.
34
37
  *
35
38
  * @card
36
39
  * Inbound mail bounce-handler — parse the vendor's webhook DSN / complaint / delivery payload, normalize it into one event shape, classify hard vs soft bounces, and feed an operator-supplied suppression-list hook.
37
40
  */
38
41
 
42
+ var crypto = require("./crypto");
39
43
  var lazyRequire = require("./lazy-require");
44
+ var mimeParse = require("./mime-parse");
40
45
  var numericBounds = require("./numeric-bounds");
41
46
  var audit = lazyRequire(function () { return require("./audit"); });
42
47
  var safeBuffer = require("./safe-buffer");
43
48
  var safeJson = require("./safe-json");
44
49
  var C = require("./constants");
45
50
  var requestHelpers = require("./request-helpers");
51
+ var validateOpts = require("./validate-opts");
46
52
  var { defineClass } = require("./framework-error");
47
53
 
48
54
  var HTTP = requestHelpers.HTTP_STATUS;
@@ -558,6 +564,372 @@ function handler(opts) {
558
564
  };
559
565
  }
560
566
 
567
+ // ---- Generic RFC 3464 / RFC 3461 / RFC 6533 DSN ----
568
+ //
569
+ // A delivery status notification is a multipart/report MIME body:
570
+ //
571
+ // Content-Type: multipart/report;
572
+ // report-type=delivery-status;
573
+ // boundary="boundary-string"
574
+ //
575
+ // --boundary-string
576
+ // Content-Type: text/plain; charset=us-ascii
577
+ //
578
+ // <human-readable description of the failure>
579
+ //
580
+ // --boundary-string
581
+ // Content-Type: message/delivery-status
582
+ //
583
+ // Reporting-MTA: dns; mta.example.com
584
+ // Arrival-Date: Mon, 28 Apr 2026 12:00:00 +0000
585
+ //
586
+ // Original-Recipient: rfc822;user@example.com
587
+ // Final-Recipient: rfc822;user@example.com
588
+ // Action: failed
589
+ // Status: 5.1.1
590
+ // Remote-MTA: dns; mx.example.com
591
+ // Diagnostic-Code: smtp; 550 5.1.1 No such user
592
+ //
593
+ // --boundary-string
594
+ // Content-Type: message/rfc822
595
+ //
596
+ // <original message headers + body>
597
+ //
598
+ // --boundary-string--
599
+ //
600
+ // RFC 3461 adds the SMTP NOTIFY=SUCCESS,FAILURE,DELAY and RET=FULL,HDRS
601
+ // extensions — they're carried inside the SMTP envelope, but the DSN
602
+ // the framework generates / parses ends up reflecting that operator
603
+ // choice via the `originalMessage` decision (full body vs headers
604
+ // only) and the per-recipient `Action` field (delivered / failed /
605
+ // delayed / relayed / expanded).
606
+ //
607
+ // RFC 6533 (SMTPUTF8 / EAI) extends the address-type tag from rfc822
608
+ // to utf-8 so internationalized mailbox names ride through. The
609
+ // parser accepts both; the generator picks utf-8 when the recipient
610
+ // contains non-ASCII bytes.
611
+
612
+ // Per-message DSN fields (RFC 3464 §2.2). Listed for validation +
613
+ // canonical-case re-emission.
614
+ var DSN_PER_MESSAGE_FIELDS = {
615
+ "original-envelope-id": "Original-Envelope-Id",
616
+ "reporting-mta": "Reporting-MTA",
617
+ "dsn-gateway": "DSN-Gateway",
618
+ "received-from-mta": "Received-From-MTA",
619
+ "arrival-date": "Arrival-Date",
620
+ };
621
+
622
+ // Per-recipient DSN fields (RFC 3464 §2.3).
623
+ var DSN_PER_RECIPIENT_FIELDS = {
624
+ "original-recipient": "Original-Recipient",
625
+ "final-recipient": "Final-Recipient",
626
+ "action": "Action",
627
+ "status": "Status",
628
+ "remote-mta": "Remote-MTA",
629
+ "diagnostic-code": "Diagnostic-Code",
630
+ "last-attempt-date": "Last-Attempt-Date",
631
+ "final-log-id": "Final-Log-ID",
632
+ "will-retry-until": "Will-Retry-Until",
633
+ };
634
+
635
+ // Action token allowlist — RFC 3464 §2.3.3.
636
+ var DSN_ACTIONS = {
637
+ "failed": true,
638
+ "delayed": true,
639
+ "delivered": true,
640
+ "relayed": true,
641
+ "expanded": true,
642
+ };
643
+
644
+ // Body cap for the DSN parser. The parser walks the raw bytes once;
645
+ // any payload above 1 MiB is pathological — no spec-conforming DSN
646
+ // approaches that, and uncapped parsing would let a hostile peer pin
647
+ // CPU on regex backtracking inside the header decoder.
648
+ var DSN_MAX_BYTES = C.BYTES.mib(1);
649
+
650
+ function _parseDeliveryStatusBody(body) {
651
+ // RFC 3464 §2.1 — message/delivery-status is per-message field group
652
+ // followed by ONE OR MORE per-recipient groups, each separated by an
653
+ // empty line.
654
+ var groups = body.split(/\r?\n\r?\n/).map(function (g) { return g.trim(); }).filter(Boolean);
655
+ if (groups.length === 0) return { perMessage: {}, perRecipients: [] };
656
+ var perMessage = {};
657
+ var msgHeaders = mimeParse.parseHeaderBlock(groups[0]);
658
+ for (var i = 0; i < msgHeaders.length; i += 1) {
659
+ perMessage[msgHeaders[i].name.toLowerCase()] = msgHeaders[i].value;
660
+ }
661
+ var perRecipients = [];
662
+ for (var g = 1; g < groups.length; g += 1) {
663
+ var headers = mimeParse.parseHeaderBlock(groups[g]);
664
+ if (headers.length === 0) continue;
665
+ var rec = {};
666
+ for (var k = 0; k < headers.length; k += 1) {
667
+ rec[headers[k].name.toLowerCase()] = headers[k].value;
668
+ }
669
+ perRecipients.push(rec);
670
+ }
671
+ return { perMessage: perMessage, perRecipients: perRecipients };
672
+ }
673
+
674
+ function _actionToSubType(action) {
675
+ // RFC 3464 §2.3.3 actions map onto the framework's bounce-shape
676
+ // vocabulary. failed -> hard, delayed -> soft. delivered / relayed /
677
+ // expanded come back as type=delivery; the parser swallows those
678
+ // before this lookup runs.
679
+ var a = (action || "").toLowerCase();
680
+ if (a === "failed") return "hard";
681
+ if (a === "delayed") return "soft";
682
+ return "unknown";
683
+ }
684
+
685
+ function _parseDsn(rawMessage) {
686
+ if (typeof rawMessage !== "string" || rawMessage.length === 0) {
687
+ throw _err("bounce/dsn-parse-failed",
688
+ "mailBounce.dsn.parse: rawMessage must be a non-empty string");
689
+ }
690
+ // Hot-path body cap. Above this limit the parser stops trying to
691
+ // interpret the bytes — pathological inputs become a typed error
692
+ // rather than a regex-backtrack hang.
693
+ if (rawMessage.length > DSN_MAX_BYTES) {
694
+ throw _err("bounce/dsn-parse-failed",
695
+ "mailBounce.dsn.parse: message exceeds " + DSN_MAX_BYTES + " bytes");
696
+ }
697
+
698
+ var top = mimeParse.splitHeadersAndBody(rawMessage);
699
+ var ctRaw = mimeParse.findHeader(top.headers, "Content-Type");
700
+ if (!ctRaw) {
701
+ throw _err("bounce/dsn-malformed",
702
+ "mailBounce.dsn.parse: missing top-level Content-Type");
703
+ }
704
+ var ct = mimeParse.parseContentType(ctRaw);
705
+ if (ct.type !== "multipart/report") {
706
+ throw _err("bounce/dsn-malformed",
707
+ "mailBounce.dsn.parse: top-level Content-Type must be multipart/report; got " + ct.type);
708
+ }
709
+ if (ct.params["report-type"] && ct.params["report-type"].toLowerCase() !== "delivery-status") {
710
+ throw _err("bounce/dsn-malformed",
711
+ "mailBounce.dsn.parse: report-type must be delivery-status; got " + ct.params["report-type"]);
712
+ }
713
+ var boundary = ct.params.boundary;
714
+ if (!boundary) {
715
+ throw _err("bounce/dsn-malformed",
716
+ "mailBounce.dsn.parse: multipart/report missing boundary parameter");
717
+ }
718
+
719
+ var parts = mimeParse.splitMimeParts(top.body, boundary);
720
+ if (parts.length < 2) {
721
+ throw _err("bounce/dsn-malformed",
722
+ "mailBounce.dsn.parse: multipart/report needs at least 2 parts (text + delivery-status); got " + parts.length);
723
+ }
724
+
725
+ // Find the message/delivery-status part.
726
+ var statusBody = null;
727
+ var humanText = null;
728
+ var originalMessage = null;
729
+ for (var i = 0; i < parts.length; i += 1) {
730
+ var partSplit = mimeParse.splitHeadersAndBody(parts[i].replace(/^\r?\n/, ""));
731
+ var partCtRaw = mimeParse.findHeader(partSplit.headers, "Content-Type") || "text/plain";
732
+ var partCt = mimeParse.parseContentType(partCtRaw);
733
+ if (partCt.type === "message/delivery-status") {
734
+ statusBody = partSplit.body;
735
+ } else if (partCt.type === "text/plain" && humanText === null) {
736
+ humanText = partSplit.body;
737
+ } else if (partCt.type === "message/rfc822" || partCt.type === "text/rfc822-headers") {
738
+ originalMessage = partSplit.body;
739
+ }
740
+ }
741
+
742
+ if (statusBody === null) {
743
+ throw _err("bounce/dsn-malformed",
744
+ "mailBounce.dsn.parse: no message/delivery-status part found");
745
+ }
746
+
747
+ var status = _parseDeliveryStatusBody(statusBody);
748
+ if (status.perRecipients.length === 0) {
749
+ throw _err("bounce/dsn-malformed",
750
+ "mailBounce.dsn.parse: message/delivery-status has no per-recipient groups");
751
+ }
752
+
753
+ // First recipient drives the normalized event (matches the SES /
754
+ // postmark convention; multi-recipient DSNs are exposed via
755
+ // event.raw.allRecipients for operators that need fan-out).
756
+ var recip = status.perRecipients[0];
757
+ var action = (recip["action"] || "").toLowerCase();
758
+ var finalRecipient = mimeParse.stripAddressType(recip["final-recipient"]) ||
759
+ mimeParse.stripAddressType(recip["original-recipient"]);
760
+ if (!finalRecipient) {
761
+ throw _err("bounce/dsn-malformed",
762
+ "mailBounce.dsn.parse: per-recipient group missing Final-Recipient");
763
+ }
764
+ if (action && !DSN_ACTIONS[action]) {
765
+ throw _err("bounce/dsn-malformed",
766
+ "mailBounce.dsn.parse: Action token '" + action + "' is not RFC 3464 §2.3.3");
767
+ }
768
+
769
+ var type, subType;
770
+ if (action === "delivered" || action === "relayed" || action === "expanded") {
771
+ type = "delivery"; subType = null;
772
+ } else {
773
+ type = "bounce"; subType = _actionToSubType(action);
774
+ }
775
+
776
+ var diagnosticCode = recip["diagnostic-code"] || null;
777
+ // RFC 3464 §2.3.6 — Diagnostic-Code is `diagnostic-type;
778
+ // diagnostic`. Most are `smtp; <reply>` — strip the type prefix to
779
+ // surface the human-readable reason in audit metadata.
780
+ var reason = diagnosticCode ? mimeParse.stripAddressType(diagnosticCode) : null;
781
+ if (!reason && humanText) {
782
+ // Fall back to the human-readable section when the spec'd
783
+ // Diagnostic-Code field is absent (legacy MTAs).
784
+ reason = humanText.trim().split(/\r?\n/).slice(0, 5).join(" ").slice(0, 500) || null;
785
+ }
786
+
787
+ var arrivalDate = status.perMessage["arrival-date"];
788
+ var messageId = mimeParse.findHeader(top.headers, "Message-ID") || null;
789
+
790
+ return {
791
+ vendor: "rfc3464",
792
+ type: type,
793
+ subType: subType,
794
+ recipient: finalRecipient,
795
+ messageId: messageId,
796
+ reason: reason,
797
+ timestamp: arrivalDate || new Date().toISOString(),
798
+ raw: {
799
+ perMessage: status.perMessage,
800
+ allRecipients: status.perRecipients,
801
+ humanText: humanText,
802
+ originalMessage: originalMessage,
803
+ // Status code (per RFC 3463 — class.subject.detail) is one of the
804
+ // most useful operator fields; surface it on raw so policy code
805
+ // can branch without re-parsing.
806
+ status: recip["status"] || null,
807
+ action: action || null,
808
+ diagnosticCode: diagnosticCode,
809
+ },
810
+ };
811
+ }
812
+
813
+ function _foldFieldValue(name, value) {
814
+ // RFC 5322 §2.2.3 — long lines fold at WSP. Keep it simple: emit
815
+ // `Name: value` and let downstream MTAs handle further folding.
816
+ return name + ": " + value + "\r\n";
817
+ }
818
+
819
+ function _generateBoundary() {
820
+ return "blamejs-dsn-" + crypto.generateToken(C.BYTES.bytes(12));
821
+ }
822
+
823
+ function _buildDsn(opts) {
824
+ validateOpts.requireObject(opts, "mailBounce.dsn.build", MailBounceError, "bounce/dsn-malformed");
825
+ validateOpts.requireNonEmptyString(opts.finalRecipient,
826
+ "mailBounce.dsn.build: opts.finalRecipient", MailBounceError, "bounce/dsn-malformed");
827
+ var action = String(opts.action || "failed").toLowerCase();
828
+ if (!DSN_ACTIONS[action]) {
829
+ throw _err("bounce/dsn-malformed",
830
+ "mailBounce.dsn.build: opts.action must be one of " +
831
+ Object.keys(DSN_ACTIONS).join(" / ") + "; got '" + action + "'");
832
+ }
833
+ if (typeof opts.status !== "string" || !/^\d\.\d{1,3}\.\d{1,3}$/.test(opts.status)) {
834
+ throw _err("bounce/dsn-malformed",
835
+ "mailBounce.dsn.build: opts.status must match RFC 3463 class.subject.detail; got '" +
836
+ String(opts.status) + "'");
837
+ }
838
+
839
+ var reportingMta = opts.reportingMta || "dns; localhost";
840
+ var arrivalDate = opts.arrivalDate || new Date().toUTCString();
841
+ var originalMessage = opts.originalMessage || null;
842
+ var diagnosticCode = opts.diagnosticCode || null;
843
+ var remoteMta = opts.remoteMta || null;
844
+ var humanText = opts.humanText || (
845
+ "This is the mail system at " + reportingMta + ".\r\n\r\n" +
846
+ "Your message could not be delivered to:\r\n\r\n" +
847
+ " " + opts.finalRecipient + "\r\n\r\n" +
848
+ (diagnosticCode ? "The remote server reported: " + diagnosticCode + "\r\n" : ""));
849
+
850
+ var recipType = mimeParse.addressType(opts.finalRecipient);
851
+ var origRecipType = opts.originalRecipient ? mimeParse.addressType(opts.originalRecipient) : recipType;
852
+
853
+ var boundary = _generateBoundary();
854
+ var lines = [];
855
+ lines.push("MIME-Version: 1.0");
856
+ lines.push('Content-Type: multipart/report; report-type=delivery-status; boundary="' + boundary + '"');
857
+ if (opts.from) lines.push("From: " + opts.from);
858
+ if (opts.to) lines.push("To: " + opts.to);
859
+ if (opts.subject) lines.push("Subject: " + opts.subject);
860
+ if (opts.messageId) lines.push("Message-ID: " + opts.messageId);
861
+ lines.push("");
862
+
863
+ // Part 1 - human-readable description.
864
+ lines.push("--" + boundary);
865
+ lines.push("Content-Type: text/plain; charset=utf-8");
866
+ lines.push("Content-Transfer-Encoding: 8bit");
867
+ lines.push("");
868
+ lines.push(humanText);
869
+ lines.push("");
870
+
871
+ // Part 2 - message/delivery-status.
872
+ lines.push("--" + boundary);
873
+ lines.push("Content-Type: message/delivery-status");
874
+ lines.push("");
875
+ // Per-message group.
876
+ var perMessage = "";
877
+ perMessage += _foldFieldValue("Reporting-MTA", reportingMta);
878
+ perMessage += _foldFieldValue("Arrival-Date", arrivalDate);
879
+ if (opts.originalEnvelopeId) {
880
+ perMessage += _foldFieldValue("Original-Envelope-Id", opts.originalEnvelopeId);
881
+ }
882
+ lines.push(perMessage.replace(/\r\n$/, ""));
883
+ lines.push("");
884
+ // Per-recipient group.
885
+ var perRecip = "";
886
+ if (opts.originalRecipient) {
887
+ perRecip += _foldFieldValue("Original-Recipient",
888
+ origRecipType + ";" + opts.originalRecipient);
889
+ }
890
+ perRecip += _foldFieldValue("Final-Recipient",
891
+ recipType + ";" + opts.finalRecipient);
892
+ perRecip += _foldFieldValue("Action", action);
893
+ perRecip += _foldFieldValue("Status", opts.status);
894
+ if (remoteMta) {
895
+ perRecip += _foldFieldValue("Remote-MTA", remoteMta);
896
+ }
897
+ if (diagnosticCode) {
898
+ perRecip += _foldFieldValue("Diagnostic-Code", diagnosticCode);
899
+ }
900
+ if (opts.lastAttemptDate) {
901
+ perRecip += _foldFieldValue("Last-Attempt-Date", opts.lastAttemptDate);
902
+ }
903
+ if (opts.willRetryUntil) {
904
+ perRecip += _foldFieldValue("Will-Retry-Until", opts.willRetryUntil);
905
+ }
906
+ lines.push(perRecip.replace(/\r\n$/, ""));
907
+ lines.push("");
908
+
909
+ // Part 3 (optional) - original message or just headers per RFC 3461
910
+ // RET= choice. The framework picks the part-type from the
911
+ // originalMessage shape: if `{ headersOnly: true, headers: "..." }`
912
+ // is supplied, emit text/rfc822-headers; if a plain string is
913
+ // supplied, emit message/rfc822 with the full body.
914
+ if (originalMessage) {
915
+ lines.push("--" + boundary);
916
+ if (typeof originalMessage === "object" && originalMessage.headersOnly) {
917
+ lines.push("Content-Type: text/rfc822-headers");
918
+ lines.push("");
919
+ lines.push(originalMessage.headers || "");
920
+ } else {
921
+ lines.push("Content-Type: message/rfc822");
922
+ lines.push("");
923
+ lines.push(typeof originalMessage === "string" ? originalMessage : "");
924
+ }
925
+ lines.push("");
926
+ }
927
+
928
+ lines.push("--" + boundary + "--");
929
+ lines.push("");
930
+ return lines.join("\r\n");
931
+ }
932
+
561
933
  module.exports = {
562
934
  parse: parse,
563
935
  handler: handler,
@@ -570,4 +942,14 @@ module.exports = {
570
942
  ses: _parseSes,
571
943
  resend: _parseResend,
572
944
  },
945
+ // Generic RFC 3464 / RFC 3461 / RFC 6533 DSN parser + generator.
946
+ dsn: {
947
+ parse: _parseDsn,
948
+ build: _buildDsn,
949
+ // Tables surfaced for tests + advanced operator code (e.g. operators
950
+ // building DSN-shaped reports against a custom action vocabulary).
951
+ PER_MESSAGE_FIELDS: DSN_PER_MESSAGE_FIELDS,
952
+ PER_RECIPIENT_FIELDS: DSN_PER_RECIPIENT_FIELDS,
953
+ ACTIONS: DSN_ACTIONS,
954
+ },
573
955
  };