@blamejs/core 0.8.52 → 0.8.58

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 (45) hide show
  1. package/CHANGELOG.md +6 -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/db-collection.js +290 -0
  9. package/lib/db-query.js +245 -0
  10. package/lib/db.js +173 -67
  11. package/lib/framework-error.js +55 -0
  12. package/lib/guard-cidr.js +2 -1
  13. package/lib/guard-jwt.js +2 -2
  14. package/lib/guard-oauth.js +2 -2
  15. package/lib/http-client-cache.js +916 -0
  16. package/lib/http-client.js +242 -0
  17. package/lib/mail-arf.js +343 -0
  18. package/lib/mail-auth.js +265 -40
  19. package/lib/mail-bimi.js +948 -33
  20. package/lib/mail-bounce.js +386 -4
  21. package/lib/mail-mdn.js +424 -0
  22. package/lib/mail-unsubscribe.js +265 -25
  23. package/lib/mail.js +403 -21
  24. package/lib/middleware/bearer-auth.js +1 -1
  25. package/lib/middleware/clear-site-data.js +122 -0
  26. package/lib/middleware/dpop.js +1 -1
  27. package/lib/middleware/index.js +9 -0
  28. package/lib/middleware/nel.js +214 -0
  29. package/lib/middleware/security-headers.js +56 -4
  30. package/lib/middleware/speculation-rules.js +323 -0
  31. package/lib/mime-parse.js +198 -0
  32. package/lib/mtls-ca.js +15 -5
  33. package/lib/network-dns.js +890 -27
  34. package/lib/network-tls.js +745 -0
  35. package/lib/object-store/sigv4.js +54 -0
  36. package/lib/public-suffix.js +414 -0
  37. package/lib/safe-buffer.js +7 -0
  38. package/lib/safe-json.js +1 -1
  39. package/lib/static.js +120 -0
  40. package/lib/storage.js +11 -0
  41. package/lib/vendor/MANIFEST.json +33 -0
  42. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  43. package/lib/vendor/public-suffix-list.dat +16376 -0
  44. package/package.json +1 -1
  45. package/sbom.cyclonedx.json +6 -6
package/lib/mail.js CHANGED
@@ -69,7 +69,9 @@ var mailAuth = require("./mail-auth");
69
69
  var mailBimi = require("./mail-bimi");
70
70
  var mailUnsubscribe = require("./mail-unsubscribe");
71
71
  var net = lazyRequire(function () { return require("net"); });
72
+ var networkDns = lazyRequire(function () { return require("./network-dns"); });
72
73
  var nodeUrl = require("url");
74
+ var numericBounds = require("./numeric-bounds");
73
75
  var tls = lazyRequire(function () { return require("tls"); });
74
76
  var safeJson = require("./safe-json");
75
77
  var safeSchema = require("./safe-schema");
@@ -165,6 +167,85 @@ function toUnicode(domain) {
165
167
  catch (_e) { return null; }
166
168
  }
167
169
 
