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.
@@ -1784,7 +1784,7 @@ var init_entities = __esm({
1784
1784
  ",itilde=129,iukcy=456,iuml=ef,jcirc=135,jcy=439,jfr=1d527,jmath=237,jopf=1d55b,jscr=1d4bf,jsercy=458",
1785
1785
  ",jukcy=454,kappa=3ba,kappav=3f0,kcedil=137,kcy=43a,kfr=1d528,kgreen=138,khcy=445,kjcy=45c,kopf=1d55c",
1786
1786
  ",kscr=1d4c0,lAarr=21da,lArr=21d0,lAtail=291b,lBarr=290e,lE=2266,lEg=2a8b,lHar=2962,lacute=13a,laempt",
1787
- "yv=29b4,lagran=2112,lambda=3bb,lang=2329,langd=2991,langle=27e8,lap=2a85,laquo=ab,larr=2190,larrb=21",
1787
+ "yv=29b4,lagran=2112,lambda=3bb,lang=27e8,langd=2991,langle=27e8,lap=2a85,laquo=ab,larr=2190,larrb=21",
1788
1788
  "e4,larrbfs=291f,larrfs=291d,larrhk=21a9,larrlp=21ab,larrpl=2939,larrsim=2973,larrtl=21a2,lat=2aab,la",
1789
1789
  "tail=2919,late=2aad,lates=2aad.fe00,lbarr=290c,lbbrk=2772,lbrace=7b,lbrack=5b,lbrke=298b,lbrksld=298",
1790
1790
  "f,lbrkslu=298d,lcaron=13e,lcedil=13c,lceil=2308,lcub=7b,lcy=43b,ldca=2936,ldquo=201c,ldquor=201e,ldr",
@@ -1842,7 +1842,7 @@ var init_entities = __esm({
1842
1842
  "line=2312,profsurf=2313,prop=221d,propto=221d,prsim=227e,prurel=22b0,pscr=1d4c5,psi=3c8,puncsp=2008,",
1843
1843
  "qfr=1d52e,qint=2a0c,qopf=1d562,qprime=2057,qscr=1d4c6,quaternions=210d,quatint=2a16,quest=3f,questeq",
1844
1844
  "=225f,quot=22,rAarr=21db,rArr=21d2,rAtail=291c,rBarr=290f,rHar=2964,race=223d.331,racute=155,radic=2",
1845
- "21a,raemptyv=29b3,rang=232a,rangd=2992,range=29a5,rangle=27e9,raquo=bb,rarr=2192,rarrap=2975,rarrb=2",
1845
+ "21a,raemptyv=29b3,rang=27e9,rangd=2992,range=29a5,rangle=27e9,raquo=bb,rarr=2192,rarrap=2975,rarrb=2",
1846
1846
  "1e5,rarrbfs=2920,rarrc=2933,rarrfs=291e,rarrhk=21aa,rarrlp=21ac,rarrpl=2945,rarrsim=2974,rarrtl=21a3",
1847
1847
  ",rarrw=219d,ratail=291a,ratio=2236,rationals=211a,rbarr=290d,rbbrk=2773,rbrace=7d,rbrack=5d,rbrke=29",
1848
1848
  "8c,rbrksld=298e,rbrkslu=2990,rcaron=159,rcedil=157,rceil=2309,rcub=7d,rcy=440,rdca=2937,rdldhar=2969",
@@ -2697,6 +2697,31 @@ function continuesName(text, index) {
2697
2697
  }
2698
2698
  return (text[index - 1] === "." || text[index - 1] === "-") && NAME_END.test(text.slice(Math.max(0, index - 3), index - 1));
2699
2699
  }
2700
+ function boundedUrl(address2) {
2701
+ const text = String(address2).replaceAll(/[\t\n\r]/g, "");
2702
+ const colon = text.indexOf(":");
2703
+ let start = colon === -1 ? 0 : colon + 1;
2704
+ while (start < text.length && (text[start] === "/" || text[start] === "\\")) {
2705
+ start++;
2706
+ }
2707
+ AUTHORITY_END.lastIndex = start;
2708
+ const end = AUTHORITY_END.exec(text)?.index ?? text.length;
2709
+ const file = /^[\0- ]*file:/i.test(text);
2710
+ const at = file ? start : Math.max(start, text.lastIndexOf("@", end - 1) + 1);
2711
+ const bracket = text.lastIndexOf("]", end - 1);
2712
+ const port = text.lastIndexOf(":", end - 1);
2713
+ let host = text.slice(at, !file && port > Math.max(at - 1, bracket) ? port : end);
2714
+ if (host.length > MAX_URL_HOST && host.includes("%")) {
2715
+ try {
2716
+ host = decodeURIComponent(host);
2717
+ } catch {
2718
+ }
2719
+ }
2720
+ if (host.length > MAX_URL_HOST && host.replaceAll(IGNORED_IN_HOST, "").length > MAX_URL_HOST) {
2721
+ throw new TypeError("Invalid URL: host too long");
2722
+ }
2723
+ return new URL(text);
2724
+ }
2700
2725
  function isClickTracker(url) {
2701
2726
  const host = url.hostname.toLowerCase();
2702
2727
  return CLICK_TRACKERS.some((domain) => isUnder(host, domain)) || host === "substack.com" && url.pathname.startsWith("/redirect/");
@@ -2723,7 +2748,7 @@ function gatewayTarget(url) {
2723
2748
  function unwrapLink(address2) {
2724
2749
  let url;
2725
2750
  try {
2726
- url = new URL(address2);
2751
+ url = boundedUrl(address2);
2727
2752
  } catch {
2728
2753
  return null;
2729
2754
  }
@@ -2734,7 +2759,7 @@ function unwrapLink(address2) {
2734
2759
  }
2735
2760
  let next;
2736
2761
  try {
2737
- next = new URL(target);
2762
+ next = boundedUrl(target);
2738
2763
  } catch {
2739
2764
  break;
2740
2765
  }
@@ -2996,7 +3021,7 @@ function extractLinks(text, html2, { maxLength = 5e5, bareHosts: bareHosts2 = fa
2996
3021
  }
2997
3022
  let url;
2998
3023
  try {
2999
- url = new URL(href);
3024
+ url = boundedUrl(href);
3000
3025
  } catch {
3001
3026
  return;
3002
3027
  }
@@ -3309,7 +3334,7 @@ function isDeceptiveLink(link, { from } = {}) {
3309
3334
  }
3310
3335
  let shownUrl;
3311
3336
  try {
3312
- shownUrl = new URL(/^https?:\/\//i.test(link.text) ? link.text : `http://${link.text}`);
3337
+ shownUrl = boundedUrl(/^https?:\/\//i.test(link.text) ? link.text : `http://${link.text}`);
3313
3338
  } catch {
3314
3339
  return false;
3315
3340
  }
@@ -3667,7 +3692,7 @@ ${body}`);
3667
3692
  hidden: visible.hidden
3668
3693
  };
3669
3694
  }
3670
- var import_tldts2, INVISIBLE, OBFUSCATING, JOINERS, INVISIBLE_OR_JOINER, ZERO_WIDTH_SPACE, ZERO_WIDTH_JOINERS, LATIN_END, LATIN_START, ZWSP_SCRIPTS, ZWSP_SCRIPT_END, ZWSP_SCRIPT_START, SOFT_HYPHEN, BIDI, RIGHT_TO_LEFT, LETTER_END, LETTER_START, DIGIT_END, DIGIT_START, STYLED_LETTERS, WIDE_LETTERS, CJK, LOOKALIKES, UNIT_LETTERS, UNIT_WORD, LEET, LEET_DIGITS, UNSPACED, URL_PATTERN, EMAIL_SOURCE, EMAIL_PATTERN, PHONE_PATTERN, IP_PATTERN, MONEY_PATTERN, BITCOIN_PATTERN, CARD_PATTERN, ANCHOR_START, MAX_LINK_TEXT, MAX_URL, SHOWN_URL, IMG_PIXEL, IMG, BARE_HOST, MAX_BARE_HOSTS, NAME_END, FILE_TLDS, LINK_FILE_TLDS, CLICK_TRACKERS, isUnder, MAX_GATEWAYS, BRAND_NAMES, SHORTENERS, FREEMAIL, segmenters, LATIN_LOOKALIKES, LATIN, CYRILLIC_OR_GREEK, INLINE_TAG, HOST_CHAR, MAX_SHOWN_HTML, IN_WORD, MAX_BARE_NAME, LINKED_BRANDS, SIGN_IN_PART, signInPath, SENDING_DOMAINS, ADDRESS_SOURCE, NAMED_ADDRESS, MIN_SHOWN_WORDS, PART_WORD;
3695
+ var import_tldts2, INVISIBLE, OBFUSCATING, JOINERS, INVISIBLE_OR_JOINER, ZERO_WIDTH_SPACE, ZERO_WIDTH_JOINERS, LATIN_END, LATIN_START, ZWSP_SCRIPTS, ZWSP_SCRIPT_END, ZWSP_SCRIPT_START, SOFT_HYPHEN, BIDI, RIGHT_TO_LEFT, LETTER_END, LETTER_START, DIGIT_END, DIGIT_START, STYLED_LETTERS, WIDE_LETTERS, CJK, LOOKALIKES, UNIT_LETTERS, UNIT_WORD, LEET, LEET_DIGITS, UNSPACED, URL_PATTERN, EMAIL_SOURCE, EMAIL_PATTERN, PHONE_PATTERN, IP_PATTERN, MONEY_PATTERN, BITCOIN_PATTERN, CARD_PATTERN, ANCHOR_START, MAX_LINK_TEXT, MAX_URL, SHOWN_URL, IMG_PIXEL, IMG, BARE_HOST, MAX_BARE_HOSTS, NAME_END, FILE_TLDS, LINK_FILE_TLDS, CLICK_TRACKERS, MAX_URL_HOST, AUTHORITY_END, IGNORED_IN_HOST, isUnder, MAX_GATEWAYS, BRAND_NAMES, SHORTENERS, FREEMAIL, segmenters, LATIN_LOOKALIKES, LATIN, CYRILLIC_OR_GREEK, INLINE_TAG, HOST_CHAR, MAX_SHOWN_HTML, IN_WORD, MAX_BARE_NAME, LINKED_BRANDS, SIGN_IN_PART, signInPath, SENDING_DOMAINS, ADDRESS_SOURCE, NAMED_ADDRESS, MIN_SHOWN_WORDS, PART_WORD;
3671
3696
  var init_tokenizer = __esm({
3672
3697
  "src/tokenizer.js"() {
3673
3698
  import_tldts2 = require("tldts");
@@ -3773,6 +3798,9 @@ var init_tokenizer = __esm({
3773
3798
  FILE_TLDS = /* @__PURE__ */ new Set(["ai", "app", "md", "mov", "pm", "ps", "py", "rs", "sh", "so", "zip"]);
3774
3799
  LINK_FILE_TLDS = /* @__PURE__ */ new Set(["md", "mov", "pm", "ps", "py", "rs", "sh", "so", "zip"]);
3775
3800
  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"];
3801
+ MAX_URL_HOST = 4096;
3802
+ AUTHORITY_END = /[/\\?#]/g;
3803
+ IGNORED_IN_HOST = /[\u00AD\u034F\u180B-\u180D\u180F\u200B\u2060\u2064\uFE00-\uFE0F\uFEFF]|\uD82F[\uDCA0-\uDCA3]|\uDB40[\uDD00-\uDDEF]/g;
3776
3804
  isUnder = (host, domain) => host === domain || host.endsWith(`.${domain}`);
3777
3805
  MAX_GATEWAYS = 16;
3778
3806
  BRAND_NAMES = new Set(BRANDS);
@@ -4612,7 +4640,7 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
4612
4640
  }
4613
4641
  }
4614
4642
  async function expandAttachments(attachments = [], errors = [], parse = parseMessage, gets = {}) {
4615
- const { unopened = [], mails = [] } = Array.isArray(gets) ? { unopened: gets } : gets;
4643
+ const { unopened = [], mails = [] } = Array.isArray(gets) ? { unopened: gets } : gets ?? {};
4616
4644
  const all = [];
4617
4645
  const deadline = Date.now() + PARSE_TIMEOUT;
4618
4646
  let budget = MAX_NESTED_BYTES;
@@ -4761,6 +4789,10 @@ var init_parse = __esm({
4761
4789
  });
4762
4790
 
4763
4791
  // src/dnsbl.js
4792
+ function lastLabels(name) {
4793
+ const separators = [...name.matchAll(LABEL_SEPARATOR)];
4794
+ return separators.length < 128 ? 0 : separators.at(-128).index + 1;
4795
+ }
4764
4796
  function lookupName(host) {
4765
4797
  if (typeof host !== "string") {
4766
4798
  return null;
@@ -4771,6 +4803,7 @@ function lookupName(host) {
4771
4803
  }
4772
4804
  let name = host.slice(0, end).toLowerCase();
4773
4805
  if (/[\u0080-\u{10FFFF}]/u.test(name)) {
4806
+ name = name.slice(lastLabels(name));
4774
4807
  name = (0, import_node_url2.domainToASCII)(name) || name;
4775
4808
  }
4776
4809
  if ((0, import_node_net.isIP)(name.replaceAll(/^\[|]$/g, "")) !== 0) {
@@ -4870,7 +4903,7 @@ function isListedAnswer(address2, zone) {
4870
4903
  }
4871
4904
  return true;
4872
4905
  }
4873
- var import_node_dns, import_node_net, import_node_url2, import_tldts3, LOOKUP_LABEL, CLOUDFLARE_MALWARE, CLOUDFLARE_FAMILY, DnsChecker;
4906
+ var import_node_dns, import_node_net, import_node_url2, import_tldts3, LOOKUP_LABEL, LABEL_SEPARATOR, CLOUDFLARE_MALWARE, CLOUDFLARE_FAMILY, DnsChecker;
4874
4907
  var init_dnsbl = __esm({
4875
4908
  "src/dnsbl.js"() {
4876
4909
  import_node_dns = __toESM(require("node:dns"), 1);
@@ -4878,6 +4911,7 @@ var init_dnsbl = __esm({
4878
4911
  import_node_url2 = require("node:url");
4879
4912
  import_tldts3 = require("tldts");
4880
4913
  LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
4914
+ LABEL_SEPARATOR = /[.\u3002\uFF0E\uFF61]/g;
4881
4915
  CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
4882
4916
  CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
4883
4917
  DnsChecker = class {
@@ -5117,7 +5151,7 @@ function asciiHostname(value) {
5117
5151
  return null;
5118
5152
  }
5119
5153
  const lower = value.normalize("NFKC").toLowerCase().replace(/\.$/, "");
5120
- if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
5154
+ if (lower === "" || lower.length > 1024 || /[\0-,/:-^`{-\u007F]/.test(lower)) {
5121
5155
  return null;
5122
5156
  }
5123
5157
  const ascii = (0, import_node_url3.domainToASCII)(lower);
@@ -5190,8 +5224,8 @@ function trustReputation(result, trusted = []) {
5190
5224
  for (const value of result.checkedValues.filter((item) => allowed.has(item))) {
5191
5225
  const signers = allowed.get(value);
5192
5226
  const counts = (entry) => Boolean(entry) && (signers === null || signers.some((signer) => vouches(normalizeEntry(entry), signer)));
5193
- const detail = result.details[value];
5194
- const listed = result.allowlisted[value] || (detail?.isAllowlisted ? detail.allowlistValue : null);
5227
+ const detail = Object.hasOwn(result.details, value) ? result.details[value] : void 0;
5228
+ const listed = Object.hasOwn(result.allowlisted, value) && result.allowlisted[value] || (detail?.isAllowlisted ? detail.allowlistValue : null);
5195
5229
  if (counts(listed) && !out.isAllowlisted) {
5196
5230
  out.isAllowlisted = true;
5197
5231
  out.allowlistValue = listed;
@@ -6234,7 +6268,7 @@ function urlOf(value) {
6234
6268
  return null;
6235
6269
  }
6236
6270
  try {
6237
- return new URL(href);
6271
+ return boundedUrl(href);
6238
6272
  } catch {
6239
6273
  return null;
6240
6274
  }
@@ -6324,7 +6358,23 @@ function* attributesOf(html2) {
6324
6358
  TAG_START.lastIndex = Math.max(i, match.index + 1);
6325
6359
  }
6326
6360
  }
6327
- function linksOf(name, value, add, embed) {
6361
+ function isCut(value) {
6362
+ if (value.length <= MAX_VALUE2) {
6363
+ return false;
6364
+ }
6365
+ let references = 0;
6366
+ for (let index = value.indexOf("&"); index !== -1; index = value.indexOf("&", index + 1)) {
6367
+ if (++references > MAX_REFERENCES) {
6368
+ return true;
6369
+ }
6370
+ }
6371
+ return false;
6372
+ }
6373
+ function linksOf(name, value, { add, embed, cut }) {
6374
+ if (isCut(value)) {
6375
+ cut();
6376
+ value = value.slice(0, MAX_VALUE2);
6377
+ }
6328
6378
  const decoded = decodeEntities(value, { attribute: true });
6329
6379
  switch (name) {
6330
6380
  case "srcdoc": {
@@ -6363,7 +6413,7 @@ function linksOf(name, value, add, embed) {
6363
6413
  }
6364
6414
  }
6365
6415
  }
6366
- function lenientLinks(html2, add, done, embed) {
6416
+ function lenientLinks(html2, done, reader) {
6367
6417
  LENIENT.lastIndex = 0;
6368
6418
  let match;
6369
6419
  while (!done() && (match = LENIENT.exec(html2)) !== null) {
@@ -6379,8 +6429,8 @@ function lenientLinks(html2, add, done, embed) {
6379
6429
  }
6380
6430
  if (end < html2.length && html2[end] !== quote) {
6381
6431
  const close = html2.indexOf(quote, end);
6382
- if (close !== -1 && close - i <= MAX_LENIENT_VALUE) {
6383
- linksOf(name, html2.slice(i, close), add, embed);
6432
+ if (close !== -1 && close - i <= MAX_LENIENT_VALUE && !reader.spend(close - i)) {
6433
+ linksOf(name, html2.slice(i, close), reader);
6384
6434
  }
6385
6435
  }
6386
6436
  } else {
@@ -6388,7 +6438,10 @@ function lenientLinks(html2, add, done, embed) {
6388
6438
  end++;
6389
6439
  }
6390
6440
  }
6391
- linksOf(name, html2.slice(i, end), add, embed);
6441
+ if (reader.spend(end - i)) {
6442
+ break;
6443
+ }
6444
+ linksOf(name, html2.slice(i, end), reader);
6392
6445
  }
6393
6446
  }
6394
6447
  function endsLink2(text, i) {
@@ -6559,8 +6612,17 @@ function* chunksOf(html2) {
6559
6612
  cutStart = false;
6560
6613
  continue;
6561
6614
  }
6562
- yield { text: html2.slice(start, end), cutStart, cutEnd: true };
6563
- start = end - CHUNK_OVERLAP;
6615
+ let hard = end;
6616
+ while (hard > start + CHUNK_OVERLAP + 1 && /[\dA-Za-z#]/.test(html2[hard - 1])) {
6617
+ hard--;
6618
+ }
6619
+ if (hard < end && html2[hard - 1] === "&" && hard - 1 > start + CHUNK_OVERLAP) {
6620
+ hard--;
6621
+ } else {
6622
+ hard = end;
6623
+ }
6624
+ yield { text: html2.slice(start, hard), cutStart, cutEnd: true };
6625
+ start = hard - CHUNK_OVERLAP;
6564
6626
  cutStart = true;
6565
6627
  continue;
6566
6628
  }
@@ -6774,40 +6836,50 @@ function htmlDescriptions(text) {
6774
6836
  }
6775
6837
  return html2.join("\n");
6776
6838
  }
6777
- function withoutBinaryValues(text) {
6839
+ function withoutBinaryValues(text, limit = Number.POSITIVE_INFINITY) {
6778
6840
  if (!/base64|binary|encoding\s*=\s*"?b|;\s*b\s*[;:]/i.test(text)) {
6779
6841
  return text;
6780
6842
  }
6781
6843
  const lines2 = text.split(LINE_BREAK);
6782
6844
  const out = [];
6845
+ let length = 0;
6846
+ const push = (line) => {
6847
+ out.push(line);
6848
+ length += line.length + 1;
6849
+ };
6783
6850
  for (let i = 0; i < lines2.length; i++) {
6851
+ if (length > limit) {
6852
+ break;
6853
+ }
6784
6854
  const line = lines2[i];
6785
6855
  const colon = valueColon(line);
6786
6856
  if (colon === -1 || !BINARY.test(line.slice(0, colon))) {
6787
- out.push(line);
6857
+ push(line);
6788
6858
  continue;
6789
6859
  }
6790
- const value = [line.slice(colon + 1)];
6860
+ let base64 = /^[\w+/=\s]*$/.test(line.slice(colon + 1));
6791
6861
  let next = i + 1;
6792
6862
  while (next < lines2.length && (lines2[next].startsWith(" ") || lines2[next].startsWith(" "))) {
6793
- value.push(lines2[next++]);
6863
+ base64 &&= /^[\w+/=\s]*$/.test(lines2[next]);
6864
+ next++;
6794
6865
  }
6795
- if (!value.every((part) => /^[\w+/=\s]*$/.test(part))) {
6866
+ if (!base64) {
6796
6867
  for (let index = i; index < next; index++) {
6797
- out.push(lines2[index]);
6868
+ push(lines2[index]);
6798
6869
  }
6799
6870
  i = next - 1;
6800
6871
  continue;
6801
6872
  }
6802
6873
  while (next < lines2.length && lines2[next] !== "" && /^[\w+/=]+$/.test(lines2[next])) {
6803
- value.push(lines2[next++]);
6874
+ next++;
6804
6875
  }
6876
+ const end = next;
6805
6877
  if (next < lines2.length && lines2[next] === "") {
6806
6878
  next++;
6807
6879
  }
6808
- i = next - 1;
6809
6880
  const name = line.slice(0, colon).split(";")[0].split(".").pop().trim().toLowerCase();
6810
- out.push(MEDIA_PROPERTIES.has(name) ? line.slice(0, colon + 1) : line.slice(0, colon + 1) + import_node_buffer7.Buffer.from(value.join("").replaceAll(/\s/g, ""), "base64").toString("utf8"));
6881
+ push(MEDIA_PROPERTIES.has(name) ? line.slice(0, colon + 1) : line.slice(0, colon + 1) + import_node_buffer7.Buffer.from([line.slice(colon + 1), ...lines2.slice(i + 1, end)].join("").replaceAll(/\s/g, ""), "base64").toString("utf8"));
6882
+ i = next - 1;
6811
6883
  }
6812
6884
  return out.join("\n");
6813
6885
  }
@@ -6841,58 +6913,75 @@ function charsetOf(attachment) {
6841
6913
  const charset = headers && typeof headers.get === "function" ? headers.get("content-type")?.params?.charset : null;
6842
6914
  return typeof charset === "string" ? charset.toLowerCase() : "";
6843
6915
  }
6844
- function documentsOf(attachments, until) {
6845
- const documents = [];
6916
+ function kindOf(attachment) {
6917
+ const { content } = attachment ?? {};
6918
+ if (!import_node_buffer7.Buffer.isBuffer(content) || content.length === 0) {
6919
+ return null;
6920
+ }
6921
+ const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
6922
+ const extensions = extensionsOf(attachment);
6923
+ const has = (set) => [...extensions].some((name) => set.has(name));
6924
+ if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
6925
+ return null;
6926
+ }
6927
+ const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
6928
+ const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
6929
+ const isText = isCard || !isHtml && type === "text/plain";
6930
+ return isHtml || isText ? { isHtml, isText, isCard } : null;
6931
+ }
6932
+ function* documentsOf(attachments, state, late) {
6933
+ const read = [];
6846
6934
  let bytes = 0;
6847
6935
  let opened = 0;
6848
6936
  for (const attachment of attachments) {
6849
- const { content } = attachment ?? {};
6850
- if (!import_node_buffer7.Buffer.isBuffer(content) || content.length === 0) {
6851
- continue;
6852
- }
6853
- const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
6854
- const extensions = extensionsOf(attachment);
6855
- const has = (set) => [...extensions].some((name) => set.has(name));
6856
- if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
6857
- continue;
6858
- }
6859
- const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
6860
- const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
6861
- const isText = isCard || !isHtml && type === "text/plain";
6862
- if (!isHtml && !isText) {
6937
+ const kind = kindOf(attachment);
6938
+ if (!kind) {
6863
6939
  continue;
6864
6940
  }
6865
- if (!isText && ++opened > MAX_ATTACHED || bytes + content.length > MAX_ATTACHED_BYTES) {
6866
- documents.tooComplex = true;
6867
- return documents;
6941
+ if (!kind.isText && ++opened > MAX_ATTACHED || bytes + attachment.content.length > MAX_ATTACHED_BYTES) {
6942
+ state.tooComplex = true;
6943
+ break;
6868
6944
  }
6869
- if (Date.now() > until) {
6870
- documents.incomplete = true;
6871
- return documents;
6945
+ bytes += attachment.content.length;
6946
+ read.push({ attachment, kind });
6947
+ }
6948
+ for (const { attachment, kind: { isHtml, isCard } } of read) {
6949
+ if (late()) {
6950
+ state.incomplete = true;
6951
+ return;
6872
6952
  }
6873
- bytes += content.length;
6874
- const text = decodeText(content, charsetOf(attachment));
6953
+ const text = decodeText(attachment.content, charsetOf(attachment));
6875
6954
  if (isHtml) {
6876
- documents.push({ html: text });
6955
+ yield { html: text };
6877
6956
  continue;
6878
6957
  }
6879
6958
  if (!isCard) {
6880
- documents.push({ text });
6959
+ yield { text };
6881
6960
  continue;
6882
6961
  }
6883
- const readable = withoutBinaryValues(text);
6962
+ let readable = withoutBinaryValues(text, MAX_CARD_TEXT);
6963
+ if (readable.length > MAX_CARD_TEXT) {
6964
+ state.tooComplex = true;
6965
+ readable = readable.slice(0, MAX_CARD_TEXT);
6966
+ }
6884
6967
  const full = readable.length <= MAX_SHOWN_BYTES;
6885
6968
  if (!full && hasQuotedPrintable(readable)) {
6886
- documents.tooComplex = true;
6887
- continue;
6969
+ state.tooComplex = true;
6970
+ }
6971
+ if (late()) {
6972
+ state.incomplete = true;
6973
+ return;
6974
+ }
6975
+ yield { text: shownText(readable, full) };
6976
+ if (late()) {
6977
+ state.incomplete = true;
6978
+ return;
6888
6979
  }
6889
- documents.push({ text: shownText(readable, full) });
6890
6980
  const html2 = htmlDescriptions(readable);
6891
6981
  if (html2 !== "") {
6892
- documents.push({ html: html2 });
6982
+ yield { html: html2 };
6893
6983
  }
6894
6984
  }
6895
- return documents;
6896
6985
  }
6897
6986
  function carriedLinkHosts(mail, {
6898
6987
  mails = [],
@@ -6906,40 +6995,45 @@ function carriedLinkHosts(mail, {
6906
6995
  const seenBare = /* @__PURE__ */ new Set();
6907
6996
  const seenLoose = /* @__PURE__ */ new Set();
6908
6997
  let more = false;
6909
- let tooComplex = false;
6910
- const done = () => more;
6911
- const looseDone = () => more || loose.size > maxHosts;
6998
+ let full = false;
6999
+ const domains = /* @__PURE__ */ new Set();
7000
+ const state = { tooComplex: false, incomplete: false };
7001
+ const until = Date.now() + MAX_READ_TIME;
7002
+ const late = () => {
7003
+ if (!state.incomplete && Date.now() > until) {
7004
+ state.incomplete = true;
7005
+ }
7006
+ return state.incomplete;
7007
+ };
7008
+ let calls = 0;
7009
+ const done = () => full || state.incomplete || ++calls % LATE_EVERY === 0 && late();
7010
+ const looseDone = () => done() || loose.size > maxHosts;
6912
7011
  const addLoose = (host) => {
6913
7012
  if (!looseDone()) {
6914
7013
  loose.add(host);
6915
7014
  }
6916
7015
  };
6917
- const until = Date.now() + MAX_READ_TIME;
6918
- let incomplete = false;
6919
- const late = () => {
6920
- if (!incomplete && Date.now() > until) {
6921
- incomplete = true;
6922
- }
6923
- return incomplete;
6924
- };
6925
- const carried = documentsOf(attachments, until);
6926
- tooComplex = Boolean(carried.tooComplex);
6927
- incomplete = Boolean(carried.incomplete);
6928
- const queue = [mail, ...mails, ...carried].map((document) => ({ document, depth: 0 }));
7016
+ const queue = [mail, ...mails].map((document) => ({ document, depth: 0 }));
7017
+ const carried = documentsOf(attachments, state, late);
6929
7018
  let depth = 0;
6930
7019
  let embedded = 0;
6931
7020
  let embeddedLength = 0;
7021
+ const embeddedSeen = /* @__PURE__ */ new Set();
6932
7022
  const embed = (html2) => {
7023
+ if (html2 === "" || embeddedSeen.has(html2)) {
7024
+ return;
7025
+ }
6933
7026
  if (depth >= MAX_EMBED_DEPTH || embedded >= MAX_EMBEDDED || embeddedLength + html2.length > MAX_EMBEDDED_LENGTH) {
6934
- tooComplex = true;
7027
+ state.tooComplex = true;
6935
7028
  return;
6936
7029
  }
7030
+ embeddedSeen.add(html2);
6937
7031
  embedded++;
6938
7032
  embeddedLength += html2.length;
6939
7033
  queue.push({ document: { html: html2 }, depth: depth + 1 });
6940
7034
  };
6941
7035
  const add = (value) => {
6942
- if (more || typeof value !== "string" || value === "" || seen.has(value)) {
7036
+ if (full || typeof value !== "string" || value === "" || seen.has(value)) {
6943
7037
  return;
6944
7038
  }
6945
7039
  if (seen.size < MAX_SEEN) {
@@ -6954,34 +7048,64 @@ function carriedLinkHosts(mail, {
6954
7048
  }
6955
7049
  const found = hostsOf(value);
6956
7050
  if (found.unresolved) {
6957
- tooComplex = true;
7051
+ state.tooComplex = true;
6958
7052
  }
6959
7053
  for (const host of found) {
6960
7054
  if (hosts.has(host)) {
6961
7055
  continue;
6962
7056
  }
6963
- if (hosts.size >= maxHosts) {
6964
- more = true;
7057
+ const domain = registrableDomain(host);
7058
+ if (hosts.size < maxHosts || !domains.has(domain) && domains.size < maxHosts) {
7059
+ hosts.add(host);
7060
+ domains.add(domain);
7061
+ continue;
7062
+ }
7063
+ more = true;
7064
+ if (domains.size >= maxHosts) {
7065
+ full = true;
6965
7066
  return;
6966
7067
  }
6967
- hosts.add(host);
6968
7068
  }
6969
7069
  };
6970
- const readText = (source, { cutStart = false, cutEnd = false } = {}) => {
7070
+ let spent = 0;
7071
+ const spend = (length) => {
7072
+ spent += length;
7073
+ if (spent < LONG_VALUE) {
7074
+ return false;
7075
+ }
7076
+ spent = 0;
7077
+ return late();
7078
+ };
7079
+ const reader = {
7080
+ add,
7081
+ embed,
7082
+ spend,
7083
+ cut() {
7084
+ state.tooComplex = true;
7085
+ }
7086
+ };
7087
+ const readText = (source, { cutStart = false, cutEnd = false, exact = false } = {}) => {
6971
7088
  for (const match of source.matchAll(URL_START)) {
6972
- if (more) {
7089
+ if (done()) {
6973
7090
  break;
6974
7091
  }
6975
7092
  if (cutStart && match.index === 0) {
6976
7093
  continue;
6977
7094
  }
6978
7095
  if (cutEnd && match.index + match[0].length === source.length) {
7096
+ if (exact && source.length - match.index < CHUNK_OVERLAP) {
7097
+ continue;
7098
+ }
6979
7099
  const value = match[0];
6980
7100
  const at = /^www\./i.test(value) ? 0 : value.indexOf("//") + 2;
6981
- if (/[/?#\\]/.test(value.slice(at))) {
6982
- add(value);
6983
- } else if (source.length - match.index > CHUNK_OVERLAP) {
6984
- tooComplex = true;
7101
+ if (!/[/?#\\]/.test(value.slice(at))) {
7102
+ state.tooComplex = true;
7103
+ continue;
7104
+ }
7105
+ add(value);
7106
+ const url = urlOf(value);
7107
+ if (url && isClickTracker(url)) {
7108
+ state.tooComplex = true;
6985
7109
  }
6986
7110
  continue;
6987
7111
  }
@@ -7003,42 +7127,61 @@ function carriedLinkHosts(mail, {
7003
7127
  });
7004
7128
  }
7005
7129
  };
7006
- for (const element of queue) {
7007
- if (more || late()) {
7130
+ for (let index = 0; ; index++) {
7131
+ if (full || late()) {
7008
7132
  break;
7009
7133
  }
7010
- ({ depth } = element);
7011
- const { document } = element;
7134
+ if (index >= queue.length) {
7135
+ const next = carried.next();
7136
+ if (next.done) {
7137
+ break;
7138
+ }
7139
+ queue.push({ document: next.value, depth: 0 });
7140
+ }
7141
+ ({ depth } = queue[index]);
7142
+ const { document } = queue[index];
7012
7143
  const html2 = withoutNul(typeof document?.html === "string" ? document.html : "");
7013
7144
  const text = withoutNul(typeof document?.text === "string" ? document.text : "");
7014
7145
  for (const [name, value] of attributesOf(html2)) {
7015
- if (more) {
7146
+ if (done()) {
7016
7147
  break;
7017
7148
  }
7018
- if (LINK_ATTRIBUTES.has(name)) {
7019
- linksOf(name, value, add, embed);
7149
+ if (!LINK_ATTRIBUTES.has(name)) {
7150
+ continue;
7151
+ }
7152
+ if (spend(value.length)) {
7153
+ break;
7020
7154
  }
7155
+ linksOf(name, value, reader);
7021
7156
  }
7022
- lenientLinks(html2, add, done, embed);
7157
+ lenientLinks(html2, done, reader);
7023
7158
  for (const pattern of [CSS_URL, CSS_IMPORT]) {
7024
7159
  for (const [, link] of html2.matchAll(pattern)) {
7025
- if (more) {
7160
+ if (done() || spend(link.length)) {
7026
7161
  break;
7027
7162
  }
7028
- add(decodeEntities(link, { attribute: true }));
7163
+ const cut = isCut(link);
7164
+ if (cut) {
7165
+ state.tooComplex = true;
7166
+ }
7167
+ add(decodeEntities(cut ? link.slice(0, MAX_VALUE2) : link, { attribute: true }));
7029
7168
  }
7030
7169
  }
7031
- if (!more) {
7032
- readText(text);
7170
+ for (const chunk2 of chunksOf(text)) {
7171
+ if (full || late()) {
7172
+ break;
7173
+ }
7174
+ readText(chunk2.text, { ...chunk2, exact: true });
7033
7175
  }
7034
7176
  for (const chunk2 of chunksOf(html2)) {
7035
- if (more || late()) {
7177
+ if (full || late()) {
7036
7178
  break;
7037
7179
  }
7038
- readText(decodeEntities(chunk2.text), chunk2);
7180
+ const decoded = decodeEntities(chunk2.text);
7181
+ readText(decoded, { ...chunk2, exact: decoded.endsWith(chunk2.text.slice(-CHUNK_OVERLAP)) });
7039
7182
  }
7040
7183
  }
7041
- if (!more && !incomplete) {
7184
+ if (!more && !state.incomplete) {
7042
7185
  for (const name of loose) {
7043
7186
  if (hosts.size >= maxHosts) {
7044
7187
  break;
@@ -7052,11 +7195,11 @@ function carriedLinkHosts(mail, {
7052
7195
  return {
7053
7196
  hosts: [...hosts],
7054
7197
  more,
7055
- tooComplex,
7056
- incomplete
7198
+ tooComplex: state.tooComplex,
7199
+ incomplete: state.incomplete
7057
7200
  };
7058
7201
  }
7059
- var import_node_buffer7, import_node_net4, import_tldts4, LINK_ATTRIBUTES, TAG_START, URL_START, CSS_URL, CSS_IMPORT, LENIENT, IN_WORD2, NAMESPACES, MAX_HOST, MAX_SEEN, TRAILING, DECODE_CHUNK, CHUNK_OVERLAP, MAX_EMBED_DEPTH, MAX_EMBEDDED, MAX_EMBEDDED_LENGTH, MAX_ATTACHED, MAX_ATTACHED_BYTES, MAX_READ_TIME, MAX_SHOWN_BYTES, MAX_LENIENT_VALUE, UNQUOTED_END, UTF16LE, UTF16BE, DATA_DOCUMENT, LINE_BREAK, FOLD, QUOTED_PRINTABLE, MEDIA_PROPERTIES, HTML_PROPERTIES, BINARY, HTML_TYPES, HTML_EXTENSIONS, CARD_TYPES, CARD_EXTENSIONS, MESSAGE_TYPES;
7202
+ var import_node_buffer7, import_node_net4, import_tldts4, LINK_ATTRIBUTES, TAG_START, URL_START, CSS_URL, CSS_IMPORT, LENIENT, IN_WORD2, NAMESPACES, MAX_HOST, MAX_SEEN, TRAILING, DECODE_CHUNK, CHUNK_OVERLAP, MAX_EMBED_DEPTH, MAX_EMBEDDED, MAX_EMBEDDED_LENGTH, MAX_ATTACHED, MAX_ATTACHED_BYTES, MAX_READ_TIME, MAX_SHOWN_BYTES, MAX_CARD_TEXT, MAX_VALUE2, MAX_REFERENCES, LONG_VALUE, MAX_LENIENT_VALUE, UNQUOTED_END, UTF16LE, UTF16BE, DATA_DOCUMENT, LINE_BREAK, FOLD, QUOTED_PRINTABLE, MEDIA_PROPERTIES, HTML_PROPERTIES, BINARY, HTML_TYPES, HTML_EXTENSIONS, CARD_TYPES, CARD_EXTENSIONS, MESSAGE_TYPES, LATE_EVERY;
7060
7203
  var init_links = __esm({
7061
7204
  "src/links.js"() {
7062
7205
  import_node_buffer7 = require("node:buffer");
@@ -7103,7 +7246,11 @@ var init_links = __esm({
7103
7246
  MAX_ATTACHED = 100;
7104
7247
  MAX_ATTACHED_BYTES = 50 * 1024 * 1024;
7105
7248
  MAX_READ_TIME = 5e3;
7106
- MAX_SHOWN_BYTES = 4 * 1024 * 1024;
7249
+ MAX_SHOWN_BYTES = 1024 * 1024;
7250
+ MAX_CARD_TEXT = 8 * 1024 * 1024;
7251
+ MAX_VALUE2 = 1024 * 1024;
7252
+ MAX_REFERENCES = 64 * 1024;
7253
+ LONG_VALUE = 64 * 1024;
7107
7254
  MAX_LENIENT_VALUE = 4096;
7108
7255
  UNQUOTED_END = /* @__PURE__ */ new Set(["<", ">", '"', "'", "`", "="]);
7109
7256
  UTF16LE = /* @__PURE__ */ new Set(["csunicode", "iso-10646-ucs-2", "ucs-2", "unicode", "unicodefeff", "utf-16", "utf-16le"]);
@@ -7120,6 +7267,7 @@ var init_links = __esm({
7120
7267
  CARD_TYPES = /* @__PURE__ */ new Set(["text/calendar", "text/x-vcalendar", "application/ics", "text/vcard", "text/x-vcard", "text/directory"]);
7121
7268
  CARD_EXTENSIONS = /* @__PURE__ */ new Set(["ics", "ical", "ifb", "vcs", "vcf", "vcard"]);
7122
7269
  MESSAGE_TYPES = /* @__PURE__ */ new Set(["message/rfc822", "message/global"]);
7270
+ LATE_EVERY = 1024;
7123
7271
  }
7124
7272
  });
7125
7273
 
@@ -8784,7 +8932,7 @@ var init_package = __esm({
8784
8932
  package_default = {
8785
8933
  name: "spamscanner",
8786
8934
  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.",
8787
- version: "7.0.11",
8935
+ version: "7.0.13",
8788
8936
  author: "Forward Email (https://forwardemail.net)",
8789
8937
  bin: {
8790
8938
  spamscanner: "./dist/esm/cli.js"
@@ -9050,9 +9198,12 @@ function rewriteMessage(source, headers = [], options = {}) {
9050
9198
  }
9051
9199
  }
9052
9200
  const added = headers.map(([name, value]) => foldHeader(name, import_node_buffer9.Buffer.from(String(value), "utf8").toString("latin1"), newline));
9053
- const mbox = kept.length > 0 && kept[0][0].startsWith("From ") ? kept.shift() : [];
9201
+ const mbox = kept.length > 0 && kept[0][0].startsWith("From ") ? kept.shift().slice(0, 1) : [];
9202
+ if (kept.length > 0 && /^[ \t]/.test(kept[0][0])) {
9203
+ kept.shift();
9204
+ }
9054
9205
  const head = [...mbox, ...added, ...kept.map((field) => field.join(newline))].join(newline);
9055
- return import_node_buffer9.Buffer.concat([import_node_buffer9.Buffer.from(head === "" && header === "" ? newline : `${head}${newline}${newline}`, "latin1"), body]);
9206
+ return import_node_buffer9.Buffer.concat([import_node_buffer9.Buffer.from(head === "" ? newline : `${head}${newline}${newline}`, "latin1"), body]);
9056
9207
  }
9057
9208
  var import_node_buffer9, ENCODED_BYTES, PLAIN_WORD, ENCODED_LINE, MAX_LINE2, ENCODED_WORD, ENCODED_START;
9058
9209
  var init_headers = __esm({
@@ -9414,7 +9565,12 @@ var init_train = __esm({
9414
9565
 
9415
9566
  // src/server.js
9416
9567
  function serializeResult(result, options = {}) {
9417
- const { mail, tokens, ...rest } = result;
9568
+ const {
9569
+ mail,
9570
+ tokens,
9571
+ attached,
9572
+ ...rest
9573
+ } = result;
9418
9574
  const output = {
9419
9575
  ...rest,
9420
9576
  results: { ...result.results }
@@ -10099,7 +10255,7 @@ function report(result) {
10099
10255
  "",
10100
10256
  " pts rule name description",
10101
10257
  "---- ---------------------- --------------------------------------------------",
10102
- ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${test.name.padEnd(22)} ${test.description}`)
10258
+ ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${oneLine2(test.name).padEnd(22)} ${oneLine2(test.description)}`)
10103
10259
  ];
10104
10260
  return `${lines2.join("\r\n")}\r
10105
10261
  `;
@@ -10245,7 +10401,7 @@ function createSpamdServer(scanner, options = {}) {
10245
10401
  server.maxConnections = settings.maxConnections;
10246
10402
  return server;
10247
10403
  }
10248
- var import_node_buffer13, import_node_net7, EX_OK, EX_USAGE, EX_DATAERR, EX_SOFTWARE, EX_PROTOCOL, EX_NOPERM, COMMANDS2;
10404
+ var import_node_buffer13, import_node_net7, EX_OK, EX_USAGE, EX_DATAERR, EX_SOFTWARE, EX_PROTOCOL, EX_NOPERM, COMMANDS2, oneLine2;
10249
10405
  var init_spamd = __esm({
10250
10406
  "src/spamd.js"() {
10251
10407
  import_node_buffer13 = require("node:buffer");
@@ -10260,6 +10416,7 @@ var init_spamd = __esm({
10260
10416
  EX_PROTOCOL = 76;
10261
10417
  EX_NOPERM = 77;
10262
10418
  COMMANDS2 = /* @__PURE__ */ new Set(["CHECK", "SYMBOLS", "REPORT", "REPORT_IFSPAM", "PROCESS", "HEADERS", "PING", "SKIP", "TELL"]);
10419
+ oneLine2 = (text) => String(text ?? "").replaceAll(/[\p{Cc}\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]+/gu, " ");
10263
10420
  }
10264
10421
  });
10265
10422
 
@@ -10824,7 +10981,7 @@ var init_src = __esm({
10824
10981
  // (`from`: the sender's domain; `carried`: the link hosts of the whole
10825
10982
  // message and of what it carries, see carriedLinkHosts)
10826
10983
  async checkLinks(links, config, hosts = [], options = {}) {
10827
- const { from = "", carried = null } = typeof options === "string" ? { from: options } : options;
10984
+ const { from = "", carried = null } = typeof options === "string" ? { from: options } : options ?? {};
10828
10985
  const phishing = [];
10829
10986
  const homographs = [];
10830
10987
  const seenHosts = /* @__PURE__ */ new Set();
@@ -10884,15 +11041,15 @@ var init_src = __esm({
10884
11041
  for (const host of carriedHosts) {
10885
11042
  seenHosts.add(host);
10886
11043
  }
10887
- const ordered = [.../* @__PURE__ */ new Set([...[...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean), ...carriedHosts])];
11044
+ const own3 = [...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean);
10888
11045
  const firsts = /* @__PURE__ */ new Map();
10889
- for (const host of ordered) {
11046
+ for (const host of [...own3, ...carriedHosts]) {
10890
11047
  const domain = registrableDomain(host);
10891
11048
  if (!firsts.has(domain)) {
10892
11049
  firsts.set(domain, host);
10893
11050
  }
10894
11051
  }
10895
- const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...ordered])].slice(0, config.phishing.maxHosts);
11052
+ const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...own3, ...carriedHosts])].slice(0, config.phishing.maxHosts);
10896
11053
  const domains = [...new Set(checked.map((host) => registrableDomain(host)))];
10897
11054
  const blocked = [];
10898
11055
  let answered = 0;