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/index.js CHANGED
@@ -1264,7 +1264,7 @@ var NAMED = [
1264
1264
  ",itilde=129,iukcy=456,iuml=ef,jcirc=135,jcy=439,jfr=1d527,jmath=237,jopf=1d55b,jscr=1d4bf,jsercy=458",
1265
1265
  ",jukcy=454,kappa=3ba,kappav=3f0,kcedil=137,kcy=43a,kfr=1d528,kgreen=138,khcy=445,kjcy=45c,kopf=1d55c",
1266
1266
  ",kscr=1d4c0,lAarr=21da,lArr=21d0,lAtail=291b,lBarr=290e,lE=2266,lEg=2a8b,lHar=2962,lacute=13a,laempt",
1267
- "yv=29b4,lagran=2112,lambda=3bb,lang=2329,langd=2991,langle=27e8,lap=2a85,laquo=ab,larr=2190,larrb=21",
1267
+ "yv=29b4,lagran=2112,lambda=3bb,lang=27e8,langd=2991,langle=27e8,lap=2a85,laquo=ab,larr=2190,larrb=21",
1268
1268
  "e4,larrbfs=291f,larrfs=291d,larrhk=21a9,larrlp=21ab,larrpl=2939,larrsim=2973,larrtl=21a2,lat=2aab,la",
1269
1269
  "tail=2919,late=2aad,lates=2aad.fe00,lbarr=290c,lbbrk=2772,lbrace=7b,lbrack=5b,lbrke=298b,lbrksld=298",
1270
1270
  "f,lbrkslu=298d,lcaron=13e,lcedil=13c,lceil=2308,lcub=7b,lcy=43b,ldca=2936,ldquo=201c,ldquor=201e,ldr",
@@ -1322,7 +1322,7 @@ var NAMED = [
1322
1322
  "line=2312,profsurf=2313,prop=221d,propto=221d,prsim=227e,prurel=22b0,pscr=1d4c5,psi=3c8,puncsp=2008,",
1323
1323
  "qfr=1d52e,qint=2a0c,qopf=1d562,qprime=2057,qscr=1d4c6,quaternions=210d,quatint=2a16,quest=3f,questeq",
1324
1324
  "=225f,quot=22,rAarr=21db,rArr=21d2,rAtail=291c,rBarr=290f,rHar=2964,race=223d.331,racute=155,radic=2",
1325
- "21a,raemptyv=29b3,rang=232a,rangd=2992,range=29a5,rangle=27e9,raquo=bb,rarr=2192,rarrap=2975,rarrb=2",
1325
+ "21a,raemptyv=29b3,rang=27e9,rangd=2992,range=29a5,rangle=27e9,raquo=bb,rarr=2192,rarrap=2975,rarrb=2",
1326
1326
  "1e5,rarrbfs=2920,rarrc=2933,rarrfs=291e,rarrhk=21aa,rarrlp=21ac,rarrpl=2945,rarrsim=2974,rarrtl=21a3",
1327
1327
  ",rarrw=219d,ratail=291a,ratio=2236,rationals=211a,rbarr=290d,rbbrk=2773,rbrace=7d,rbrack=5d,rbrke=29",
1328
1328
  "8c,rbrksld=298e,rbrkslu=2990,rcaron=159,rcedil=157,rceil=2309,rcub=7d,rcy=440,rdca=2937,rdldhar=2969",
@@ -2265,6 +2265,34 @@ function continuesName(text, index) {
2265
2265
  var FILE_TLDS = /* @__PURE__ */ new Set(["ai", "app", "md", "mov", "pm", "ps", "py", "rs", "sh", "so", "zip"]);
2266
2266
  var LINK_FILE_TLDS = /* @__PURE__ */ new Set(["md", "mov", "pm", "ps", "py", "rs", "sh", "so", "zip"]);
2267
2267
  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"];
2268
+ var MAX_URL_HOST = 4096;
2269
+ var AUTHORITY_END = /[/\\?#]/g;
2270
+ var IGNORED_IN_HOST = /[\u00AD\u034F\u180B-\u180D\u180F\u200B\u2060\u2064\uFE00-\uFE0F\uFEFF]|\uD82F[\uDCA0-\uDCA3]|\uDB40[\uDD00-\uDDEF]/g;
2271
+ function boundedUrl(address2) {
2272
+ const text = String(address2).replaceAll(/[\t\n\r]/g, "");
2273
+ const colon = text.indexOf(":");
2274
+ let start = colon === -1 ? 0 : colon + 1;
2275
+ while (start < text.length && (text[start] === "/" || text[start] === "\\")) {
2276
+ start++;
2277
+ }
2278
+ AUTHORITY_END.lastIndex = start;
2279
+ const end = AUTHORITY_END.exec(text)?.index ?? text.length;
2280
+ const file = /^[\0- ]*file:/i.test(text);
2281
+ const at = file ? start : Math.max(start, text.lastIndexOf("@", end - 1) + 1);
2282
+ const bracket = text.lastIndexOf("]", end - 1);
2283
+ const port = text.lastIndexOf(":", end - 1);
2284
+ let host = text.slice(at, !file && port > Math.max(at - 1, bracket) ? port : end);
2285
+ if (host.length > MAX_URL_HOST && host.includes("%")) {
2286
+ try {
2287
+ host = decodeURIComponent(host);
2288
+ } catch {
2289
+ }
2290
+ }
2291
+ if (host.length > MAX_URL_HOST && host.replaceAll(IGNORED_IN_HOST, "").length > MAX_URL_HOST) {
2292
+ throw new TypeError("Invalid URL: host too long");
2293
+ }
2294
+ return new URL(text);
2295
+ }
2268
2296
  var isUnder = (host, domain) => host === domain || host.endsWith(`.${domain}`);
2269
2297
  function isClickTracker(url) {
2270
2298
  const host = url.hostname.toLowerCase();
@@ -2293,7 +2321,7 @@ var MAX_GATEWAYS = 16;
2293
2321
  function unwrapLink(address2) {
2294
2322
  let url;
2295
2323
  try {
2296
- url = new URL(address2);
2324
+ url = boundedUrl(address2);
2297
2325
  } catch {
2298
2326
  return null;
2299
2327
  }
@@ -2304,7 +2332,7 @@ function unwrapLink(address2) {
2304
2332
  }
2305
2333
  let next;
2306
2334
  try {
2307
- next = new URL(target);
2335
+ next = boundedUrl(target);
2308
2336
  } catch {
2309
2337
  break;
2310
2338
  }
@@ -2582,7 +2610,7 @@ function extractLinks(text, html, { maxLength = 5e5, bareHosts: bareHosts2 = fal
2582
2610
  }
2583
2611
  let url;
2584
2612
  try {
2585
- url = new URL(href);
2613
+ url = boundedUrl(href);
2586
2614
  } catch {
2587
2615
  return;
2588
2616
  }
@@ -2907,7 +2935,7 @@ function isDeceptiveLink(link, { from } = {}) {
2907
2935
  }
2908
2936
  let shownUrl;
2909
2937
  try {
2910
- shownUrl = new URL(/^https?:\/\//i.test(link.text) ? link.text : `http://${link.text}`);
2938
+ shownUrl = boundedUrl(/^https?:\/\//i.test(link.text) ? link.text : `http://${link.text}`);
2911
2939
  } catch {
2912
2940
  return false;
2913
2941
  }
@@ -4154,7 +4182,7 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
4154
4182
  }
4155
4183
  }
4156
4184
  async function expandAttachments(attachments = [], errors = [], parse = parseMessage, gets = {}) {
4157
- const { unopened = [], mails = [] } = Array.isArray(gets) ? { unopened: gets } : gets;
4185
+ const { unopened = [], mails = [] } = Array.isArray(gets) ? { unopened: gets } : gets ?? {};
4158
4186
  const all = [];
4159
4187
  const deadline = Date.now() + PARSE_TIMEOUT;
4160
4188
  let budget = MAX_NESTED_BYTES;
@@ -4222,6 +4250,11 @@ import { isIP } from "node:net";
4222
4250
  import { domainToASCII } from "node:url";
4223
4251
  import { getPublicSuffix } from "tldts";
4224
4252
  var LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
4253
+ var LABEL_SEPARATOR = /[.\u3002\uFF0E\uFF61]/g;
4254
+ function lastLabels(name) {
4255
+ const separators = [...name.matchAll(LABEL_SEPARATOR)];
4256
+ return separators.length < 128 ? 0 : separators.at(-128).index + 1;
4257
+ }
4225
4258
  function lookupName(host) {
4226
4259
  if (typeof host !== "string") {
4227
4260
  return null;
@@ -4232,6 +4265,7 @@ function lookupName(host) {
4232
4265
  }
4233
4266
  let name = host.slice(0, end).toLowerCase();
4234
4267
  if (/[\u0080-\u{10FFFF}]/u.test(name)) {
4268
+ name = name.slice(lastLabels(name));
4235
4269
  name = domainToASCII(name) || name;
4236
4270
  }
4237
4271
  if (isIP(name.replaceAll(/^\[|]$/g, "")) !== 0) {
@@ -4571,7 +4605,7 @@ function asciiHostname(value) {
4571
4605
  return null;
4572
4606
  }
4573
4607
  const lower = value.normalize("NFKC").toLowerCase().replace(/\.$/, "");
4574
- if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
4608
+ if (lower === "" || lower.length > 1024 || /[\0-,/:-^`{-\u007F]/.test(lower)) {
4575
4609
  return null;
4576
4610
  }
4577
4611
  const ascii = domainToASCII2(lower);
@@ -4753,8 +4787,8 @@ function trustReputation(result, trusted = []) {
4753
4787
  for (const value of result.checkedValues.filter((item) => allowed.has(item))) {
4754
4788
  const signers = allowed.get(value);
4755
4789
  const counts = (entry) => Boolean(entry) && (signers === null || signers.some((signer) => vouches(normalizeEntry(entry), signer)));
4756
- const detail = result.details[value];
4757
- const listed = result.allowlisted[value] || (detail?.isAllowlisted ? detail.allowlistValue : null);
4790
+ const detail = Object.hasOwn(result.details, value) ? result.details[value] : void 0;
4791
+ const listed = Object.hasOwn(result.allowlisted, value) && result.allowlisted[value] || (detail?.isAllowlisted ? detail.allowlistValue : null);
4758
4792
  if (counts(listed) && !out.isAllowlisted) {
4759
4793
  out.isAllowlisted = true;
4760
4794
  out.allowlistValue = listed;
@@ -5661,7 +5695,8 @@ var MAX_EMBEDDED_LENGTH = 10 * 1024 * 1024;
5661
5695
  var MAX_ATTACHED = 100;
5662
5696
  var MAX_ATTACHED_BYTES = 50 * 1024 * 1024;
5663
5697
  var MAX_READ_TIME = 5e3;
5664
- var MAX_SHOWN_BYTES = 4 * 1024 * 1024;
5698
+ var MAX_CARD_BYTES = 4 * 1024 * 1024;
5699
+ var MAX_SHOWN_BYTES = 1024 * 1024;
5665
5700
  function isHostCode2(code) {
5666
5701
  return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
5667
5702
  }
@@ -5690,7 +5725,7 @@ function urlOf(value) {
5690
5725
  return null;
5691
5726
  }
5692
5727
  try {
5693
- return new URL(href);
5728
+ return boundedUrl(href);
5694
5729
  } catch {
5695
5730
  return null;
5696
5731
  }
@@ -5780,7 +5815,13 @@ function* attributesOf(html) {
5780
5815
  TAG_START.lastIndex = Math.max(i, match.index + 1);
5781
5816
  }
5782
5817
  }
5783
- function linksOf(name, value, add, embed) {
5818
+ var MAX_VALUE2 = 1024 * 1024;
5819
+ var LONG_VALUE = 64 * 1024;
5820
+ function linksOf(name, value, { add, embed, cut }) {
5821
+ if (value.length > MAX_VALUE2) {
5822
+ cut();
5823
+ value = value.slice(0, MAX_VALUE2);
5824
+ }
5784
5825
  const decoded = decodeEntities(value, { attribute: true });
5785
5826
  switch (name) {
5786
5827
  case "srcdoc": {
@@ -5821,7 +5862,7 @@ function linksOf(name, value, add, embed) {
5821
5862
  }
5822
5863
  var MAX_LENIENT_VALUE = 4096;
5823
5864
  var UNQUOTED_END = /* @__PURE__ */ new Set(["<", ">", '"', "'", "`", "="]);
5824
- function lenientLinks(html, add, done, embed) {
5865
+ function lenientLinks(html, done, reader) {
5825
5866
  LENIENT.lastIndex = 0;
5826
5867
  let match;
5827
5868
  while (!done() && (match = LENIENT.exec(html)) !== null) {
@@ -5837,8 +5878,8 @@ function lenientLinks(html, add, done, embed) {
5837
5878
  }
5838
5879
  if (end < html.length && html[end] !== quote) {
5839
5880
  const close = html.indexOf(quote, end);
5840
- if (close !== -1 && close - i <= MAX_LENIENT_VALUE) {
5841
- linksOf(name, html.slice(i, close), add, embed);
5881
+ if (close !== -1 && close - i <= MAX_LENIENT_VALUE && !reader.spend(close - i)) {
5882
+ linksOf(name, html.slice(i, close), reader);
5842
5883
  }
5843
5884
  }
5844
5885
  } else {
@@ -5846,7 +5887,10 @@ function lenientLinks(html, add, done, embed) {
5846
5887
  end++;
5847
5888
  }
5848
5889
  }
5849
- linksOf(name, html.slice(i, end), add, embed);
5890
+ if (reader.spend(end - i)) {
5891
+ break;
5892
+ }
5893
+ linksOf(name, html.slice(i, end), reader);
5850
5894
  }
5851
5895
  }
5852
5896
  function endsLink2(text, i) {
@@ -6017,8 +6061,17 @@ function* chunksOf(html) {
6017
6061
  cutStart = false;
6018
6062
  continue;
6019
6063
  }
6020
- yield { text: html.slice(start, end), cutStart, cutEnd: true };
6021
- start = end - CHUNK_OVERLAP;
6064
+ let hard = end;
6065
+ while (hard > start + CHUNK_OVERLAP + 1 && /[\dA-Za-z#]/.test(html[hard - 1])) {
6066
+ hard--;
6067
+ }
6068
+ if (hard < end && html[hard - 1] === "&" && hard - 1 > start + CHUNK_OVERLAP) {
6069
+ hard--;
6070
+ } else {
6071
+ hard = end;
6072
+ }
6073
+ yield { text: html.slice(start, hard), cutStart, cutEnd: true };
6074
+ start = hard - CHUNK_OVERLAP;
6022
6075
  cutStart = true;
6023
6076
  continue;
6024
6077
  }
@@ -6188,27 +6241,14 @@ function quotedPrintableValues(text) {
6188
6241
  }
6189
6242
  return out.join("\n");
6190
6243
  }
6191
- function hasQuotedPrintable(text) {
6192
- const unfolded = text.replaceAll(FOLD, "");
6193
- if (!/quoted-printable/i.test(unfolded)) {
6194
- return false;
6195
- }
6196
- return unfolded.split(LINE_BREAK).some((line) => {
6197
- const colon = valueColon(line);
6198
- return colon !== -1 && QUOTED_PRINTABLE.test(line.slice(0, colon));
6199
- });
6200
- }
6201
- function shownText(text, full = true) {
6244
+ function shownText(text) {
6202
6245
  const unfolded = text.replaceAll(FOLD, "");
6203
6246
  const shown = /* @__PURE__ */ new Set([text, readEscapes(unfolded)]);
6204
- let decoded = unfolded;
6205
- if (full) {
6206
- decoded = quotedPrintableValues(unfolded);
6207
- if (decoded !== unfolded) {
6208
- shown.add(readEscapes(decoded));
6209
- if (unfolded !== text) {
6210
- shown.add(quotedPrintableValues(text));
6211
- }
6247
+ const decoded = quotedPrintableValues(unfolded);
6248
+ if (decoded !== unfolded) {
6249
+ shown.add(readEscapes(decoded));
6250
+ if (unfolded !== text) {
6251
+ shown.add(quotedPrintableValues(text));
6212
6252
  }
6213
6253
  }
6214
6254
  if (/\\[^nN,;\\]/.test(decoded)) {
@@ -6313,59 +6353,73 @@ function charsetOf(attachment) {
6313
6353
  const charset = headers && typeof headers.get === "function" ? headers.get("content-type")?.params?.charset : null;
6314
6354
  return typeof charset === "string" ? charset.toLowerCase() : "";
6315
6355
  }
6316
- function documentsOf(attachments, until) {
6317
- const documents = [];
6356
+ function kindOf(attachment) {
6357
+ const { content } = attachment ?? {};
6358
+ if (!Buffer7.isBuffer(content) || content.length === 0) {
6359
+ return null;
6360
+ }
6361
+ const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
6362
+ const extensions = extensionsOf(attachment);
6363
+ const has = (set) => [...extensions].some((name) => set.has(name));
6364
+ if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
6365
+ return null;
6366
+ }
6367
+ const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
6368
+ const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
6369
+ const isText = isCard || !isHtml && type === "text/plain";
6370
+ return isHtml || isText ? { isHtml, isText, isCard } : null;
6371
+ }
6372
+ function* documentsOf(attachments, state, late) {
6373
+ const read = [];
6318
6374
  let bytes = 0;
6319
6375
  let opened = 0;
6320
6376
  for (const attachment of attachments) {
6321
- const { content } = attachment ?? {};
6322
- if (!Buffer7.isBuffer(content) || content.length === 0) {
6323
- continue;
6324
- }
6325
- const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
6326
- const extensions = extensionsOf(attachment);
6327
- const has = (set) => [...extensions].some((name) => set.has(name));
6328
- if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
6377
+ const kind = kindOf(attachment);
6378
+ if (!kind) {
6329
6379
  continue;
6330
6380
  }
6331
- const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
6332
- const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
6333
- const isText = isCard || !isHtml && type === "text/plain";
6334
- if (!isHtml && !isText) {
6335
- continue;
6336
- }
6337
- if (!isText && ++opened > MAX_ATTACHED || bytes + content.length > MAX_ATTACHED_BYTES) {
6338
- documents.tooComplex = true;
6339
- return documents;
6381
+ if (!kind.isText && ++opened > MAX_ATTACHED || bytes + attachment.content.length > MAX_ATTACHED_BYTES) {
6382
+ state.tooComplex = true;
6383
+ break;
6340
6384
  }
6341
- if (Date.now() > until) {
6342
- documents.incomplete = true;
6343
- return documents;
6385
+ bytes += attachment.content.length;
6386
+ read.push({ attachment, kind });
6387
+ }
6388
+ for (const { attachment, kind: { isHtml, isCard } } of read) {
6389
+ if (late()) {
6390
+ state.incomplete = true;
6391
+ return;
6344
6392
  }
6345
- bytes += content.length;
6346
- const text = decodeText(content, charsetOf(attachment));
6393
+ const text = decodeText(attachment.content, charsetOf(attachment));
6347
6394
  if (isHtml) {
6348
- documents.push({ html: text });
6395
+ yield { html: text };
6349
6396
  continue;
6350
6397
  }
6351
6398
  if (!isCard) {
6352
- documents.push({ text });
6399
+ yield { text };
6353
6400
  continue;
6354
6401
  }
6355
- const readable = withoutBinaryValues(text);
6356
- const full = readable.length <= MAX_SHOWN_BYTES;
6357
- if (!full && hasQuotedPrintable(readable)) {
6358
- documents.tooComplex = true;
6359
- continue;
6402
+ let readable = withoutBinaryValues(text.length > MAX_CARD_BYTES ? text.slice(0, MAX_CARD_BYTES) : text);
6403
+ if (text.length > MAX_CARD_BYTES || readable.length > MAX_SHOWN_BYTES) {
6404
+ state.tooComplex = true;
6405
+ readable = readable.slice(0, MAX_SHOWN_BYTES);
6406
+ }
6407
+ if (late()) {
6408
+ state.incomplete = true;
6409
+ return;
6410
+ }
6411
+ yield { text: shownText(readable) };
6412
+ if (late()) {
6413
+ state.incomplete = true;
6414
+ return;
6360
6415
  }
6361
- documents.push({ text: shownText(readable, full) });
6362
6416
  const html = htmlDescriptions(readable);
6363
6417
  if (html !== "") {
6364
- documents.push({ html });
6418
+ yield { html };
6365
6419
  }
6366
6420
  }
6367
- return documents;
6368
6421
  }
6422
+ var LATE_EVERY = 1024;
6369
6423
  function carriedLinkHosts(mail, {
6370
6424
  mails = [],
6371
6425
  attachments = [],
@@ -6378,40 +6432,45 @@ function carriedLinkHosts(mail, {
6378
6432
  const seenBare = /* @__PURE__ */ new Set();
6379
6433
  const seenLoose = /* @__PURE__ */ new Set();
6380
6434
  let more = false;
6381
- let tooComplex = false;
6382
- const done = () => more;
6383
- const looseDone = () => more || loose.size > maxHosts;
6435
+ let full = false;
6436
+ const domains = /* @__PURE__ */ new Set();
6437
+ const state = { tooComplex: false, incomplete: false };
6438
+ const until = Date.now() + MAX_READ_TIME;
6439
+ const late = () => {
6440
+ if (!state.incomplete && Date.now() > until) {
6441
+ state.incomplete = true;
6442
+ }
6443
+ return state.incomplete;
6444
+ };
6445
+ let calls = 0;
6446
+ const done = () => full || state.incomplete || ++calls % LATE_EVERY === 0 && late();
6447
+ const looseDone = () => done() || loose.size > maxHosts;
6384
6448
  const addLoose = (host) => {
6385
6449
  if (!looseDone()) {
6386
6450
  loose.add(host);
6387
6451
  }
6388
6452
  };
6389
- const until = Date.now() + MAX_READ_TIME;
6390
- let incomplete = false;
6391
- const late = () => {
6392
- if (!incomplete && Date.now() > until) {
6393
- incomplete = true;
6394
- }
6395
- return incomplete;
6396
- };
6397
- const carried = documentsOf(attachments, until);
6398
- tooComplex = Boolean(carried.tooComplex);
6399
- incomplete = Boolean(carried.incomplete);
6400
- const queue = [mail, ...mails, ...carried].map((document) => ({ document, depth: 0 }));
6453
+ const queue = [mail, ...mails].map((document) => ({ document, depth: 0 }));
6454
+ const carried = documentsOf(attachments, state, late);
6401
6455
  let depth = 0;
6402
6456
  let embedded = 0;
6403
6457
  let embeddedLength = 0;
6458
+ const embeddedSeen = /* @__PURE__ */ new Set();
6404
6459
  const embed = (html) => {
6460
+ if (html === "" || embeddedSeen.has(html)) {
6461
+ return;
6462
+ }
6405
6463
  if (depth >= MAX_EMBED_DEPTH || embedded >= MAX_EMBEDDED || embeddedLength + html.length > MAX_EMBEDDED_LENGTH) {
6406
- tooComplex = true;
6464
+ state.tooComplex = true;
6407
6465
  return;
6408
6466
  }
6467
+ embeddedSeen.add(html);
6409
6468
  embedded++;
6410
6469
  embeddedLength += html.length;
6411
6470
  queue.push({ document: { html }, depth: depth + 1 });
6412
6471
  };
6413
6472
  const add = (value) => {
6414
- if (more || typeof value !== "string" || value === "" || seen.has(value)) {
6473
+ if (full || typeof value !== "string" || value === "" || seen.has(value)) {
6415
6474
  return;
6416
6475
  }
6417
6476
  if (seen.size < MAX_SEEN) {
@@ -6426,34 +6485,64 @@ function carriedLinkHosts(mail, {
6426
6485
  }
6427
6486
  const found = hostsOf(value);
6428
6487
  if (found.unresolved) {
6429
- tooComplex = true;
6488
+ state.tooComplex = true;
6430
6489
  }
6431
6490
  for (const host of found) {
6432
6491
  if (hosts.has(host)) {
6433
6492
  continue;
6434
6493
  }
6435
- if (hosts.size >= maxHosts) {
6436
- more = true;
6494
+ const domain = registrableDomain(host);
6495
+ if (hosts.size < maxHosts || !domains.has(domain) && domains.size < maxHosts) {
6496
+ hosts.add(host);
6497
+ domains.add(domain);
6498
+ continue;
6499
+ }
6500
+ more = true;
6501
+ if (domains.size >= maxHosts) {
6502
+ full = true;
6437
6503
  return;
6438
6504
  }
6439
- hosts.add(host);
6440
6505
  }
6441
6506
  };
6442
- const readText = (source, { cutStart = false, cutEnd = false } = {}) => {
6507
+ let spent = 0;
6508
+ const spend = (length) => {
6509
+ spent += length;
6510
+ if (spent < LONG_VALUE) {
6511
+ return false;
6512
+ }
6513
+ spent = 0;
6514
+ return late();
6515
+ };
6516
+ const reader = {
6517
+ add,
6518
+ embed,
6519
+ spend,
6520
+ cut() {
6521
+ state.tooComplex = true;
6522
+ }
6523
+ };
6524
+ const readText = (source, { cutStart = false, cutEnd = false, exact = false } = {}) => {
6443
6525
  for (const match of source.matchAll(URL_START)) {
6444
- if (more) {
6526
+ if (done()) {
6445
6527
  break;
6446
6528
  }
6447
6529
  if (cutStart && match.index === 0) {
6448
6530
  continue;
6449
6531
  }
6450
6532
  if (cutEnd && match.index + match[0].length === source.length) {
6533
+ if (exact && source.length - match.index < CHUNK_OVERLAP) {
6534
+ continue;
6535
+ }
6451
6536
  const value = match[0];
6452
6537
  const at = /^www\./i.test(value) ? 0 : value.indexOf("//") + 2;
6453
- if (/[/?#\\]/.test(value.slice(at))) {
6454
- add(value);
6455
- } else if (source.length - match.index > CHUNK_OVERLAP) {
6456
- tooComplex = true;
6538
+ if (!/[/?#\\]/.test(value.slice(at))) {
6539
+ state.tooComplex = true;
6540
+ continue;
6541
+ }
6542
+ add(value);
6543
+ const url = urlOf(value);
6544
+ if (url && isClickTracker(url)) {
6545
+ state.tooComplex = true;
6457
6546
  }
6458
6547
  continue;
6459
6548
  }
@@ -6475,42 +6564,60 @@ function carriedLinkHosts(mail, {
6475
6564
  });
6476
6565
  }
6477
6566
  };
6478
- for (const element of queue) {
6479
- if (more || late()) {
6567
+ for (let index = 0; ; index++) {
6568
+ if (full || late()) {
6480
6569
  break;
6481
6570
  }
6482
- ({ depth } = element);
6483
- const { document } = element;
6571
+ if (index >= queue.length) {
6572
+ const next = carried.next();
6573
+ if (next.done) {
6574
+ break;
6575
+ }
6576
+ queue.push({ document: next.value, depth: 0 });
6577
+ }
6578
+ ({ depth } = queue[index]);
6579
+ const { document } = queue[index];
6484
6580
  const html = withoutNul(typeof document?.html === "string" ? document.html : "");
6485
6581
  const text = withoutNul(typeof document?.text === "string" ? document.text : "");
6486
6582
  for (const [name, value] of attributesOf(html)) {
6487
- if (more) {
6583
+ if (done()) {
6488
6584
  break;
6489
6585
  }
6490
- if (LINK_ATTRIBUTES.has(name)) {
6491
- linksOf(name, value, add, embed);
6586
+ if (!LINK_ATTRIBUTES.has(name)) {
6587
+ continue;
6492
6588
  }
6589
+ if (spend(value.length)) {
6590
+ break;
6591
+ }
6592
+ linksOf(name, value, reader);
6493
6593
  }
6494
- lenientLinks(html, add, done, embed);
6594
+ lenientLinks(html, done, reader);
6495
6595
  for (const pattern of [CSS_URL, CSS_IMPORT]) {
6496
6596
  for (const [, link] of html.matchAll(pattern)) {
6497
- if (more) {
6597
+ if (done() || spend(link.length)) {
6498
6598
  break;
6499
6599
  }
6500
- add(decodeEntities(link, { attribute: true }));
6600
+ if (link.length > MAX_VALUE2) {
6601
+ state.tooComplex = true;
6602
+ }
6603
+ add(decodeEntities(link.slice(0, MAX_VALUE2), { attribute: true }));
6501
6604
  }
6502
6605
  }
6503
- if (!more) {
6504
- readText(text);
6606
+ for (const chunk2 of chunksOf(text)) {
6607
+ if (full || late()) {
6608
+ break;
6609
+ }
6610
+ readText(chunk2.text, { ...chunk2, exact: true });
6505
6611
  }
6506
6612
  for (const chunk2 of chunksOf(html)) {
6507
- if (more || late()) {
6613
+ if (full || late()) {
6508
6614
  break;
6509
6615
  }
6510
- readText(decodeEntities(chunk2.text), chunk2);
6616
+ const decoded = decodeEntities(chunk2.text);
6617
+ readText(decoded, { ...chunk2, exact: decoded.endsWith(chunk2.text.slice(-CHUNK_OVERLAP)) });
6511
6618
  }
6512
6619
  }
6513
- if (!more && !incomplete) {
6620
+ if (!more && !state.incomplete) {
6514
6621
  for (const name of loose) {
6515
6622
  if (hosts.size >= maxHosts) {
6516
6623
  break;
@@ -6524,8 +6631,8 @@ function carriedLinkHosts(mail, {
6524
6631
  return {
6525
6632
  hosts: [...hosts],
6526
6633
  more,
6527
- tooComplex,
6528
- incomplete
6634
+ tooComplex: state.tooComplex,
6635
+ incomplete: state.incomplete
6529
6636
  };
6530
6637
  }
6531
6638
 
@@ -8171,7 +8278,7 @@ function scoreResults(results, options) {
8171
8278
  var package_default = {
8172
8279
  name: "spamscanner",
8173
8280
  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.",
8174
- version: "7.0.11",
8281
+ version: "7.0.12",
8175
8282
  author: "Forward Email (https://forwardemail.net)",
8176
8283
  bin: {
8177
8284
  spamscanner: "./dist/esm/cli.js"
@@ -8436,9 +8543,12 @@ function rewriteMessage(source, headers = [], options = {}) {
8436
8543
  }
8437
8544
  }
8438
8545
  const added = headers.map(([name, value]) => foldHeader(name, Buffer9.from(String(value), "utf8").toString("latin1"), newline));
8439
- const mbox = kept.length > 0 && kept[0][0].startsWith("From ") ? kept.shift() : [];
8546
+ const mbox = kept.length > 0 && kept[0][0].startsWith("From ") ? kept.shift().slice(0, 1) : [];
8547
+ if (kept.length > 0 && /^[ \t]/.test(kept[0][0])) {
8548
+ kept.shift();
8549
+ }
8440
8550
  const head = [...mbox, ...added, ...kept.map((field) => field.join(newline))].join(newline);
8441
- return Buffer9.concat([Buffer9.from(head === "" && header === "" ? newline : `${head}${newline}${newline}`, "latin1"), body]);
8551
+ return Buffer9.concat([Buffer9.from(head === "" ? newline : `${head}${newline}${newline}`, "latin1"), body]);
8442
8552
  }
8443
8553
 
8444
8554
  // src/train.js
@@ -8787,7 +8897,12 @@ import net2 from "node:net";
8787
8897
  var MAX_MESSAGE_SIZE = 25 * 1024 * 1024;
8788
8898
  var MAX_CONNECTIONS = 200;
8789
8899
  function serializeResult(result, options = {}) {
8790
- const { mail, tokens, ...rest } = result;
8900
+ const {
8901
+ mail,
8902
+ tokens,
8903
+ attached,
8904
+ ...rest
8905
+ } = result;
8791
8906
  const output = {
8792
8907
  ...rest,
8793
8908
  results: { ...result.results }
@@ -9448,6 +9563,7 @@ function reply(code, text, headers = [], body = null) {
9448
9563
  lines2.push(...headers.map(([name, value]) => `${name}: ${value}`), "", "");
9449
9564
  return body === null ? Buffer13.from(lines2.join("\r\n")) : Buffer13.concat([Buffer13.from(lines2.join("\r\n")), body]);
9450
9565
  }
9566
+ var oneLine2 = (text) => String(text ?? "").replaceAll(/[\p{Cc}\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]+/gu, " ");
9451
9567
  function report(result) {
9452
9568
  const lines2 = [
9453
9569
  `Spam detection software, running on the system "spamscanner", has ${result.isSpam ? "identified this incoming email as possible spam" : "scanned this incoming email"}.`,
@@ -9456,7 +9572,7 @@ function report(result) {
9456
9572
  "",
9457
9573
  " pts rule name description",
9458
9574
  "---- ---------------------- --------------------------------------------------",
9459
- ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${test.name.padEnd(22)} ${test.description}`)
9575
+ ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${oneLine2(test.name).padEnd(22)} ${oneLine2(test.description)}`)
9460
9576
  ];
9461
9577
  return `${lines2.join("\r\n")}\r
9462
9578
  `;
@@ -10091,7 +10207,7 @@ var SpamScanner = class {
10091
10207
  // (`from`: the sender's domain; `carried`: the link hosts of the whole
10092
10208
  // message and of what it carries, see carriedLinkHosts)
10093
10209
  async checkLinks(links, config, hosts = [], options = {}) {
10094
- const { from = "", carried = null } = typeof options === "string" ? { from: options } : options;
10210
+ const { from = "", carried = null } = typeof options === "string" ? { from: options } : options ?? {};
10095
10211
  const phishing = [];
10096
10212
  const homographs = [];
10097
10213
  const seenHosts = /* @__PURE__ */ new Set();
@@ -10151,15 +10267,15 @@ var SpamScanner = class {
10151
10267
  for (const host of carriedHosts) {
10152
10268
  seenHosts.add(host);
10153
10269
  }
10154
- const ordered = [.../* @__PURE__ */ new Set([...[...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean), ...carriedHosts])];
10270
+ const own = [...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean);
10155
10271
  const firsts = /* @__PURE__ */ new Map();
10156
- for (const host of ordered) {
10272
+ for (const host of [...own, ...carriedHosts]) {
10157
10273
  const domain = registrableDomain(host);
10158
10274
  if (!firsts.has(domain)) {
10159
10275
  firsts.set(domain, host);
10160
10276
  }
10161
10277
  }
10162
- const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...ordered])].slice(0, config.phishing.maxHosts);
10278
+ const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...own, ...carriedHosts])].slice(0, config.phishing.maxHosts);
10163
10279
  const domains = [...new Set(checked.map((host) => registrableDomain(host)))];
10164
10280
  const blocked = [];
10165
10281
  let answered = 0;