spamscanner 7.0.11 → 7.0.12

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.12",
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_CARD_BYTES = 4 * 1024 * 1024;
8402
+ var MAX_SHOWN_BYTES = 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,13 @@ 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 LONG_VALUE = 64 * 1024;
8523
+ function linksOf(name, value, { add, embed, cut }) {
8524
+ if (value.length > MAX_VALUE2) {
8525
+ cut();
8526
+ value = value.slice(0, MAX_VALUE2);
8527
+ }
8478
8528
  const decoded = decodeEntities(value, { attribute: true });
8479
8529
  switch (name) {
8480
8530
  case "srcdoc": {
@@ -8515,7 +8565,7 @@ function linksOf(name, value, add, embed) {
8515
8565
  }
8516
8566
  var MAX_LENIENT_VALUE = 4096;
8517
8567
  var UNQUOTED_END = /* @__PURE__ */ new Set(["<", ">", '"', "'", "`", "="]);
8518
- function lenientLinks(html, add, done, embed) {
8568
+ function lenientLinks(html, done, reader) {
8519
8569
  LENIENT.lastIndex = 0;
8520
8570
  let match;
8521
8571
  while (!done() && (match = LENIENT.exec(html)) !== null) {
@@ -8531,8 +8581,8 @@ function lenientLinks(html, add, done, embed) {
8531
8581
  }
8532
8582
  if (end < html.length && html[end] !== quote) {
8533
8583
  const close = html.indexOf(quote, end);
8534
- if (close !== -1 && close - i <= MAX_LENIENT_VALUE) {
8535
- linksOf(name, html.slice(i, close), add, embed);
8584
+ if (close !== -1 && close - i <= MAX_LENIENT_VALUE && !reader.spend(close - i)) {
8585
+ linksOf(name, html.slice(i, close), reader);
8536
8586
  }
8537
8587
  }
8538
8588
  } else {
@@ -8540,7 +8590,10 @@ function lenientLinks(html, add, done, embed) {
8540
8590
  end++;
8541
8591
  }
8542
8592
  }
8543
- linksOf(name, html.slice(i, end), add, embed);
8593
+ if (reader.spend(end - i)) {
8594
+ break;
8595
+ }
8596
+ linksOf(name, html.slice(i, end), reader);
8544
8597
  }
8545
8598
  }
8546
8599
  function endsLink2(text, i) {
@@ -8711,8 +8764,17 @@ function* chunksOf(html) {
8711
8764
  cutStart = false;
8712
8765
  continue;
8713
8766
  }
8714
- yield { text: html.slice(start, end), cutStart, cutEnd: true };
8715
- start = end - CHUNK_OVERLAP;
8767
+ let hard = end;
8768
+ while (hard > start + CHUNK_OVERLAP + 1 && /[\dA-Za-z#]/.test(html[hard - 1])) {
8769
+ hard--;
8770
+ }
8771
+ if (hard < end && html[hard - 1] === "&" && hard - 1 > start + CHUNK_OVERLAP) {
8772
+ hard--;
8773
+ } else {
8774
+ hard = end;
8775
+ }
8776
+ yield { text: html.slice(start, hard), cutStart, cutEnd: true };
8777
+ start = hard - CHUNK_OVERLAP;
8716
8778
  cutStart = true;
8717
8779
  continue;
8718
8780
  }
@@ -8882,27 +8944,14 @@ function quotedPrintableValues(text) {
8882
8944
  }
8883
8945
  return out.join("\n");
8884
8946
  }
8885
- function hasQuotedPrintable(text) {
8886
- const unfolded = text.replaceAll(FOLD, "");
8887
- if (!/quoted-printable/i.test(unfolded)) {
8888
- return false;
8889
- }
8890
- return unfolded.split(LINE_BREAK).some((line) => {
8891
- const colon = valueColon(line);
8892
- return colon !== -1 && QUOTED_PRINTABLE.test(line.slice(0, colon));
8893
- });
8894
- }
8895
- function shownText(text, full = true) {
8947
+ function shownText(text) {
8896
8948
  const unfolded = text.replaceAll(FOLD, "");
8897
8949
  const shown = /* @__PURE__ */ new Set([text, readEscapes(unfolded)]);
8898
- let decoded = unfolded;
8899
- if (full) {
8900
- decoded = quotedPrintableValues(unfolded);
8901
- if (decoded !== unfolded) {
8902
- shown.add(readEscapes(decoded));
8903
- if (unfolded !== text) {
8904
- shown.add(quotedPrintableValues(text));
8905
- }
8950
+ const decoded = quotedPrintableValues(unfolded);
8951
+ if (decoded !== unfolded) {
8952
+ shown.add(readEscapes(decoded));
8953
+ if (unfolded !== text) {
8954
+ shown.add(quotedPrintableValues(text));
8906
8955
  }
8907
8956
  }
8908
8957
  if (/\\[^nN,;\\]/.test(decoded)) {
@@ -9007,59 +9056,73 @@ function charsetOf(attachment) {
9007
9056
  const charset = headers && typeof headers.get === "function" ? headers.get("content-type")?.params?.charset : null;
9008
9057
  return typeof charset === "string" ? charset.toLowerCase() : "";
9009
9058
  }
9010
- function documentsOf(attachments, until) {
9011
- const documents = [];
9059
+ function kindOf(attachment) {
9060
+ const { content } = attachment ?? {};
9061
+ if (!Buffer13.isBuffer(content) || content.length === 0) {
9062
+ return null;
9063
+ }
9064
+ const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
9065
+ const extensions = extensionsOf(attachment);
9066
+ const has = (set) => [...extensions].some((name) => set.has(name));
9067
+ if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
9068
+ return null;
9069
+ }
9070
+ const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
9071
+ const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
9072
+ const isText = isCard || !isHtml && type === "text/plain";
9073
+ return isHtml || isText ? { isHtml, isText, isCard } : null;
9074
+ }
9075
+ function* documentsOf(attachments, state, late) {
9076
+ const read = [];
9012
9077
  let bytes = 0;
9013
9078
  let opened = 0;
9014
9079
  for (const attachment of attachments) {
9015
- const { content } = attachment ?? {};
9016
- if (!Buffer13.isBuffer(content) || content.length === 0) {
9080
+ const kind = kindOf(attachment);
9081
+ if (!kind) {
9017
9082
  continue;
9018
9083
  }
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;
9084
+ if (!kind.isText && ++opened > MAX_ATTACHED || bytes + attachment.content.length > MAX_ATTACHED_BYTES) {
9085
+ state.tooComplex = true;
9086
+ break;
9034
9087
  }
9035
- if (Date.now() > until) {
9036
- documents.incomplete = true;
9037
- return documents;
9088
+ bytes += attachment.content.length;
9089
+ read.push({ attachment, kind });
9090
+ }
9091
+ for (const { attachment, kind: { isHtml, isCard } } of read) {
9092
+ if (late()) {
9093
+ state.incomplete = true;
9094
+ return;
9038
9095
  }
9039
- bytes += content.length;
9040
- const text = decodeText(content, charsetOf(attachment));
9096
+ const text = decodeText(attachment.content, charsetOf(attachment));
9041
9097
  if (isHtml) {
9042
- documents.push({ html: text });
9098
+ yield { html: text };
9043
9099
  continue;
9044
9100
  }
9045
9101
  if (!isCard) {
9046
- documents.push({ text });
9102
+ yield { text };
9047
9103
  continue;
9048
9104
  }
9049
- const readable = withoutBinaryValues(text);
9050
- const full = readable.length <= MAX_SHOWN_BYTES;
9051
- if (!full && hasQuotedPrintable(readable)) {
9052
- documents.tooComplex = true;
9053
- continue;
9105
+ let readable = withoutBinaryValues(text.length > MAX_CARD_BYTES ? text.slice(0, MAX_CARD_BYTES) : text);
9106
+ if (text.length > MAX_CARD_BYTES || readable.length > MAX_SHOWN_BYTES) {
9107
+ state.tooComplex = true;
9108
+ readable = readable.slice(0, MAX_SHOWN_BYTES);
9109
+ }
9110
+ if (late()) {
9111
+ state.incomplete = true;
9112
+ return;
9113
+ }
9114
+ yield { text: shownText(readable) };
9115
+ if (late()) {
9116
+ state.incomplete = true;
9117
+ return;
9054
9118
  }
9055
- documents.push({ text: shownText(readable, full) });
9056
9119
  const html = htmlDescriptions(readable);
9057
9120
  if (html !== "") {
9058
- documents.push({ html });
9121
+ yield { html };
9059
9122
  }
9060
9123
  }
9061
- return documents;
9062
9124
  }
9125
+ var LATE_EVERY = 1024;
9063
9126
  function carriedLinkHosts(mail, {
9064
9127
  mails = [],
9065
9128
  attachments = [],
@@ -9072,40 +9135,45 @@ function carriedLinkHosts(mail, {
9072
9135
  const seenBare = /* @__PURE__ */ new Set();
9073
9136
  const seenLoose = /* @__PURE__ */ new Set();
9074
9137
  let more = false;
9075
- let tooComplex = false;
9076
- const done = () => more;
9077
- const looseDone = () => more || loose.size > maxHosts;
9138
+ let full = false;
9139
+ const domains = /* @__PURE__ */ new Set();
9140
+ const state = { tooComplex: false, incomplete: false };
9141
+ const until = Date.now() + MAX_READ_TIME;
9142
+ const late = () => {
9143
+ if (!state.incomplete && Date.now() > until) {
9144
+ state.incomplete = true;
9145
+ }
9146
+ return state.incomplete;
9147
+ };
9148
+ let calls = 0;
9149
+ const done = () => full || state.incomplete || ++calls % LATE_EVERY === 0 && late();
9150
+ const looseDone = () => done() || loose.size > maxHosts;
9078
9151
  const addLoose = (host) => {
9079
9152
  if (!looseDone()) {
9080
9153
  loose.add(host);
9081
9154
  }
9082
9155
  };
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 }));
9156
+ const queue = [mail, ...mails].map((document) => ({ document, depth: 0 }));
9157
+ const carried = documentsOf(attachments, state, late);
9095
9158
  let depth = 0;
9096
9159
  let embedded = 0;
9097
9160
  let embeddedLength = 0;
9161
+ const embeddedSeen = /* @__PURE__ */ new Set();
9098
9162
  const embed = (html) => {
9163
+ if (html === "" || embeddedSeen.has(html)) {
9164
+ return;
9165
+ }
9099
9166
  if (depth >= MAX_EMBED_DEPTH || embedded >= MAX_EMBEDDED || embeddedLength + html.length > MAX_EMBEDDED_LENGTH) {
9100
- tooComplex = true;
9167
+ state.tooComplex = true;
9101
9168
  return;
9102
9169
  }
9170
+ embeddedSeen.add(html);
9103
9171
  embedded++;
9104
9172
  embeddedLength += html.length;
9105
9173
  queue.push({ document: { html }, depth: depth + 1 });
9106
9174
  };
9107
9175
  const add = (value) => {
9108
- if (more || typeof value !== "string" || value === "" || seen.has(value)) {
9176
+ if (full || typeof value !== "string" || value === "" || seen.has(value)) {
9109
9177
  return;
9110
9178
  }
9111
9179
  if (seen.size < MAX_SEEN) {
@@ -9120,34 +9188,64 @@ function carriedLinkHosts(mail, {
9120
9188
  }
9121
9189
  const found = hostsOf(value);
9122
9190
  if (found.unresolved) {
9123
- tooComplex = true;
9191
+ state.tooComplex = true;
9124
9192
  }
9125
9193
  for (const host of found) {
9126
9194
  if (hosts.has(host)) {
9127
9195
  continue;
9128
9196
  }
9129
- if (hosts.size >= maxHosts) {
9130
- more = true;
9197
+ const domain = registrableDomain(host);
9198
+ if (hosts.size < maxHosts || !domains.has(domain) && domains.size < maxHosts) {
9199
+ hosts.add(host);
9200
+ domains.add(domain);
9201
+ continue;
9202
+ }
9203
+ more = true;
9204
+ if (domains.size >= maxHosts) {
9205
+ full = true;
9131
9206
  return;
9132
9207
  }
9133
- hosts.add(host);
9134
9208
  }
9135
9209
  };
9136
- const readText = (source, { cutStart = false, cutEnd = false } = {}) => {
9210
+ let spent = 0;
9211
+ const spend = (length) => {
9212
+ spent += length;
9213
+ if (spent < LONG_VALUE) {
9214
+ return false;
9215
+ }
9216
+ spent = 0;
9217
+ return late();
9218
+ };
9219
+ const reader = {
9220
+ add,
9221
+ embed,
9222
+ spend,
9223
+ cut() {
9224
+ state.tooComplex = true;
9225
+ }
9226
+ };
9227
+ const readText = (source, { cutStart = false, cutEnd = false, exact = false } = {}) => {
9137
9228
  for (const match of source.matchAll(URL_START)) {
9138
- if (more) {
9229
+ if (done()) {
9139
9230
  break;
9140
9231
  }
9141
9232
  if (cutStart && match.index === 0) {
9142
9233
  continue;
9143
9234
  }
9144
9235
  if (cutEnd && match.index + match[0].length === source.length) {
9236
+ if (exact && source.length - match.index < CHUNK_OVERLAP) {
9237
+ continue;
9238
+ }
9145
9239
  const value = match[0];
9146
9240
  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;
9241
+ if (!/[/?#\\]/.test(value.slice(at))) {
9242
+ state.tooComplex = true;
9243
+ continue;
9244
+ }
9245
+ add(value);
9246
+ const url = urlOf(value);
9247
+ if (url && isClickTracker(url)) {
9248
+ state.tooComplex = true;
9151
9249
  }
9152
9250
  continue;
9153
9251
  }
@@ -9169,42 +9267,60 @@ function carriedLinkHosts(mail, {
9169
9267
  });
9170
9268
  }
9171
9269
  };
9172
- for (const element of queue) {
9173
- if (more || late()) {
9270
+ for (let index = 0; ; index++) {
9271
+ if (full || late()) {
9174
9272
  break;
9175
9273
  }
9176
- ({ depth } = element);
9177
- const { document } = element;
9274
+ if (index >= queue.length) {
9275
+ const next = carried.next();
9276
+ if (next.done) {
9277
+ break;
9278
+ }
9279
+ queue.push({ document: next.value, depth: 0 });
9280
+ }
9281
+ ({ depth } = queue[index]);
9282
+ const { document } = queue[index];
9178
9283
  const html = withoutNul(typeof document?.html === "string" ? document.html : "");
9179
9284
  const text = withoutNul(typeof document?.text === "string" ? document.text : "");
9180
9285
  for (const [name, value] of attributesOf(html)) {
9181
- if (more) {
9286
+ if (done()) {
9182
9287
  break;
9183
9288
  }
9184
- if (LINK_ATTRIBUTES.has(name)) {
9185
- linksOf(name, value, add, embed);
9289
+ if (!LINK_ATTRIBUTES.has(name)) {
9290
+ continue;
9186
9291
  }
9292
+ if (spend(value.length)) {
9293
+ break;
9294
+ }
9295
+ linksOf(name, value, reader);
9187
9296
  }
9188
- lenientLinks(html, add, done, embed);
9297
+ lenientLinks(html, done, reader);
9189
9298
  for (const pattern of [CSS_URL, CSS_IMPORT]) {
9190
9299
  for (const [, link] of html.matchAll(pattern)) {
9191
- if (more) {
9300
+ if (done() || spend(link.length)) {
9192
9301
  break;
9193
9302
  }
9194
- add(decodeEntities(link, { attribute: true }));
9303
+ if (link.length > MAX_VALUE2) {
9304
+ state.tooComplex = true;
9305
+ }
9306
+ add(decodeEntities(link.slice(0, MAX_VALUE2), { attribute: true }));
9195
9307
  }
9196
9308
  }
9197
- if (!more) {
9198
- readText(text);
9309
+ for (const chunk2 of chunksOf(text)) {
9310
+ if (full || late()) {
9311
+ break;
9312
+ }
9313
+ readText(chunk2.text, { ...chunk2, exact: true });
9199
9314
  }
9200
9315
  for (const chunk2 of chunksOf(html)) {
9201
- if (more || late()) {
9316
+ if (full || late()) {
9202
9317
  break;
9203
9318
  }
9204
- readText(decodeEntities(chunk2.text), chunk2);
9319
+ const decoded = decodeEntities(chunk2.text);
9320
+ readText(decoded, { ...chunk2, exact: decoded.endsWith(chunk2.text.slice(-CHUNK_OVERLAP)) });
9205
9321
  }
9206
9322
  }
9207
- if (!more && !incomplete) {
9323
+ if (!more && !state.incomplete) {
9208
9324
  for (const name of loose) {
9209
9325
  if (hosts.size >= maxHosts) {
9210
9326
  break;
@@ -9218,8 +9334,8 @@ function carriedLinkHosts(mail, {
9218
9334
  return {
9219
9335
  hosts: [...hosts],
9220
9336
  more,
9221
- tooComplex,
9222
- incomplete
9337
+ tooComplex: state.tooComplex,
9338
+ incomplete: state.incomplete
9223
9339
  };
9224
9340
  }
9225
9341
 
@@ -10120,7 +10236,7 @@ var SpamScanner = class {
10120
10236
  // (`from`: the sender's domain; `carried`: the link hosts of the whole
10121
10237
  // message and of what it carries, see carriedLinkHosts)
10122
10238
  async checkLinks(links, config, hosts = [], options = {}) {
10123
- const { from = "", carried = null } = typeof options === "string" ? { from: options } : options;
10239
+ const { from = "", carried = null } = typeof options === "string" ? { from: options } : options ?? {};
10124
10240
  const phishing = [];
10125
10241
  const homographs = [];
10126
10242
  const seenHosts = /* @__PURE__ */ new Set();
@@ -10180,15 +10296,15 @@ var SpamScanner = class {
10180
10296
  for (const host of carriedHosts) {
10181
10297
  seenHosts.add(host);
10182
10298
  }
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])];
10299
+ const own = [...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean);
10184
10300
  const firsts = /* @__PURE__ */ new Map();
10185
- for (const host of ordered) {
10301
+ for (const host of [...own, ...carriedHosts]) {
10186
10302
  const domain = registrableDomain(host);
10187
10303
  if (!firsts.has(domain)) {
10188
10304
  firsts.set(domain, host);
10189
10305
  }
10190
10306
  }
10191
- const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...ordered])].slice(0, config.phishing.maxHosts);
10307
+ const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...own, ...carriedHosts])].slice(0, config.phishing.maxHosts);
10192
10308
  const domains = [...new Set(checked.map((host) => registrableDomain(host)))];
10193
10309
  const blocked = [];
10194
10310
  let answered = 0;
@@ -10925,17 +11041,18 @@ async function readInput(file, stdin, maxSize) {
10925
11041
  }
10926
11042
  return data;
10927
11043
  }
11044
+ var printable = (text) => String(text ?? "").replaceAll(/[\p{Cc}\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]+/gu, " ");
10928
11045
  function formatResult(result, verbose) {
10929
11046
  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
11047
  const tests = verbose ? result.tests : result.tests.filter((test) => test.score !== 0);
10931
11048
  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}`);
11049
+ lines2.push(` ${`${test.score > 0 ? "+" : ""}${test.score.toFixed(1)}`.padStart(7)} ${printable(test.name).padEnd(28)} ${printable(test.description)}`);
10933
11050
  }
10934
11051
  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}`));
11052
+ lines2.push(" Strongest clues:", ...result.results.classification.clues.slice(0, 10).map((clue) => ` ${clue.probability.toFixed(3)} ${printable(clue.feature)}`));
10936
11053
  }
10937
11054
  if (result.results.llm?.error) {
10938
- lines2.push(` Language model failed: ${result.results.llm.error}`);
11055
+ lines2.push(` Language model failed: ${printable(result.results.llm.error)}`);
10939
11056
  }
10940
11057
  return lines2.join("\n");
10941
11058
  }
@@ -11073,11 +11190,11 @@ var HANDLERS = {
11073
11190
  message = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam ? values["subject-tag"] ?? null : null });
11074
11191
  } catch (error) {
11075
11192
  if (!accept) {
11076
- stderr.write(`filter: scan failed, deferring: ${error.message}
11193
+ stderr.write(`filter: scan failed, deferring: ${printable(error.message)}
11077
11194
  `);
11078
11195
  return 75;
11079
11196
  }
11080
- stderr.write(`filter: scan failed, passing the message on: ${error.message}
11197
+ stderr.write(`filter: scan failed, passing the message on: ${printable(error.message)}
11081
11198
  `);
11082
11199
  result = null;
11083
11200
  message = rewriteMessage(raw, [["X-Spam-Virus-Scan", "failed"]]);
@@ -11110,10 +11227,10 @@ var HANDLERS = {
11110
11227
  hostname: values.name || "spamscanner",
11111
11228
  onError: onError(values)
11112
11229
  });
11113
- milter.on("error", (error) => stderr.write(`milter: ${error.message}
11230
+ milter.on("error", (error) => stderr.write(`milter: ${printable(error.message)}
11114
11231
  `));
11115
11232
  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(",")}
11233
+ 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
11234
  `));
11118
11235
  }
11119
11236
  await listen(milter.server, values, 7831, { stderr });