170
+ /**
171
+ * @primitive b.mail.reverseDns
172
+ * @signature b.mail.reverseDns(ip)
173
+ * @since 0.8.53
174
+ * @status stable
175
+ * @related b.mail.create
176
+ *
177
+ * Forward-confirmed reverse DNS lookup (FCrDNS, RFC 8601 §3 lite) for
178
+ * an IPv4 or IPv6 address. Returns
179
+ * `{ ok, ptr, forward, fcrdns }`:
180
+ *
181
+ * - `ok` — whether the PTR resolved at all.
182
+ * - `ptr` — the first PTR record name (or `null`).
183
+ * - `forward` — array of A / AAAA addresses for that name (or `[]`).
184
+ * - `fcrdns` — `true` when the original `ip` appears in `forward`.
185
+ *
186
+ * Used as the building block for the iprev mail-authentication check
187
+ * (RFC 8601 §2.7.3): a sender's connect-IP must reverse-resolve to a
188
+ * PTR name whose forward A/AAAA includes that IP. Operators wiring
189
+ * inbound mail-receive paths call this on the connect address before
190
+ * accepting the SMTP transaction; bulk-sender reputation systems use
191
+ * the same check for outbound submission.
192
+ *
193
+ * Errors thrown by the underlying DNS path (bad-IP shape / lookup
194
+ * timeout) are caught and surfaced as `{ ok: false, error: code }`
195
+ * so the call doesn't reject the inbound path on a transient DNS
196
+ * blip; `fcrdns` remains `false`.
197
+ *
198
+ * @example
199
+ * var b = require("@blamejs/core");
200
+ * var r = await b.mail.reverseDns("8.8.8.8");
201
+ * // → { ok: true, ptr: "dns.google", forward: ["8.8.8.8"], fcrdns: true }
202
+ */
203
+ async function reverseDns(ip) {
204
+ var dns = networkDns();
205
+ var result = { ok: false, ptr: null, forward: [], fcrdns: false };
206
+ var ptrs;
207
+ try {
208
+ ptrs = await dns.reverse(ip);
209
+ } catch (e) {
210
+ result.error = (e && e.code) || "dns/reverse-failed";
211
+ return result;
212
+ }
213
+ if (!Array.isArray(ptrs) || ptrs.length === 0) {
214
+ result.error = "dns/no-ptr";
215
+ return result;
216
+ }
217
+ var ptrName = String(ptrs[0]);
218
+ result.ok = true;
219
+ result.ptr = ptrName;
220
+ // Forward-confirm — query A or AAAA depending on the IP family of
221
+ // the original input. RFC 8601 §3 says the forward query must use
222
+ // the same family as the source; mismatched families don't count
223
+ // as confirmation.
224
+ var net = require("net");
225
+ var forwardAddrs = [];
226
+ try {
227
+ if (net.isIPv6(ip)) {
228
+ forwardAddrs = await dns.resolveAaaa(ptrName);
229
+ } else {
230
+ forwardAddrs = await dns.resolve4(ptrName);
231
+ }
232
+ } catch (e) {
233
+ result.error = (e && e.code) || "dns/forward-failed";
234
+ return result;
235
+ }
236
+ result.forward = Array.isArray(forwardAddrs) ? forwardAddrs.slice() : [];
237
+ // Case-insensitive equality on IPv6 (canonical form differs for
238
+ // ::ffff:8.8.8.8 vs 8.8.8.8); compare lower-cased strings.
239
+ var ipLc = String(ip).toLowerCase();
240
+ for (var i = 0; i < result.forward.length; i += 1) {
241
+ if (String(result.forward[i]).toLowerCase() === ipLc) {
242
+ result.fcrdns = true;
243
+ break;
244
+ }
245
+ }
246
+ return result;
247
+ }
248
+
168
249
  function _isValidEmail(addr) {
169
250
  if (typeof addr !== "string" || addr.length === 0 || addr.length > EMAIL_MAX_LEN) {
170
251
  return false;
@@ -707,17 +788,44 @@ function smtpTransport(opts) {
707
788
  ? undefined : host;
708
789
  }
709
790
 
791
+ // RFC 3030 BDAT chunking — default chunk size 256 KiB. Operator opt
792
+ // `chunking: false` disables BDAT even when offered (some legacy
793
+ // receivers advertise CHUNKING but mishandle bare BDAT framing).
794
+ var chunkingEnabled = opts.chunking !== false;
795
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.chunkSize,
796
+ "smtp transport: opts.chunkSize", MailError, "mail/smtp-misconfigured");
797
+ var chunkSize = (opts.chunkSize !== undefined) ? opts.chunkSize : C.BYTES.kib(256);
798
+
799
+ // RFC 1870 SIZE — by default we honor the peer's advertised cap and
800
+ // refuse before opening DATA / BDAT. `respectPeerSize: false`
801
+ // disables the precheck (operator-asserted "I trust the peer to
802
+ // accept whatever I send").
803
+ var respectPeerSize = opts.respectPeerSize !== false;
804
+
805
+ // IPv4 / IPv6 family preference for the underlying connect. "any"
806
+ // lets Node pick (default — Happy Eyeballs / system policy); "4" or
807
+ // "6" forces a single family. The framework auto-detects when the
808
+ // local network has no IPv6 interfaces and prefers v4 in that case
809
+ // so a v6-only peer doesn't hang the connect timeout.
810
+ var preferFamily = (opts.preferFamily === 4 || opts.preferFamily === 6 ||
811
+ opts.preferFamily === "4" || opts.preferFamily === "6")
812
+ ? Number(opts.preferFamily) : "any";
813
+
710
814
  var cfg = {
711
- host: host,
712
- port: port,
713
- user: opts.user,
714
- pass: opts.pass,
715
- useImplicitTLS: useImplicitTLS,
716
- ehloName: ehloName,
717
- timeoutMs: timeoutMs,
718
- tlsOpts: tlsOpts,
719
- servername: servername,
720
- dkimSigner: opts.dkimSigner || null,
815
+ host: host,
816
+ port: port,
817
+ user: opts.user,
818
+ pass: opts.pass,
819
+ useImplicitTLS: useImplicitTLS,
820
+ ehloName: ehloName,
821
+ timeoutMs: timeoutMs,
822
+ tlsOpts: tlsOpts,
823
+ servername: servername,
824
+ dkimSigner: opts.dkimSigner || null,
825
+ chunkingEnabled: chunkingEnabled,
826
+ chunkSize: chunkSize,
827
+ respectPeerSize: respectPeerSize,
828
+ preferFamily: preferFamily,
721
829
  };
722
830
 
723
831
  return {
@@ -739,6 +847,10 @@ var SMTP_STEP_RCPT_TO = 0x6;
739
847
  var SMTP_STEP_DATA = 0x7;
740
848
  var SMTP_STEP_BODY = 0x8;
741
849
  var SMTP_STEP_STARTTLS = 0xA;
850
+ // RFC 3030 BDAT chunked-body framing. The transport sends `BDAT N`
851
+ // (or `BDAT N LAST`) followed by exactly N bytes of body; each chunk
852
+ // expects a 250 response before the next chunk is written.
853
+ var SMTP_STEP_BDAT = 0xB;
742
854
 
743
855
  function _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8) {
744
856
  // RFC 6531 §3.4 — when SMTPUTF8 is advertised by the peer AND the
@@ -748,16 +860,133 @@ function _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8) {
748
860
  return (requiresSmtpUtf8 && peerSupportsSmtpUtf8) ? " SMTPUTF8" : "";
749
861
  }
750
862
 
863
+ // Detect whether the message has an 8-bit / binary attachment that
864
+ // requires BINARYMIME (RFC 3030 §3) on the wire. Pure-text messages —
865
+ // even when text/html bodies contain UTF-8 — go through 8BITMIME
866
+ // (RFC 6152) which is universally supported. The "binary" trigger is
867
+ // a Buffer attachment whose octet stream includes NUL bytes (or any
868
+ // byte > 0x7F when the operator marks `binary: true`). Buffers with
869
+ // the "application/octet-stream" claimed content-type also count.
870
+ function _messageRequiresBinaryMime(message) {
871
+ if (!message) return false;
872
+ if (!Array.isArray(message.attachments)) return false;
873
+ for (var i = 0; i < message.attachments.length; i += 1) {
874
+ var att = message.attachments[i];
875
+ if (!att) continue;
876
+ // Operator can mark explicit binary intent.
877
+ if (att.binary === true) return true;
878
+ // Buffer attachments whose content includes NUL are binary —
879
+ // base64 wraps them safely but the source octets are 8-bit-binary
880
+ // so the BODY=BINARYMIME hint is what tells the peer to expect
881
+ // RFC 3030 framing instead of bare 8BITMIME.
882
+ if (Buffer.isBuffer(att.content)) {
883
+ // Quick scan — first 4 KiB is enough to detect the common case
884
+ // (any executable / image / archive / pdf).
885
+ var max = Math.min(att.content.length, C.BYTES.kib(4));
886
+ for (var j = 0; j < max; j += 1) {
887
+ if (att.content[j] === 0) return true;
888
+ }
889
+ }
890
+ }
891
+ return false;
892
+ }
893
+
894
+ // Detect whether the message body has any non-ASCII (8-bit) octets
895
+ // requiring at minimum 8BITMIME (RFC 6152) on the wire. Triggers on
896
+ // non-ASCII text bytes in subject / text / html / calendar parts.
897
+ // Distinct from BINARYMIME — the latter is for true binary streams
898
+ // (NUL-bearing).
899
+ function _messageRequires8BitMime(message) {
900
+ if (!message) return false;
901
+ var fields = ["text", "html", "subject"];
902
+ for (var i = 0; i < fields.length; i += 1) {
903
+ var v = message[fields[i]];
904
+ if (typeof v === "string" && NON_ASCII_RE.test(v)) return true; // allow:regex-no-length-cap — header-value detector; bounded by SMTP line cap upstream
905
+ }
906
+ if (message.calendar && typeof message.calendar.icalText === "string" &&
907
+ NON_ASCII_RE.test(message.calendar.icalText)) return true; // allow:regex-no-length-cap — caller bounds calendar.icalText size
908
+ return false;
909
+ }
910
+
911
+ // Auto-detect IPv4 / IPv6 family for outbound connect when the
912
+ // operator didn't pin one. Walks `os.networkInterfaces()`; if the
913
+ // host has no non-internal IPv6 interfaces, prefer family=4 so a
914
+ // v6-only AAAA record doesn't hang the connect timeout. When the
915
+ // host has both, return 0 (let Node pick — Happy Eyeballs / system
916
+ // resultOrder applies).
917
+ function _autoDetectFamily() {
918
+ try {
919
+ var os = require("os");
920
+ var ifaces = os.networkInterfaces();
921
+ var hasV6 = false;
922
+ var hasV4 = false;
923
+ var keys = Object.keys(ifaces);
924
+ for (var k = 0; k < keys.length; k += 1) {
925
+ var arr = ifaces[keys[k]] || [];
926
+ for (var i = 0; i < arr.length; i += 1) {
927
+ var entry = arr[i];
928
+ if (entry.internal) continue;
929
+ if (entry.family === "IPv6" || entry.family === 6) hasV6 = true;
930
+ if (entry.family === "IPv4" || entry.family === 4) hasV4 = true;
931
+ }
932
+ }
933
+ if (hasV4 && !hasV6) return 4;
934
+ if (!hasV4 && hasV6) return 6;
935
+ return 0;
936
+ } catch (_e) {
937
+ return 0;
938
+ }
939
+ }
940
+
941
+ // Compute the wire size of the produced RFC 822 message. Used by RFC
942
+ // 1870 SIZE pre-check before MAIL FROM. Caller passes the already-
943
+ // CRLF-normalized + dot-stuffed wire string (the same one that goes
944
+ // into DATA / BDAT). Returns the byte count Node will write.
945
+ function _messageWireSize(wire) {
946
+ if (typeof wire !== "string") return 0;
947
+ return Buffer.byteLength(wire, "utf8");
948
+ }
949
+
950
+ // Parse the SIZE keyword's argument from a `SIZE 12345` EHLO line.
951
+ // Returns 0 when SIZE is advertised without a value (RFC 1870 §3 —
952
+ // some peers omit the limit, indicating "no enforced cap"); returns
953
+ // -1 when SIZE isn't advertised; otherwise returns the operator-side
954
+ // peer cap.
955
+ function _parsePeerSize(ehloLines) {
956
+ if (!Array.isArray(ehloLines)) return -1;
957
+ for (var i = 0; i < ehloLines.length; i += 1) {
958
+ var line = ehloLines[i];
959
+ // Lines come in already uppercased keyword form; the SIZE entry
960
+ // may be `SIZE` alone OR `SIZE 12345` — split on whitespace.
961
+ var parts = String(line).split(/\s+/);
962
+ if (parts[0] === "SIZE") {
963
+ if (parts.length < 2) return 0;
964
+ var n = parseInt(parts[1], 10);
965
+ return isFinite(n) && n >= 0 ? n : -1;
966
+ }
967
+ }
968
+ return -1;
969
+ }
970
+
751
971
  function _smtpSend(message, cfg) {
752
972
  return new Promise(function (resolve, reject) {
753
973
  var socket;
754
974
  var step = SMTP_STEP_GREETING;
755
975
  var buffer = "";
756
- var ehloLines = []; // RFC 5321 §4.1.1.1 — EHLO extension lines
757
- var peerSupportsSmtpUtf8 = false; // RFC 6531 set from EHLO response
976
+ var ehloLines = []; // RFC 5321 §4.1.1.1 — EHLO extension lines (uppercase keyword)
977
+ var ehloFullLines = []; // Full extension text (incl. args, e.g. "SIZE 12345")
978
+ var peerSupportsSmtpUtf8 = false; // RFC 6531 — set from EHLO response
979
+ var peerSupportsChunking = false; // RFC 3030 §2 CHUNKING
980
+ var peerSupportsBinaryMime = false; // RFC 3030 §3 BINARYMIME
981
+ var peerSupports8BitMime = false; // RFC 6152 (eight-bit MIME) // allow:raw-byte-literal — RFC number, not a byte literal
982
+ var peerSizeCap = -1; // RFC 1870 SIZE — -1 unset, 0 = no cap, >0 = byte limit
758
983
  var upgradedToTLS = false;
759
984
  var settled = false;
760
985
  var rcptIndex = 0;
986
+ var bdatOffset = 0; // Bytes of dataMessage written so far via BDAT
987
+ var dataWireBytes = null; // Buffer view of dataMessage for BDAT slicing
988
+ var useBdat = false; // Decided post-EHLO based on peerSupportsChunking + cfg.chunkingEnabled
989
+ var bodyMode = "7BIT"; // "7BIT" / "8BITMIME" / "BINARYMIME"
761
990
 
762
991
  var fromAddr = _extractAddr(message.from);
763
992
  var toList = _toArray(message.to).map(_extractAddr);
@@ -765,6 +994,8 @@ function _smtpSend(message, cfg) {
765
994
  var bccList = _toArray(message.bcc).map(_extractAddr);
766
995
  var rcpts = toList.concat(ccList, bccList);
767
996
  var requiresSmtpUtf8 = _messageRequiresSmtpUtf8(message);
997
+ var requiresBinaryMime = _messageRequiresBinaryMime(message);
998
+ var requires8BitMime = _messageRequires8BitMime(message);
768
999
  var dataMessage = _buildRfc822(message);
769
1000
  if (cfg.dkimSigner) {
770
1001
  try { dataMessage = cfg.dkimSigner.sign(dataMessage); }
@@ -774,6 +1005,7 @@ function _smtpSend(message, cfg) {
774
1005
  return;
775
1006
  }
776
1007
  }
1008
+ var messageWireSize = _messageWireSize(dataMessage);
777
1009
 
778
1010
  // Outbound SMTP-smuggling defense — refuse before opening the
779
1011
  // socket if the produced RFC 822 wire contains the bare-CR / bare-
@@ -814,6 +1046,51 @@ function _smtpSend(message, cfg) {
814
1046
  catch (e) { fail(e.message || String(e)); }
815
1047
  }
816
1048
 
1049
+ // RFC 5321 + 6531 + 6152 + 3030 + 1870 — MAIL FROM keyword bundle.
1050
+ // Order: SMTPUTF8 (6531) → BODY=<7BIT|8BITMIME|BINARYMIME> →
1051
+ // SIZE=<bytes>. Peers tolerate any order but consistent ordering
1052
+ // simplifies the wire-trace gold files.
1053
+ function _mailFromSuffix() {
1054
+ var s = "";
1055
+ s += _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8);
1056
+ if (bodyMode === "BINARYMIME" && peerSupportsBinaryMime) {
1057
+ s += " BODY=BINARYMIME";
1058
+ } else if (bodyMode === "8BITMIME" && peerSupports8BitMime) {
1059
+ s += " BODY=8BITMIME";
1060
+ }
1061
+ // Append SIZE= when peer advertised SIZE (cap or no-cap form).
1062
+ // Peers without SIZE support get no SIZE= keyword (some legacy
1063
+ // peers reject unknown MAIL FROM keywords).
1064
+ if (peerSizeCap !== -1) {
1065
+ s += " SIZE=" + messageWireSize;
1066
+ }
1067
+ return s;
1068
+ }
1069
+
1070
+ // Send the next BDAT chunk. Each `BDAT N [LAST]` line is followed
1071
+ // immediately by exactly N bytes of body (no CRLF terminator on
1072
+ // the chunk; SMTP framing is purely length-based per RFC 3030 §2).
1073
+ function sendBdatChunk() {
1074
+ if (!dataWireBytes) dataWireBytes = Buffer.from(dataMessage, "utf8");
1075
+ var remaining = dataWireBytes.length - bdatOffset;
1076
+ if (remaining <= 0) {
1077
+ // Empty body — send `BDAT 0 LAST` to terminate gracefully.
1078
+ socket.write("BDAT 0 LAST\r\n");
1079
+ return;
1080
+ }
1081
+ var thisChunk = Math.min(remaining, cfg.chunkSize);
1082
+ var isLast = (bdatOffset + thisChunk) >= dataWireBytes.length;
1083
+ var header = "BDAT " + thisChunk + (isLast ? " LAST" : "") + "\r\n";
1084
+ try {
1085
+ socket.write(header);
1086
+ socket.write(dataWireBytes.slice(bdatOffset, bdatOffset + thisChunk));
1087
+ } catch (e) {
1088
+ fail(e.message || String(e));
1089
+ return;
1090
+ }
1091
+ bdatOffset += thisChunk;
1092
+ }
1093
+
817
1094
  function onData(data) {
818
1095
  buffer += data;
819
1096
  var lines = buffer.split("\r\n");
@@ -823,11 +1100,17 @@ function _smtpSend(message, cfg) {
823
1100
  if (!line) continue;
824
1101
  var code = parseInt(line.slice(0, 3), 10);
825
1102
  // EHLO continuation lines (250-X) carry extension names. We
826
- // capture them so the dispatcher can branch on SMTPUTF8 / 8BITMIME
827
- // / STARTTLS support before MAIL FROM is sent.
1103
+ // capture them so the dispatcher can branch on SMTPUTF8 /
1104
+ // 8BITMIME / BINARYMIME / CHUNKING / SIZE / STARTTLS before
1105
+ // MAIL FROM is sent. Both forms recorded: keyword-only (used
1106
+ // for set-membership tests) and full-line (used for SIZE arg).
828
1107
  if (step === SMTP_STEP_EHLO_RESP) {
829
- var keyword = line.slice(4).split(" ")[0].toUpperCase();
830
- if (keyword) ehloLines.push(keyword);
1108
+ var rest = line.slice(4);
1109
+ var keyword = rest.split(" ")[0].toUpperCase();
1110
+ if (keyword) {
1111
+ ehloLines.push(keyword);
1112
+ ehloFullLines.push(rest.toUpperCase());
1113
+ }
831
1114
  }
832
1115
  if (line[3] === "-") continue; // continuation line
833
1116
  try { handleResponse(code); }
@@ -846,12 +1129,24 @@ function _smtpSend(message, cfg) {
846
1129
  }
847
1130
 
848
1131
  function connect() {
1132
+ // Family preference for the underlying lookup. Node's net /
1133
+ // tls.connect both honor `family: 4|6` to bias the dns lookup;
1134
+ // the framework auto-detects an IPv4-only host when the operator
1135
+ // didn't pin one explicitly so a v6-only AAAA result doesn't
1136
+ // hang the connect.
1137
+ var family = cfg.preferFamily;
1138
+ if (family === "any") family = _autoDetectFamily();
849
1139
  if (cfg.useImplicitTLS) {
850
1140
  var tlsConnectOpts = Object.assign({}, cfg.tlsOpts);
851
1141
  if (cfg.servername) tlsConnectOpts.servername = cfg.servername;
852
- attachSocket(tls().connect(cfg.port, cfg.host, tlsConnectOpts));
1142
+ tlsConnectOpts.host = cfg.host;
1143
+ tlsConnectOpts.port = cfg.port;
1144
+ if (family === 4 || family === 6) tlsConnectOpts.family = family;
1145
+ attachSocket(tls().connect(tlsConnectOpts));
853
1146
  } else {
854
- attachSocket(net().createConnection(cfg.port, cfg.host));
1147
+ var netOpts = { host: cfg.host, port: cfg.port };
1148
+ if (family === 4 || family === 6) netOpts.family = family;
1149
+ attachSocket(net().createConnection(netOpts));
855
1150
  }
856
1151
  }
857
1152
 
@@ -863,7 +1158,11 @@ function _smtpSend(message, cfg) {
863
1158
  else if (step === SMTP_STEP_EHLO_RESP) {
864
1159
  if (code < 200 || code >= 300) { fail("ehlo-rejected (code " + code + ")"); return; }
865
1160
  // Snapshot extensions advertised on the wire for downstream use.
866
- peerSupportsSmtpUtf8 = ehloLines.indexOf("SMTPUTF8") !== -1;
1161
+ peerSupportsSmtpUtf8 = ehloLines.indexOf("SMTPUTF8") !== -1;
1162
+ peerSupports8BitMime = ehloLines.indexOf("8BITMIME") !== -1;
1163
+ peerSupportsBinaryMime = ehloLines.indexOf("BINARYMIME") !== -1;
1164
+ peerSupportsChunking = ehloLines.indexOf("CHUNKING") !== -1;
1165
+ peerSizeCap = _parsePeerSize(ehloFullLines);
867
1166
  // RFC 6531 §3.2 — if the message requires SMTPUTF8 and the
868
1167
  // peer does not advertise it, refuse hard rather than emit a
869
1168
  // mangled wire (server might still accept but headers/local
@@ -872,9 +1171,41 @@ function _smtpSend(message, cfg) {
872
1171
  fail("eai-required-not-supported: message has non-ASCII content but peer does not advertise SMTPUTF8");
873
1172
  return;
874
1173
  }
1174
+ // RFC 3030 §3 — BINARYMIME is only legal when the peer
1175
+ // advertises it. If the message requires it but the peer
1176
+ // doesn't offer it, refuse: silently downgrading to 8BITMIME
1177
+ // would corrupt NUL-bearing octets in transit.
1178
+ if (requiresBinaryMime && !peerSupportsBinaryMime) {
1179
+ settled = true;
1180
+ try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1181
+ reject(new MailError("mail/binarymime-not-advertised",
1182
+ "message has 8-bit binary content but peer does not advertise BINARYMIME (RFC 3030 §3)",
1183
+ true));
1184
+ return;
1185
+ }
1186
+ // RFC 1870 §3 — SIZE pre-check. Refuse before opening DATA /
1187
+ // BDAT so the caller gets a clean error instead of a 552
1188
+ // mid-stream rejection. peerSizeCap = 0 means "no enforced
1189
+ // cap"; -1 means "SIZE not advertised" (no precheck).
1190
+ if (cfg.respectPeerSize && peerSizeCap > 0 && messageWireSize > peerSizeCap) {
1191
+ settled = true;
1192
+ try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1193
+ reject(new MailError("mail/peer-size-exceeded",
1194
+ "message wire size " + messageWireSize + " bytes exceeds peer SIZE cap " +
1195
+ peerSizeCap + " bytes (RFC 1870)", true));
1196
+ return;
1197
+ }
1198
+ // Decide BDAT vs DATA + BODY=8BITMIME / BINARYMIME for this
1199
+ // transaction. CHUNKING + BDAT is preferred when both peer
1200
+ // advertises it AND operator didn't disable it.
1201
+ useBdat = peerSupportsChunking && cfg.chunkingEnabled;
1202
+ if (requiresBinaryMime) bodyMode = "BINARYMIME";
1203
+ else if (requires8BitMime && peerSupports8BitMime) bodyMode = "8BITMIME";
1204
+ else bodyMode = "7BIT";
1205
+
875
1206
  if (!cfg.useImplicitTLS && !upgradedToTLS) { send("STARTTLS"); step = SMTP_STEP_STARTTLS; }
876
1207
  else if (cfg.user) { send("AUTH LOGIN"); step = SMTP_STEP_AUTH_USER; }
877
- else { send("MAIL FROM:<" + fromAddr + ">" + _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8)); step = SMTP_STEP_MAIL_FROM; }
1208
+ else { send("MAIL FROM:<" + fromAddr + ">" + _mailFromSuffix()); step = SMTP_STEP_MAIL_FROM; }
878
1209
  }
879
1210
  else if (step === SMTP_STEP_STARTTLS) {
880
1211
  if (code !== 220) { fail("starttls-rejected (code " + code + ")"); return; }
@@ -901,7 +1232,7 @@ function _smtpSend(message, cfg) {
901
1232
  }
902
1233
  else if (step === SMTP_STEP_AUTH_FINAL) {
903
1234
  if (code !== 235) { fail("auth-failed (code " + code + ")"); return; }
904
- send("MAIL FROM:<" + fromAddr + ">" + _smtpUtf8Suffix(requiresSmtpUtf8, peerSupportsSmtpUtf8)); step = SMTP_STEP_MAIL_FROM;
1235
+ send("MAIL FROM:<" + fromAddr + ">" + _mailFromSuffix()); step = SMTP_STEP_MAIL_FROM;
905
1236
  }
906
1237
  else if (step === SMTP_STEP_MAIL_FROM) {
907
1238
  if (code < 200 || code >= 300) { fail("mail-from-rejected (code " + code + ")"); return; }
@@ -911,6 +1242,33 @@ function _smtpSend(message, cfg) {
911
1242
  if (code < 200 || code >= 300) { fail("rcpt-rejected (code " + code + ")"); return; }
912
1243
  if (rcptIndex < rcpts.length) {
913
1244
  send("RCPT TO:<" + rcpts[rcptIndex++] + ">");
1245
+ } else if (useBdat) {
1246
+ // RFC 3030 §2 — BDAT framing replaces DATA + CRLF.CRLF.
1247
+ // Each chunk is `BDAT <octet-count> [LAST]\r\n` followed by
1248
+ // exactly <octet-count> bytes of body. We emit the audit
1249
+ // signal once per send (not per chunk) so the audit chain
1250
+ // doesn't get flooded for large messages.
1251
+ try {
1252
+ audit().safeEmit({
1253
+ action: "mail.transport.bdat",
1254
+ outcome: "success",
1255
+ metadata: {
1256
+ wireBytes: messageWireSize,
1257
+ chunkSize: cfg.chunkSize,
1258
+ expectedChunks: Math.max(1, Math.ceil(messageWireSize / cfg.chunkSize)),
1259
+ bodyMode: bodyMode,
1260
+ },
1261
+ });
1262
+ if (bodyMode === "BINARYMIME") {
1263
+ audit().safeEmit({
1264
+ action: "mail.transport.binarymime",
1265
+ outcome: "success",
1266
+ metadata: { wireBytes: messageWireSize },
1267
+ });
1268
+ }
1269
+ } catch (_e) { /* audit best-effort */ }
1270
+ step = SMTP_STEP_BDAT;
1271
+ sendBdatChunk();
914
1272
  } else {
915
1273
  send("DATA"); step = SMTP_STEP_DATA;
916
1274
  }
@@ -920,6 +1278,26 @@ function _smtpSend(message, cfg) {
920
1278
  send(dataMessage + "\r\n.");
921
1279
  step = SMTP_STEP_BODY;
922
1280
  }
1281
+ else if (step === SMTP_STEP_BDAT) {
1282
+ // Each BDAT chunk gets a 250 response per RFC 3030 §2. Anything
1283
+ // else (4xx / 5xx) is a hard rejection of the chunk; surface
1284
+ // a stable error code so downstream retry policy can identify
1285
+ // the chunked-body failure mode.
1286
+ if (code !== 250) {
1287
+ settled = true;
1288
+ try { socket.destroy(); } catch (_e) { /* socket may already be torn down */ }
1289
+ reject(new MailError("mail/bdat-chunk-rejected",
1290
+ "BDAT chunk rejected (code " + code + ", offset " + bdatOffset + "/" +
1291
+ messageWireSize + ")", false));
1292
+ return;
1293
+ }
1294
+ if (bdatOffset >= messageWireSize) {
1295
+ // Final chunk acknowledged — message accepted.
1296
+ done(true, code);
1297
+ return;
1298
+ }
1299
+ sendBdatChunk();
1300
+ }
923
1301
  else if (step === SMTP_STEP_BODY) {
924
1302
  var ok = code === 250;
925
1303
  done(ok, code);
@@ -1368,6 +1746,9 @@ module.exports = {
1368
1746
  // pre-SMTPUTF8 ASCII regex check.
1369
1747
  toAscii: toAscii,
1370
1748
  toUnicode: toUnicode,
1749
+ // Forward-confirmed reverse DNS lookup (RFC 8601 §3 lite). Building
1750
+ // block for inbound iprev / outbound submission reputation checks.
1751
+ reverseDns: reverseDns,
1371
1752
  // DKIM-Signature header generation for outbound mail (rsa-sha256
1372
1753
  // default, ed25519-sha256 opt-in). Wire it into the smtp transport
1373
1754
  // via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
@@ -1379,6 +1760,7 @@ module.exports = {
1379
1760
  spf: mailAuth.spf,
1380
1761
  dmarc: mailAuth.dmarc,
1381
1762
  arc: mailAuth.arc,
1763
+ iprev: mailAuth.iprev,
1382
1764
  authResults: mailAuth.authResults,
1383
1765
  bimi: mailBimi,
1384
1766
  // Test-only export: lets unit tests inspect the wire format without
@@ -82,7 +82,7 @@ function _extractToken(req, scheme) {
82
82
  * @primitive b.middleware.bearerAuth
83
83
  * @signature b.middleware.bearerAuth(req, res, next)
84
84
  * @since 0.1.0
85
- * @related b.middleware.attachUser, b.middleware.requireAuth, b.auth.jwt
85
+ * @related b.middleware.attachUser, b.middleware.requireAuth
86
86
  *
87
87
  * Extracts `Authorization: Bearer <token>`, calls an operator-supplied
88
88
  * verifier, attaches the result to `req.user`. Constructed via