spamscanner 7.0.11 → 7.0.13

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/dist/esm/cli.js CHANGED
@@ -549,9 +549,12 @@ function rewriteMessage(source, headers = [], options = {}) {
549
549
  }
550
550
  }
551
551
  const added = headers.map(([name, value]) => foldHeader(name, Buffer2.from(String(value), "utf8").toString("latin1"), newline));
552
- const mbox = kept.length > 0 && kept[0][0].startsWith("From ") ? kept.shift() : [];
552
+ const mbox = kept.length > 0 && kept[0][0].startsWith("From ") ? kept.shift().slice(0, 1) : [];
553
+ if (kept.length > 0 && /^[ \t]/.test(kept[0][0])) {
554
+ kept.shift();
555
+ }
553
556
  const head = [...mbox, ...added, ...kept.map((field) => field.join(newline))].join(newline);
554
- return Buffer2.concat([Buffer2.from(head === "" && header === "" ? newline : `${head}${newline}${newline}`, "latin1"), body]);
557
+ return Buffer2.concat([Buffer2.from(head === "" ? newline : `${head}${newline}${newline}`, "latin1"), body]);
555
558
  }
556
559
 
557
560
  // src/llm.js
@@ -1887,7 +1890,7 @@ var NAMED = [
1887
1890
  ",itilde=129,iukcy=456,iuml=ef,jcirc=135,jcy=439,jfr=1d527,jmath=237,jopf=1d55b,jscr=1d4bf,jsercy=458",
1888
1891
  ",jukcy=454,kappa=3ba,kappav=3f0,kcedil=137,kcy=43a,kfr=1d528,kgreen=138,khcy=445,kjcy=45c,kopf=1d55c",
1889
1892
  ",kscr=1d4c0,lAarr=21da,lArr=21d0,lAtail=291b,lBarr=290e,lE=2266,lEg=2a8b,lHar=2962,lacute=13a,laempt",
1890
- "yv=29b4,lagran=2112,lambda=3bb,lang=2329,langd=2991,langle=27e8,lap=2a85,laquo=ab,larr=2190,larrb=21",
1893
+ "yv=29b4,lagran=2112,lambda=3bb,lang=27e8,langd=2991,langle=27e8,lap=2a85,laquo=ab,larr=2190,larrb=21",
1891
1894
  "e4,larrbfs=291f,larrfs=291d,larrhk=21a9,larrlp=21ab,larrpl=2939,larrsim=2973,larrtl=21a2,lat=2aab,la",
1892
1895
  "tail=2919,late=2aad,lates=2aad.fe00,lbarr=290c,lbbrk=2772,lbrace=7b,lbrack=5b,lbrke=298b,lbrksld=298",
1893
1896
  "f,lbrkslu=298d,lcaron=13e,lcedil=13c,lceil=2308,lcub=7b,lcy=43b,ldca=2936,ldquo=201c,ldquor=201e,ldr",
@@ -1945,7 +1948,7 @@ var NAMED = [
1945
1948
  "line=2312,profsurf=2313,prop=221d,propto=221d,prsim=227e,prurel=22b0,pscr=1d4c5,psi=3c8,puncsp=2008,",
1946
1949
  "qfr=1d52e,qint=2a0c,qopf=1d562,qprime=2057,qscr=1d4c6,quaternions=210d,quatint=2a16,quest=3f,questeq",
1947
1950
  "=225f,quot=22,rAarr=21db,rArr=21d2,rAtail=291c,rBarr=290f,rHar=2964,race=223d.331,racute=155,radic=2",
1948
- "21a,raemptyv=29b3,rang=232a,rangd=2992,range=29a5,rangle=27e9,raquo=bb,rarr=2192,rarrap=2975,rarrb=2",
1951
+ "21a,raemptyv=29b3,rang=27e9,rangd=2992,range=29a5,rangle=27e9,raquo=bb,rarr=2192,rarrap=2975,rarrb=2",
1949
1952
  "1e5,rarrbfs=2920,rarrc=2933,rarrfs=291e,rarrhk=21aa,rarrlp=21ac,rarrpl=2945,rarrsim=2974,rarrtl=21a3",
1950
1953
  ",rarrw=219d,ratail=291a,ratio=2236,rationals=211a,rbarr=290d,rbbrk=2773,rbrace=7d,rbrack=5d,rbrke=29",
1951
1954
  "8c,rbrksld=298e,rbrkslu=2990,rcaron=159,rcedil=157,rceil=2309,rcub=7d,rcy=440,rdca=2937,rdldhar=2969",
@@ -2888,6 +2891,34 @@ function continuesName(text, index) {
2888
2891
  var FILE_TLDS = /* @__PURE__ */ new Set(["ai", "app", "md", "mov", "pm", "ps", "py", "rs", "sh", "so", "zip"]);
2889
2892
  var LINK_FILE_TLDS = /* @__PURE__ */ new Set(["md", "mov", "pm", "ps", "py", "rs", "sh", "so", "zip"]);
2890
2893
  var CLICK_TRACKERS = ["sendgrid.net", "list-manage.com", "mandrillapp.com", "awstrack.me", "pstmrk.it", "hubspotlinks.com", "rs6.net", "klclick.com", "mjt.lu", "exacttarget.com", "convertkit-mail.com", "convertkit-mail2.com", "kit-mail3.com", "sendibt2.com", "sendibt3.com", "createsend1.com", "cmail20.com", "clicks.mlsend.com", "acemlnb.com", "link.mail.beehiiv.com", "bhclick.com", "safelinks.protection.outlook.com", "urldefense.com", "urldefense.proofpoint.com", "mimecast.com", "mimecastprotect.com", "cudasvc.com", "secure-web.cisco.com"];
2894
+ var MAX_URL_HOST = 4096;
2895
+ var AUTHORITY_END = /[/\\?#]/g;
2896
+ var IGNORED_IN_HOST = /[\u00AD\u034F\u180B-\u180D\u180F\u200B\u2060\u2064\uFE00-\uFE0F\uFEFF]|\uD82F[\uDCA0-\uDCA3]|\uDB40[\uDD00-\uDDEF]/g;
2897
+ function boundedUrl(address2) {
2898
+ const text = String(address2).replaceAll(/[\t\n\r]/g, "");
2899
+ const colon = text.indexOf(":");
2900
+ let start = colon === -1 ? 0 : colon + 1;
2901
+ while (start < text.length && (text[start] === "/" || text[start] === "\\")) {
2902
+ start++;
2903
+ }
2904
+ AUTHORITY_END.lastIndex = start;
2905
+ const end = AUTHORITY_END.exec(text)?.index ?? text.length;
2906
+ const file = /^[\0- ]*file:/i.test(text);
2907
+ const at = file ? start : Math.max(start, text.lastIndexOf("@", end - 1) + 1);
2908
+ const bracket = text.lastIndexOf("]", end - 1);
2909
+ const port = text.lastIndexOf(":", end - 1);
2910
+ let host = text.slice(at, !file && port > Math.max(at - 1, bracket) ? port : end);
2911
+ if (host.length > MAX_URL_HOST && host.includes("%")) {
2912
+ try {
2913
+ host = decodeURIComponent(host);
2914
+ } catch {
2915
+ }
2916
+ }
2917
+ if (host.length > MAX_URL_HOST && host.replaceAll(IGNORED_IN_HOST, "").length > MAX_URL_HOST) {
2918
+ throw new TypeError("Invalid URL: host too long");
2919
+ }
2920
+ return new URL(text);
2921
+ }
2891
2922
  var isUnder = (host, domain) => host === domain || host.endsWith(`.${domain}`);
2892
2923
  function isClickTracker(url) {
2893
2924
  const host = url.hostname.toLowerCase();
@@ -2916,7 +2947,7 @@ var MAX_GATEWAYS = 16;
2916
2947
  function unwrapLink(address2) {
2917
2948
  let url;
2918
2949
  try {
2919
- url = new URL(address2);
2950
+ url = boundedUrl(address2);
2920
2951
  } catch {
2921
2952
  return null;
2922
2953
  }
@@ -2927,7 +2958,7 @@ function unwrapLink(address2) {
2927
2958
  }
2928
2959
  let next;
2929
2960
  try {
2930
- next = new URL(target);
2961
+ next = boundedUrl(target);
2931
2962
  } catch {
2932
2963
  break;
2933
2964
  }
@@ -3205,7 +3236,7 @@ function extractLinks(text, html, { maxLength = 5e5, bareHosts: bareHosts2 = fal
3205
3236
  }
3206
3237
  let url;
3207
3238
  try {
3208
- url = new URL(href);
3239
+ url = boundedUrl(href);
3209
3240
  } catch {
3210
3241
  return;
3211
3242
  }
@@ -3530,7 +3561,7 @@ function isDeceptiveLink(link, { from } = {}) {
3530
3561
  }
3531
3562
  let shownUrl;
3532
3563
  try {
3533
- shownUrl = new URL(/^https?:\/\//i.test(link.text) ? link.text : `http://${link.text}`);
3564
+ shownUrl = boundedUrl(/^https?:\/\//i.test(link.text) ? link.text : `http://${link.text}`);
3534
3565
  } catch {
3535
3566
  return false;
3536
3567
  }
@@ -5343,7 +5374,7 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
5343
5374
  }
5344
5375
  }
5345
5376
  async function expandAttachments(attachments = [], errors = [], parse = parseMessage, gets = {}) {
5346
- const { unopened = [], mails = [] } = Array.isArray(gets) ? { unopened: gets } : gets;
5377
+ const { unopened = [], mails = [] } = Array.isArray(gets) ? { unopened: gets } : gets ?? {};
5347
5378
  const all = [];
5348
5379
  const deadline = Date.now() + PARSE_TIMEOUT;
5349
5380
  let budget = MAX_NESTED_BYTES;
@@ -5411,6 +5442,11 @@ import { isIP } from "node:net";
5411
5442
  import { domainToASCII } from "node:url";
5412
5443
  import { getPublicSuffix } from "tldts";
5413
5444
  var LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
5445
+ var LABEL_SEPARATOR = /[.\u3002\uFF0E\uFF61]/g;
5446
+ function lastLabels(name) {
5447
+ const separators = [...name.matchAll(LABEL_SEPARATOR)];
5448
+ return separators.length < 128 ? 0 : separators.at(-128).index + 1;
5449
+ }
5414
5450
  function lookupName(host) {
5415
5451
  if (typeof host !== "string") {
5416
5452
  return null;
@@ -5421,6 +5457,7 @@ function lookupName(host) {
5421
5457
  }
5422
5458
  let name = host.slice(0, end).toLowerCase();
5423
5459
  if (/[\u0080-\u{10FFFF}]/u.test(name)) {
5460
+ name = name.slice(lastLabels(name));
5424
5461
  name = domainToASCII(name) || name;
5425
5462
  }
5426
5463
  if (isIP(name.replaceAll(/^\[|]$/g, "")) !== 0) {
@@ -5691,7 +5728,7 @@ function asciiHostname(value) {
5691
5728
  return null;
5692
5729
  }
5693
5730
  const lower = value.normalize("NFKC").toLowerCase().replace(/\.$/, "");
5694
- if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
5731
+ if (lower === "" || lower.length > 1024 || /[\0-,/:-^`{-\u007F]/.test(lower)) {
5695
5732
  return null;
5696
5733
  }
5697
5734
  const ascii = domainToASCII2(lower);
@@ -5873,8 +5910,8 @@ function trustReputation(result, trusted = []) {
5873
5910
  for (const value of result.checkedValues.filter((item) => allowed.has(item))) {
5874
5911
  const signers = allowed.get(value);
5875
5912
  const counts = (entry) => Boolean(entry) && (signers === null || signers.some((signer) => vouches(normalizeEntry(entry), signer)));
5876
- const detail = result.details[value];
5877
- const listed = result.allowlisted[value] || (detail?.isAllowlisted ? detail.allowlistValue : null);
5913
+ const detail = Object.hasOwn(result.details, value) ? result.details[value] : void 0;
5914
+ const listed = Object.hasOwn(result.allowlisted, value) && result.allowlisted[value] || (detail?.isAllowlisted ? detail.allowlistValue : null);
5878
5915
  if (counts(listed) && !out.isAllowlisted) {
5879
5916
  out.isAllowlisted = true;
5880
5917
  out.allowlistValue = listed;
@@ -6273,7 +6310,7 @@ import net from "node:net";
6273
6310
  var package_default = {
6274
6311
  name: "spamscanner",
6275
6312
  description: "Spam, phishing and malware filter for email in any language: a trainable classifier, link and attachment checks, SPF/DKIM/DMARC, and an optional AI second opinion from any LLM, including local Ollama models. Works as a library, CLI, Postfix milter or HTTP API.",
6276
- version: "7.0.11",
6313
+ version: "7.0.13",
6277
6314
  author: "Forward Email (https://forwardemail.net)",
6278
6315
  bin: {
6279
6316
  spamscanner: "./dist/esm/cli.js"
@@ -6423,7 +6460,12 @@ var VERSION = package_default.version;
6423
6460
  var MAX_MESSAGE_SIZE = 25 * 1024 * 1024;
6424
6461
  var MAX_CONNECTIONS = 200;
6425
6462
  function serializeResult(result, options = {}) {
6426
- const { mail, tokens, ...rest } = result;
6463
+ const {
6464
+ mail,
6465
+ tokens,
6466
+ attached,
6467
+ ...rest
6468
+ } = result;
6427
6469
  const output = {
6428
6470
  ...rest,
6429
6471
  results: { ...result.results }
@@ -7324,6 +7366,7 @@ function reply(code, text, headers = [], body = null) {
7324
7366
  lines2.push(...headers.map(([name, value]) => `${name}: ${value}`), "", "");
7325
7367
  return body === null ? Buffer9.from(lines2.join("\r\n")) : Buffer9.concat([Buffer9.from(lines2.join("\r\n")), body]);
7326
7368
  }
7369
+ var oneLine2 = (text) => String(text ?? "").replaceAll(/[\p{Cc}\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]+/gu, " ");
7327
7370
  function report(result) {
7328
7371
  const lines2 = [
7329
7372
  `Spam detection software, running on the system "spamscanner", has ${result.isSpam ? "identified this incoming email as possible spam" : "scanned this incoming email"}.`,
@@ -7332,7 +7375,7 @@ function report(result) {
7332
7375
  "",
7333
7376
  " pts rule name description",
7334
7377
  "---- ---------------------- --------------------------------------------------",
7335
- ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${test.name.padEnd(22)} ${test.description}`)
7378
+ ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${oneLine2(test.name).padEnd(22)} ${oneLine2(test.description)}`)
7336
7379
  ];
7337
7380
  return `${lines2.join("\r\n")}\r
7338
7381
  `;
@@ -8355,7 +8398,8 @@ var MAX_EMBEDDED_LENGTH = 10 * 1024 * 1024;
8355
8398
  var MAX_ATTACHED = 100;
8356
8399
  var MAX_ATTACHED_BYTES = 50 * 1024 * 1024;
8357
8400
  var MAX_READ_TIME = 5e3;
8358
- var MAX_SHOWN_BYTES = 4 * 1024 * 1024;
8401
+ var MAX_SHOWN_BYTES = 1024 * 1024;
8402
+ var MAX_CARD_TEXT = 8 * 1024 * 1024;
8359
8403
  function isHostCode2(code) {
8360
8404
  return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
8361
8405
  }
@@ -8384,7 +8428,7 @@ function urlOf(value) {
8384
8428
  return null;
8385
8429
  }
8386
8430
  try {
8387
- return new URL(href);
8431
+ return boundedUrl(href);
8388
8432
  } catch {
8389
8433
  return null;
8390
8434
  }
@@ -8474,7 +8518,26 @@ function* attributesOf(html) {
8474
8518
  TAG_START.lastIndex = Math.max(i, match.index + 1);
8475
8519
  }
8476
8520
  }
8477
- function linksOf(name, value, add, embed) {
8521
+ var MAX_VALUE2 = 1024 * 1024;
8522
+ var MAX_REFERENCES = 64 * 1024;
8523
+ function isCut(value) {
8524
+ if (value.length <= MAX_VALUE2) {
8525
+ return false;
8526
+ }
8527
+ let references = 0;
8528
+ for (let index = value.indexOf("&"); index !== -1; index = value.indexOf("&", index + 1)) {
8529
+ if (++references > MAX_REFERENCES) {
8530
+ return true;
8531
+ }
8532
+ }
8533
+ return false;
8534
+ }
8535
+ var LONG_VALUE = 64 * 1024;
8536
+ function linksOf(name, value, { add, embed, cut }) {
8537
+ if (isCut(value)) {
8538
+ cut();
8539
+ value = value.slice(0, MAX_VALUE2);
8540
+ }
8478
8541
  const decoded = decodeEntities(value, { attribute: true });
8479
8542
  switch (name) {
8480
8543
  case "srcdoc": {
@@ -8515,7 +8578,7 @@ function linksOf(name, value, add, embed) {
8515
8578
  }
8516
8579
  var MAX_LENIENT_VALUE = 4096;
8517
8580
  var UNQUOTED_END = /* @__PURE__ */ new Set(["<", ">", '"', "'", "`", "="]);
8518
- function lenientLinks(html, add, done, embed) {
8581
+ function lenientLinks(html, done, reader) {
8519
8582
  LENIENT.lastIndex = 0;
8520
8583
  let match;
8521
8584
  while (!done() && (match = LENIENT.exec(html)) !== null) {
@@ -8531,8 +8594,8 @@ function lenientLinks(html, add, done, embed) {
8531
8594
  }
8532
8595
  if (end < html.length && html[end] !== quote) {
8533
8596
  const close = html.indexOf(quote, end);
8534
- if (close !== -1 && close - i <= MAX_LENIENT_VALUE) {
8535
- linksOf(name, html.slice(i, close), add, embed);
8597
+ if (close !== -1 && close - i <= MAX_LENIENT_VALUE && !reader.spend(close - i)) {
8598
+ linksOf(name, html.slice(i, close), reader);
8536
8599
  }
8537
8600
  }
8538
8601
  } else {
@@ -8540,7 +8603,10 @@ function lenientLinks(html, add, done, embed) {
8540
8603
  end++;
8541
8604
  }
8542
8605
  }
8543
- linksOf(name, html.slice(i, end), add, embed);
8606
+ if (reader.spend(end - i)) {
8607
+ break;
8608
+ }
8609
+ linksOf(name, html.slice(i, end), reader);
8544
8610
  }
8545
8611
  }
8546
8612
  function endsLink2(text, i) {
@@ -8711,8 +8777,17 @@ function* chunksOf(html) {
8711
8777
  cutStart = false;
8712
8778
  continue;
8713
8779
  }
8714
- yield { text: html.slice(start, end), cutStart, cutEnd: true };
8715
- start = end - CHUNK_OVERLAP;
8780
+ let hard = end;
8781
+ while (hard > start + CHUNK_OVERLAP + 1 && /[\dA-Za-z#]/.test(html[hard - 1])) {
8782
+ hard--;
8783
+ }
8784
+ if (hard < end && html[hard - 1] === "&" && hard - 1 > start + CHUNK_OVERLAP) {
8785
+ hard--;
8786
+ } else {
8787
+ hard = end;
8788
+ }
8789
+ yield { text: html.slice(start, hard), cutStart, cutEnd: true };
8790
+ start = hard - CHUNK_OVERLAP;
8716
8791
  cutStart = true;
8717
8792
  continue;
8718
8793
  }
@@ -8935,40 +9010,50 @@ function htmlDescriptions(text) {
8935
9010
  return html.join("\n");
8936
9011
  }
8937
9012
  var BINARY = /;\s*(?:(?:encoding\s*=\s*)?"?(?:b|base64)"?|value\s*=\s*"?binary"?)\s*(?:;|$)/i;
8938
- function withoutBinaryValues(text) {
9013
+ function withoutBinaryValues(text, limit = Number.POSITIVE_INFINITY) {
8939
9014
  if (!/base64|binary|encoding\s*=\s*"?b|;\s*b\s*[;:]/i.test(text)) {
8940
9015
  return text;
8941
9016
  }
8942
9017
  const lines2 = text.split(LINE_BREAK);
8943
9018
  const out = [];
9019
+ let length = 0;
9020
+ const push = (line) => {
9021
+ out.push(line);
9022
+ length += line.length + 1;
9023
+ };
8944
9024
  for (let i = 0; i < lines2.length; i++) {
9025
+ if (length > limit) {
9026
+ break;
9027
+ }
8945
9028
  const line = lines2[i];
8946
9029
  const colon = valueColon(line);
8947
9030
  if (colon === -1 || !BINARY.test(line.slice(0, colon))) {
8948
- out.push(line);
9031
+ push(line);
8949
9032
  continue;
8950
9033
  }
8951
- const value = [line.slice(colon + 1)];
9034
+ let base64 = /^[\w+/=\s]*$/.test(line.slice(colon + 1));
8952
9035
  let next = i + 1;
8953
9036
  while (next < lines2.length && (lines2[next].startsWith(" ") || lines2[next].startsWith(" "))) {
8954
- value.push(lines2[next++]);
9037
+ base64 &&= /^[\w+/=\s]*$/.test(lines2[next]);
9038
+ next++;
8955
9039
  }
8956
- if (!value.every((part) => /^[\w+/=\s]*$/.test(part))) {
9040
+ if (!base64) {
8957
9041
  for (let index = i; index < next; index++) {
8958
- out.push(lines2[index]);
9042
+ push(lines2[index]);
8959
9043
  }
8960
9044
  i = next - 1;
8961
9045
  continue;
8962
9046
  }
8963
9047
  while (next < lines2.length && lines2[next] !== "" && /^[\w+/=]+$/.test(lines2[next])) {
8964
- value.push(lines2[next++]);
9048
+ next++;
8965
9049
  }
9050
+ const end = next;
8966
9051
  if (next < lines2.length && lines2[next] === "") {
8967
9052
  next++;
8968
9053
  }
8969
- i = next - 1;
8970
9054
  const name = line.slice(0, colon).split(";")[0].split(".").pop().trim().toLowerCase();
8971
- out.push(MEDIA_PROPERTIES.has(name) ? line.slice(0, colon + 1) : line.slice(0, colon + 1) + Buffer13.from(value.join("").replaceAll(/\s/g, ""), "base64").toString("utf8"));
9055
+ push(MEDIA_PROPERTIES.has(name) ? line.slice(0, colon + 1) : line.slice(0, colon + 1) + Buffer13.from([line.slice(colon + 1), ...lines2.slice(i + 1, end)].join("").replaceAll(/\s/g, ""), "base64").toString("utf8"));
9056
+ i = next - 1;
8972
9057
  }
8973
9058
  return out.join("\n");
8974
9059
  }
@@ -9007,59 +9092,77 @@ function charsetOf(attachment) {
9007
9092
  const charset = headers && typeof headers.get === "function" ? headers.get("content-type")?.params?.charset : null;
9008
9093
  return typeof charset === "string" ? charset.toLowerCase() : "";
9009
9094
  }
9010
- function documentsOf(attachments, until) {
9011
- const documents = [];
9095
+ function kindOf(attachment) {
9096
+ const { content } = attachment ?? {};
9097
+ if (!Buffer13.isBuffer(content) || content.length === 0) {
9098
+ return null;
9099
+ }
9100
+ const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
9101
+ const extensions = extensionsOf(attachment);
9102
+ const has = (set) => [...extensions].some((name) => set.has(name));
9103
+ if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
9104
+ return null;
9105
+ }
9106
+ const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
9107
+ const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
9108
+ const isText = isCard || !isHtml && type === "text/plain";
9109
+ return isHtml || isText ? { isHtml, isText, isCard } : null;
9110
+ }
9111
+ function* documentsOf(attachments, state, late) {
9112
+ const read = [];
9012
9113
  let bytes = 0;
9013
9114
  let opened = 0;
9014
9115
  for (const attachment of attachments) {
9015
- const { content } = attachment ?? {};
9016
- if (!Buffer13.isBuffer(content) || content.length === 0) {
9116
+ const kind = kindOf(attachment);
9117
+ if (!kind) {
9017
9118
  continue;
9018
9119
  }
9019
- const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
9020
- const extensions = extensionsOf(attachment);
9021
- const has = (set) => [...extensions].some((name) => set.has(name));
9022
- if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
9023
- continue;
9024
- }
9025
- const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
9026
- const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
9027
- const isText = isCard || !isHtml && type === "text/plain";
9028
- if (!isHtml && !isText) {
9029
- continue;
9030
- }
9031
- if (!isText && ++opened > MAX_ATTACHED || bytes + content.length > MAX_ATTACHED_BYTES) {
9032
- documents.tooComplex = true;
9033
- return documents;
9120
+ if (!kind.isText && ++opened > MAX_ATTACHED || bytes + attachment.content.length > MAX_ATTACHED_BYTES) {
9121
+ state.tooComplex = true;
9122
+ break;
9034
9123
  }
9035
- if (Date.now() > until) {
9036
- documents.incomplete = true;
9037
- return documents;
9124
+ bytes += attachment.content.length;
9125
+ read.push({ attachment, kind });
9126
+ }
9127
+ for (const { attachment, kind: { isHtml, isCard } } of read) {
9128
+ if (late()) {
9129
+ state.incomplete = true;
9130
+ return;
9038
9131
  }
9039
- bytes += content.length;
9040
- const text = decodeText(content, charsetOf(attachment));
9132
+ const text = decodeText(attachment.content, charsetOf(attachment));
9041
9133
  if (isHtml) {
9042
- documents.push({ html: text });
9134
+ yield { html: text };
9043
9135
  continue;
9044
9136
  }
9045
9137
  if (!isCard) {
9046
- documents.push({ text });
9138
+ yield { text };
9047
9139
  continue;
9048
9140
  }
9049
- const readable = withoutBinaryValues(text);
9141
+ let readable = withoutBinaryValues(text, MAX_CARD_TEXT);
9142
+ if (readable.length > MAX_CARD_TEXT) {
9143
+ state.tooComplex = true;
9144
+ readable = readable.slice(0, MAX_CARD_TEXT);
9145
+ }
9050
9146
  const full = readable.length <= MAX_SHOWN_BYTES;
9051
9147
  if (!full && hasQuotedPrintable(readable)) {
9052
- documents.tooComplex = true;
9053
- continue;
9148
+ state.tooComplex = true;
9149
+ }
9150
+ if (late()) {
9151
+ state.incomplete = true;
9152
+ return;
9153
+ }
9154
+ yield { text: shownText(readable, full) };
9155
+ if (late()) {
9156
+ state.incomplete = true;
9157
+ return;
9054
9158
  }
9055
- documents.push({ text: shownText(readable, full) });
9056
9159
  const html = htmlDescriptions(readable);
9057
9160
  if (html !== "") {
9058
- documents.push({ html });
9161
+ yield { html };
9059
9162
  }
9060
9163
  }
9061
- return documents;
9062
9164
  }
9165
+ var LATE_EVERY = 1024;
9063
9166
  function carriedLinkHosts(mail, {
9064
9167
  mails = [],
9065
9168
  attachments = [],
@@ -9072,40 +9175,45 @@ function carriedLinkHosts(mail, {
9072
9175
  const seenBare = /* @__PURE__ */ new Set();
9073
9176
  const seenLoose = /* @__PURE__ */ new Set();
9074
9177
  let more = false;
9075
- let tooComplex = false;
9076
- const done = () => more;
9077
- const looseDone = () => more || loose.size > maxHosts;
9178
+ let full = false;
9179
+ const domains = /* @__PURE__ */ new Set();
9180
+ const state = { tooComplex: false, incomplete: false };
9181
+ const until = Date.now() + MAX_READ_TIME;
9182
+ const late = () => {
9183
+ if (!state.incomplete && Date.now() > until) {
9184
+ state.incomplete = true;
9185
+ }
9186
+ return state.incomplete;
9187
+ };
9188
+ let calls = 0;
9189
+ const done = () => full || state.incomplete || ++calls % LATE_EVERY === 0 && late();
9190
+ const looseDone = () => done() || loose.size > maxHosts;
9078
9191
  const addLoose = (host) => {
9079
9192
  if (!looseDone()) {
9080
9193
  loose.add(host);
9081
9194
  }
9082
9195
  };
9083
- const until = Date.now() + MAX_READ_TIME;
9084
- let incomplete = false;
9085
- const late = () => {
9086
- if (!incomplete && Date.now() > until) {
9087
- incomplete = true;
9088
- }
9089
- return incomplete;
9090
- };
9091
- const carried = documentsOf(attachments, until);
9092
- tooComplex = Boolean(carried.tooComplex);
9093
- incomplete = Boolean(carried.incomplete);
9094
- const queue = [mail, ...mails, ...carried].map((document) => ({ document, depth: 0 }));
9196
+ const queue = [mail, ...mails].map((document) => ({ document, depth: 0 }));
9197
+ const carried = documentsOf(attachments, state, late);
9095
9198
  let depth = 0;
9096
9199
  let embedded = 0;
9097
9200
  let embeddedLength = 0;
9201
+ const embeddedSeen = /* @__PURE__ */ new Set();
9098
9202
  const embed = (html) => {
9203
+ if (html === "" || embeddedSeen.has(html)) {
9204
+ return;
9205
+ }
9099
9206
  if (depth >= MAX_EMBED_DEPTH || embedded >= MAX_EMBEDDED || embeddedLength + html.length > MAX_EMBEDDED_LENGTH) {
9100
- tooComplex = true;
9207
+ state.tooComplex = true;
9101
9208
  return;
9102
9209
  }
9210
+ embeddedSeen.add(html);
9103
9211
  embedded++;
9104
9212
  embeddedLength += html.length;
9105
9213
  queue.push({ document: { html }, depth: depth + 1 });
9106
9214
  };
9107
9215
  const add = (value) => {
9108
- if (more || typeof value !== "string" || value === "" || seen.has(value)) {
9216
+ if (full || typeof value !== "string" || value === "" || seen.has(value)) {
9109
9217
  return;
9110
9218
  }
9111
9219
  if (seen.size < MAX_SEEN) {
@@ -9120,34 +9228,64 @@ function carriedLinkHosts(mail, {
9120
9228
  }
9121
9229
  const found = hostsOf(value);
9122
9230
  if (found.unresolved) {
9123
- tooComplex = true;
9231
+ state.tooComplex = true;
9124
9232
  }
9125
9233
  for (const host of found) {
9126
9234
  if (hosts.has(host)) {
9127
9235
  continue;
9128
9236
  }
9129
- if (hosts.size >= maxHosts) {
9130
- more = true;
9237
+ const domain = registrableDomain(host);
9238
+ if (hosts.size < maxHosts || !domains.has(domain) && domains.size < maxHosts) {
9239
+ hosts.add(host);
9240
+ domains.add(domain);
9241
+ continue;
9242
+ }
9243
+ more = true;
9244
+ if (domains.size >= maxHosts) {
9245
+ full = true;
9131
9246
  return;
9132
9247
  }
9133
- hosts.add(host);
9134
9248
  }
9135
9249
  };
9136
- const readText = (source, { cutStart = false, cutEnd = false } = {}) => {
9250
+ let spent = 0;
9251
+ const spend = (length) => {
9252
+ spent += length;
9253
+ if (spent < LONG_VALUE) {
9254
+ return false;
9255
+ }
9256
+ spent = 0;
9257
+ return late();
9258
+ };
9259
+ const reader = {
9260
+ add,
9261
+ embed,
9262
+ spend,
9263
+ cut() {
9264
+ state.tooComplex = true;
9265
+ }
9266
+ };
9267
+ const readText = (source, { cutStart = false, cutEnd = false, exact = false } = {}) => {
9137
9268
  for (const match of source.matchAll(URL_START)) {
9138
- if (more) {
9269
+ if (done()) {
9139
9270
  break;
9140
9271
  }
9141
9272
  if (cutStart && match.index === 0) {
9142
9273
  continue;
9143
9274
  }
9144
9275
  if (cutEnd && match.index + match[0].length === source.length) {
9276
+ if (exact && source.length - match.index < CHUNK_OVERLAP) {
9277
+ continue;
9278
+ }
9145
9279
  const value = match[0];
9146
9280
  const at = /^www\./i.test(value) ? 0 : value.indexOf("//") + 2;
9147
- if (/[/?#\\]/.test(value.slice(at))) {
9148
- add(value);
9149
- } else if (source.length - match.index > CHUNK_OVERLAP) {
9150
- tooComplex = true;
9281
+ if (!/[/?#\\]/.test(value.slice(at))) {
9282
+ state.tooComplex = true;
9283
+ continue;
9284
+ }
9285
+ add(value);
9286
+ const url = urlOf(value);
9287
+ if (url && isClickTracker(url)) {
9288
+ state.tooComplex = true;
9151
9289
  }
9152
9290
  continue;
9153
9291
  }
@@ -9169,42 +9307,61 @@ function carriedLinkHosts(mail, {
9169
9307
  });
9170
9308
  }
9171
9309
  };
9172
- for (const element of queue) {
9173
- if (more || late()) {
9310
+ for (let index = 0; ; index++) {
9311
+ if (full || late()) {
9174
9312
  break;
9175
9313
  }
9176
- ({ depth } = element);
9177
- const { document } = element;
9314
+ if (index >= queue.length) {
9315
+ const next = carried.next();
9316
+ if (next.done) {
9317
+ break;
9318
+ }
9319
+ queue.push({ document: next.value, depth: 0 });
9320
+ }
9321
+ ({ depth } = queue[index]);
9322
+ const { document } = queue[index];
9178
9323
  const html = withoutNul(typeof document?.html === "string" ? document.html : "");
9179
9324
  const text = withoutNul(typeof document?.text === "string" ? document.text : "");
9180
9325
  for (const [name, value] of attributesOf(html)) {
9181
- if (more) {
9326
+ if (done()) {
9182
9327
  break;
9183
9328
  }
9184
- if (LINK_ATTRIBUTES.has(name)) {
9185
- linksOf(name, value, add, embed);
9329
+ if (!LINK_ATTRIBUTES.has(name)) {
9330
+ continue;
9331
+ }
9332
+ if (spend(value.length)) {
9333
+ break;
9186
9334
  }
9335
+ linksOf(name, value, reader);
9187
9336
  }
9188
- lenientLinks(html, add, done, embed);
9337
+ lenientLinks(html, done, reader);
9189
9338
  for (const pattern of [CSS_URL, CSS_IMPORT]) {
9190
9339
  for (const [, link] of html.matchAll(pattern)) {
9191
- if (more) {
9340
+ if (done() || spend(link.length)) {
9192
9341
  break;
9193
9342
  }
9194
- add(decodeEntities(link, { attribute: true }));
9343
+ const cut = isCut(link);
9344
+ if (cut) {
9345
+ state.tooComplex = true;
9346
+ }
9347
+ add(decodeEntities(cut ? link.slice(0, MAX_VALUE2) : link, { attribute: true }));
9195
9348
  }
9196
9349
  }
9197
- if (!more) {
9198
- readText(text);
9350
+ for (const chunk2 of chunksOf(text)) {
9351
+ if (full || late()) {
9352
+ break;
9353
+ }
9354
+ readText(chunk2.text, { ...chunk2, exact: true });
9199
9355
  }
9200
9356
  for (const chunk2 of chunksOf(html)) {
9201
- if (more || late()) {
9357
+ if (full || late()) {
9202
9358
  break;
9203
9359
  }
9204
- readText(decodeEntities(chunk2.text), chunk2);
9360
+ const decoded = decodeEntities(chunk2.text);
9361
+ readText(decoded, { ...chunk2, exact: decoded.endsWith(chunk2.text.slice(-CHUNK_OVERLAP)) });
9205
9362
  }
9206
9363
  }
9207
- if (!more && !incomplete) {
9364
+ if (!more && !state.incomplete) {
9208
9365
  for (const name of loose) {
9209
9366
  if (hosts.size >= maxHosts) {
9210
9367
  break;
@@ -9218,8 +9375,8 @@ function carriedLinkHosts(mail, {
9218
9375
  return {
9219
9376
  hosts: [...hosts],
9220
9377
  more,
9221
- tooComplex,
9222
- incomplete
9378
+ tooComplex: state.tooComplex,
9379
+ incomplete: state.incomplete
9223
9380
  };
9224
9381
  }
9225
9382
 
@@ -10120,7 +10277,7 @@ var SpamScanner = class {
10120
10277
  // (`from`: the sender's domain; `carried`: the link hosts of the whole
10121
10278
  // message and of what it carries, see carriedLinkHosts)
10122
10279
  async checkLinks(links, config, hosts = [], options = {}) {
10123
- const { from = "", carried = null } = typeof options === "string" ? { from: options } : options;
10280
+ const { from = "", carried = null } = typeof options === "string" ? { from: options } : options ?? {};
10124
10281
  const phishing = [];
10125
10282
  const homographs = [];
10126
10283
  const seenHosts = /* @__PURE__ */ new Set();
@@ -10180,15 +10337,15 @@ var SpamScanner = class {
10180
10337
  for (const host of carriedHosts) {
10181
10338
  seenHosts.add(host);
10182
10339
  }
10183
- const ordered = [.../* @__PURE__ */ new Set([...[...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean), ...carriedHosts])];
10340
+ const own = [...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean);
10184
10341
  const firsts = /* @__PURE__ */ new Map();
10185
- for (const host of ordered) {
10342
+ for (const host of [...own, ...carriedHosts]) {
10186
10343
  const domain = registrableDomain(host);
10187
10344
  if (!firsts.has(domain)) {
10188
10345
  firsts.set(domain, host);
10189
10346
  }
10190
10347
  }
10191
- const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...ordered])].slice(0, config.phishing.maxHosts);
10348
+ const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...own, ...carriedHosts])].slice(0, config.phishing.maxHosts);
10192
10349
  const domains = [...new Set(checked.map((host) => registrableDomain(host)))];
10193
10350
  const blocked = [];
10194
10351
  let answered = 0;
@@ -10925,17 +11082,18 @@ async function readInput(file, stdin, maxSize) {
10925
11082
  }
10926
11083
  return data;
10927
11084
  }
11085
+ var printable = (text) => String(text ?? "").replaceAll(/[\p{Cc}\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]+/gu, " ");
10928
11086
  function formatResult(result, verbose) {
10929
11087
  const lines2 = [`${result.isSpam ? "SPAM" : "HAM"} score ${result.score.toFixed(1)} (spam at ${result.threshold.toFixed(1)}, reject at ${result.rejectThreshold.toFixed(1)}) action: ${result.action}${result.language ? ` language: ${result.language}` : ""}`];
10930
11088
  const tests = verbose ? result.tests : result.tests.filter((test) => test.score !== 0);
10931
11089
  for (const test of [...tests].sort((a, b) => Math.abs(b.score) - Math.abs(a.score))) {
10932
- lines2.push(` ${`${test.score > 0 ? "+" : ""}${test.score.toFixed(1)}`.padStart(7)} ${test.name.padEnd(28)} ${test.description}`);
11090
+ lines2.push(` ${`${test.score > 0 ? "+" : ""}${test.score.toFixed(1)}`.padStart(7)} ${printable(test.name).padEnd(28)} ${printable(test.description)}`);
10933
11091
  }
10934
11092
  if (verbose && result.results.classification.clues?.length > 0) {
10935
- lines2.push(" Strongest clues:", ...result.results.classification.clues.slice(0, 10).map((clue) => ` ${clue.probability.toFixed(3)} ${clue.feature}`));
11093
+ lines2.push(" Strongest clues:", ...result.results.classification.clues.slice(0, 10).map((clue) => ` ${clue.probability.toFixed(3)} ${printable(clue.feature)}`));
10936
11094
  }
10937
11095
  if (result.results.llm?.error) {
10938
- lines2.push(` Language model failed: ${result.results.llm.error}`);
11096
+ lines2.push(` Language model failed: ${printable(result.results.llm.error)}`);
10939
11097
  }
10940
11098
  return lines2.join("\n");
10941
11099
  }
@@ -11073,11 +11231,11 @@ var HANDLERS = {
11073
11231
  message = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam ? values["subject-tag"] ?? null : null });
11074
11232
  } catch (error) {
11075
11233
  if (!accept) {
11076
- stderr.write(`filter: scan failed, deferring: ${error.message}
11234
+ stderr.write(`filter: scan failed, deferring: ${printable(error.message)}
11077
11235
  `);
11078
11236
  return 75;
11079
11237
  }
11080
- stderr.write(`filter: scan failed, passing the message on: ${error.message}
11238
+ stderr.write(`filter: scan failed, passing the message on: ${printable(error.message)}
11081
11239
  `);
11082
11240
  result = null;
11083
11241
  message = rewriteMessage(raw, [["X-Spam-Virus-Scan", "failed"]]);
@@ -11110,10 +11268,10 @@ var HANDLERS = {
11110
11268
  hostname: values.name || "spamscanner",
11111
11269
  onError: onError(values)
11112
11270
  });
11113
- milter.on("error", (error) => stderr.write(`milter: ${error.message}
11271
+ milter.on("error", (error) => stderr.write(`milter: ${printable(error.message)}
11114
11272
  `));
11115
11273
  if (values.verbose) {
11116
- milter.on("scan", ({ session, result }) => stderr.write(`${session.remoteAddress || "-"} ${session.envelope?.mailFrom?.address || "<>"} ${result.score.toFixed(1)} ${result.action} ${result.tests.map((test) => test.name).join(",")}
11274
+ milter.on("scan", ({ session, result }) => stderr.write(`${session.remoteAddress || "-"} ${printable(session.envelope?.mailFrom?.address) || "<>"} ${result.score.toFixed(1)} ${result.action} ${result.tests.map((test) => test.name).join(",")}
11117
11275
  `));
11118
11276
  }
11119
11277
  await listen(milter.server, values, 7831, { stderr });