spamscanner 7.0.11 → 7.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/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_SHOWN_BYTES = 1024 * 1024;
5699
+ var MAX_CARD_TEXT = 8 * 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,26 @@ 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 MAX_REFERENCES = 64 * 1024;
5820
+ function isCut(value) {
5821
+ if (value.length <= MAX_VALUE2) {
5822
+ return false;
5823
+ }
5824
+ let references = 0;
5825
+ for (let index = value.indexOf("&"); index !== -1; index = value.indexOf("&", index + 1)) {
5826
+ if (++references > MAX_REFERENCES) {
5827
+ return true;
5828
+ }
5829
+ }
5830
+ return false;
5831
+ }
5832
+ var LONG_VALUE = 64 * 1024;
5833
+ function linksOf(name, value, { add, embed, cut }) {
5834
+ if (isCut(value)) {
5835
+ cut();
5836
+ value = value.slice(0, MAX_VALUE2);
5837
+ }
5784
5838
  const decoded = decodeEntities(value, { attribute: true });
5785
5839
  switch (name) {
5786
5840
  case "srcdoc": {
@@ -5821,7 +5875,7 @@ function linksOf(name, value, add, embed) {
5821
5875
  }
5822
5876
  var MAX_LENIENT_VALUE = 4096;
5823
5877
  var UNQUOTED_END = /* @__PURE__ */ new Set(["<", ">", '"', "'", "`", "="]);
5824
- function lenientLinks(html, add, done, embed) {
5878
+ function lenientLinks(html, done, reader) {
5825
5879
  LENIENT.lastIndex = 0;
5826
5880
  let match;
5827
5881
  while (!done() && (match = LENIENT.exec(html)) !== null) {
@@ -5837,8 +5891,8 @@ function lenientLinks(html, add, done, embed) {
5837
5891
  }
5838
5892
  if (end < html.length && html[end] !== quote) {
5839
5893
  const close = html.indexOf(quote, end);
5840
- if (close !== -1 && close - i <= MAX_LENIENT_VALUE) {
5841
- linksOf(name, html.slice(i, close), add, embed);
5894
+ if (close !== -1 && close - i <= MAX_LENIENT_VALUE && !reader.spend(close - i)) {
5895
+ linksOf(name, html.slice(i, close), reader);
5842
5896
  }
5843
5897
  }
5844
5898
  } else {
@@ -5846,7 +5900,10 @@ function lenientLinks(html, add, done, embed) {
5846
5900
  end++;
5847
5901
  }
5848
5902
  }
5849
- linksOf(name, html.slice(i, end), add, embed);
5903
+ if (reader.spend(end - i)) {
5904
+ break;
5905
+ }
5906
+ linksOf(name, html.slice(i, end), reader);
5850
5907
  }
5851
5908
  }
5852
5909
  function endsLink2(text, i) {
@@ -6017,8 +6074,17 @@ function* chunksOf(html) {
6017
6074
  cutStart = false;
6018
6075
  continue;
6019
6076
  }
6020
- yield { text: html.slice(start, end), cutStart, cutEnd: true };
6021
- start = end - CHUNK_OVERLAP;
6077
+ let hard = end;
6078
+ while (hard > start + CHUNK_OVERLAP + 1 && /[\dA-Za-z#]/.test(html[hard - 1])) {
6079
+ hard--;
6080
+ }
6081
+ if (hard < end && html[hard - 1] === "&" && hard - 1 > start + CHUNK_OVERLAP) {
6082
+ hard--;
6083
+ } else {
6084
+ hard = end;
6085
+ }
6086
+ yield { text: html.slice(start, hard), cutStart, cutEnd: true };
6087
+ start = hard - CHUNK_OVERLAP;
6022
6088
  cutStart = true;
6023
6089
  continue;
6024
6090
  }
@@ -6241,40 +6307,50 @@ function htmlDescriptions(text) {
6241
6307
  return html.join("\n");
6242
6308
  }
6243
6309
  var BINARY = /;\s*(?:(?:encoding\s*=\s*)?"?(?:b|base64)"?|value\s*=\s*"?binary"?)\s*(?:;|$)/i;
6244
- function withoutBinaryValues(text) {
6310
+ function withoutBinaryValues(text, limit = Number.POSITIVE_INFINITY) {
6245
6311
  if (!/base64|binary|encoding\s*=\s*"?b|;\s*b\s*[;:]/i.test(text)) {
6246
6312
  return text;
6247
6313
  }
6248
6314
  const lines2 = text.split(LINE_BREAK);
6249
6315
  const out = [];
6316
+ let length = 0;
6317
+ const push = (line) => {
6318
+ out.push(line);
6319
+ length += line.length + 1;
6320
+ };
6250
6321
  for (let i = 0; i < lines2.length; i++) {
6322
+ if (length > limit) {
6323
+ break;
6324
+ }
6251
6325
  const line = lines2[i];
6252
6326
  const colon = valueColon(line);
6253
6327
  if (colon === -1 || !BINARY.test(line.slice(0, colon))) {
6254
- out.push(line);
6328
+ push(line);
6255
6329
  continue;
6256
6330
  }
6257
- const value = [line.slice(colon + 1)];
6331
+ let base64 = /^[\w+/=\s]*$/.test(line.slice(colon + 1));
6258
6332
  let next = i + 1;
6259
6333
  while (next < lines2.length && (lines2[next].startsWith(" ") || lines2[next].startsWith(" "))) {
6260
- value.push(lines2[next++]);
6334
+ base64 &&= /^[\w+/=\s]*$/.test(lines2[next]);
6335
+ next++;
6261
6336
  }
6262
- if (!value.every((part) => /^[\w+/=\s]*$/.test(part))) {
6337
+ if (!base64) {
6263
6338
  for (let index = i; index < next; index++) {
6264
- out.push(lines2[index]);
6339
+ push(lines2[index]);
6265
6340
  }
6266
6341
  i = next - 1;
6267
6342
  continue;
6268
6343
  }
6269
6344
  while (next < lines2.length && lines2[next] !== "" && /^[\w+/=]+$/.test(lines2[next])) {
6270
- value.push(lines2[next++]);
6345
+ next++;
6271
6346
  }
6347
+ const end = next;
6272
6348
  if (next < lines2.length && lines2[next] === "") {
6273
6349
  next++;
6274
6350
  }
6275
- i = next - 1;
6276
6351
  const name = line.slice(0, colon).split(";")[0].split(".").pop().trim().toLowerCase();
6277
- out.push(MEDIA_PROPERTIES.has(name) ? line.slice(0, colon + 1) : line.slice(0, colon + 1) + Buffer7.from(value.join("").replaceAll(/\s/g, ""), "base64").toString("utf8"));
6352
+ push(MEDIA_PROPERTIES.has(name) ? line.slice(0, colon + 1) : line.slice(0, colon + 1) + Buffer7.from([line.slice(colon + 1), ...lines2.slice(i + 1, end)].join("").replaceAll(/\s/g, ""), "base64").toString("utf8"));
6353
+ i = next - 1;
6278
6354
  }
6279
6355
  return out.join("\n");
6280
6356
  }
@@ -6313,59 +6389,77 @@ function charsetOf(attachment) {
6313
6389
  const charset = headers && typeof headers.get === "function" ? headers.get("content-type")?.params?.charset : null;
6314
6390
  return typeof charset === "string" ? charset.toLowerCase() : "";
6315
6391
  }
6316
- function documentsOf(attachments, until) {
6317
- const documents = [];
6392
+ function kindOf(attachment) {
6393
+ const { content } = attachment ?? {};
6394
+ if (!Buffer7.isBuffer(content) || content.length === 0) {
6395
+ return null;
6396
+ }
6397
+ const type = typeof attachment.contentType === "string" ? attachment.contentType.toLowerCase() : "";
6398
+ const extensions = extensionsOf(attachment);
6399
+ const has = (set) => [...extensions].some((name) => set.has(name));
6400
+ if (MESSAGE_TYPES.has(type) || extensions.has("eml") || extensions.has("u8msg")) {
6401
+ return null;
6402
+ }
6403
+ const isHtml = HTML_TYPES.has(type) || has(HTML_EXTENSIONS);
6404
+ const isCard = !isHtml && (CARD_TYPES.has(type) || has(CARD_EXTENSIONS));
6405
+ const isText = isCard || !isHtml && type === "text/plain";
6406
+ return isHtml || isText ? { isHtml, isText, isCard } : null;
6407
+ }
6408
+ function* documentsOf(attachments, state, late) {
6409
+ const read = [];
6318
6410
  let bytes = 0;
6319
6411
  let opened = 0;
6320
6412
  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")) {
6413
+ const kind = kindOf(attachment);
6414
+ if (!kind) {
6329
6415
  continue;
6330
6416
  }
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;
6417
+ if (!kind.isText && ++opened > MAX_ATTACHED || bytes + attachment.content.length > MAX_ATTACHED_BYTES) {
6418
+ state.tooComplex = true;
6419
+ break;
6340
6420
  }
6341
- if (Date.now() > until) {
6342
- documents.incomplete = true;
6343
- return documents;
6421
+ bytes += attachment.content.length;
6422
+ read.push({ attachment, kind });
6423
+ }
6424
+ for (const { attachment, kind: { isHtml, isCard } } of read) {
6425
+ if (late()) {
6426
+ state.incomplete = true;
6427
+ return;
6344
6428
  }
6345
- bytes += content.length;
6346
- const text = decodeText(content, charsetOf(attachment));
6429
+ const text = decodeText(attachment.content, charsetOf(attachment));
6347
6430
  if (isHtml) {
6348
- documents.push({ html: text });
6431
+ yield { html: text };
6349
6432
  continue;
6350
6433
  }
6351
6434
  if (!isCard) {
6352
- documents.push({ text });
6435
+ yield { text };
6353
6436
  continue;
6354
6437
  }
6355
- const readable = withoutBinaryValues(text);
6438
+ let readable = withoutBinaryValues(text, MAX_CARD_TEXT);
6439
+ if (readable.length > MAX_CARD_TEXT) {
6440
+ state.tooComplex = true;
6441
+ readable = readable.slice(0, MAX_CARD_TEXT);
6442
+ }
6356
6443
  const full = readable.length <= MAX_SHOWN_BYTES;
6357
6444
  if (!full && hasQuotedPrintable(readable)) {
6358
- documents.tooComplex = true;
6359
- continue;
6445
+ state.tooComplex = true;
6446
+ }
6447
+ if (late()) {
6448
+ state.incomplete = true;
6449
+ return;
6450
+ }
6451
+ yield { text: shownText(readable, full) };
6452
+ if (late()) {
6453
+ state.incomplete = true;
6454
+ return;
6360
6455
  }
6361
- documents.push({ text: shownText(readable, full) });
6362
6456
  const html = htmlDescriptions(readable);
6363
6457
  if (html !== "") {
6364
- documents.push({ html });
6458
+ yield { html };
6365
6459
  }
6366
6460
  }
6367
- return documents;
6368
6461
  }
6462
+ var LATE_EVERY = 1024;
6369
6463
  function carriedLinkHosts(mail, {
6370
6464
  mails = [],
6371
6465
  attachments = [],
@@ -6378,40 +6472,45 @@ function carriedLinkHosts(mail, {
6378
6472
  const seenBare = /* @__PURE__ */ new Set();
6379
6473
  const seenLoose = /* @__PURE__ */ new Set();
6380
6474
  let more = false;
6381
- let tooComplex = false;
6382
- const done = () => more;
6383
- const looseDone = () => more || loose.size > maxHosts;
6475
+ let full = false;
6476
+ const domains = /* @__PURE__ */ new Set();
6477
+ const state = { tooComplex: false, incomplete: false };
6478
+ const until = Date.now() + MAX_READ_TIME;
6479
+ const late = () => {
6480
+ if (!state.incomplete && Date.now() > until) {
6481
+ state.incomplete = true;
6482
+ }
6483
+ return state.incomplete;
6484
+ };
6485
+ let calls = 0;
6486
+ const done = () => full || state.incomplete || ++calls % LATE_EVERY === 0 && late();
6487
+ const looseDone = () => done() || loose.size > maxHosts;
6384
6488
  const addLoose = (host) => {
6385
6489
  if (!looseDone()) {
6386
6490
  loose.add(host);
6387
6491
  }
6388
6492
  };
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 }));
6493
+ const queue = [mail, ...mails].map((document) => ({ document, depth: 0 }));
6494
+ const carried = documentsOf(attachments, state, late);
6401
6495
  let depth = 0;
6402
6496
  let embedded = 0;
6403
6497
  let embeddedLength = 0;
6498
+ const embeddedSeen = /* @__PURE__ */ new Set();
6404
6499
  const embed = (html) => {
6500
+ if (html === "" || embeddedSeen.has(html)) {
6501
+ return;
6502
+ }
6405
6503
  if (depth >= MAX_EMBED_DEPTH || embedded >= MAX_EMBEDDED || embeddedLength + html.length > MAX_EMBEDDED_LENGTH) {
6406
- tooComplex = true;
6504
+ state.tooComplex = true;
6407
6505
  return;
6408
6506
  }
6507
+ embeddedSeen.add(html);
6409
6508
  embedded++;
6410
6509
  embeddedLength += html.length;
6411
6510
  queue.push({ document: { html }, depth: depth + 1 });
6412
6511
  };
6413
6512
  const add = (value) => {
6414
- if (more || typeof value !== "string" || value === "" || seen.has(value)) {
6513
+ if (full || typeof value !== "string" || value === "" || seen.has(value)) {
6415
6514
  return;
6416
6515
  }
6417
6516
  if (seen.size < MAX_SEEN) {
@@ -6426,34 +6525,64 @@ function carriedLinkHosts(mail, {
6426
6525
  }
6427
6526
  const found = hostsOf(value);
6428
6527
  if (found.unresolved) {
6429
- tooComplex = true;
6528
+ state.tooComplex = true;
6430
6529
  }
6431
6530
  for (const host of found) {
6432
6531
  if (hosts.has(host)) {
6433
6532
  continue;
6434
6533
  }
6435
- if (hosts.size >= maxHosts) {
6436
- more = true;
6534
+ const domain = registrableDomain(host);
6535
+ if (hosts.size < maxHosts || !domains.has(domain) && domains.size < maxHosts) {
6536
+ hosts.add(host);
6537
+ domains.add(domain);
6538
+ continue;
6539
+ }
6540
+ more = true;
6541
+ if (domains.size >= maxHosts) {
6542
+ full = true;
6437
6543
  return;
6438
6544
  }
6439
- hosts.add(host);
6440
6545
  }
6441
6546
  };
6442
- const readText = (source, { cutStart = false, cutEnd = false } = {}) => {
6547
+ let spent = 0;
6548
+ const spend = (length) => {
6549
+ spent += length;
6550
+ if (spent < LONG_VALUE) {
6551
+ return false;
6552
+ }
6553
+ spent = 0;
6554
+ return late();
6555
+ };
6556
+ const reader = {
6557
+ add,
6558
+ embed,
6559
+ spend,
6560
+ cut() {
6561
+ state.tooComplex = true;
6562
+ }
6563
+ };
6564
+ const readText = (source, { cutStart = false, cutEnd = false, exact = false } = {}) => {
6443
6565
  for (const match of source.matchAll(URL_START)) {
6444
- if (more) {
6566
+ if (done()) {
6445
6567
  break;
6446
6568
  }
6447
6569
  if (cutStart && match.index === 0) {
6448
6570
  continue;
6449
6571
  }
6450
6572
  if (cutEnd && match.index + match[0].length === source.length) {
6573
+ if (exact && source.length - match.index < CHUNK_OVERLAP) {
6574
+ continue;
6575
+ }
6451
6576
  const value = match[0];
6452
6577
  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;
6578
+ if (!/[/?#\\]/.test(value.slice(at))) {
6579
+ state.tooComplex = true;
6580
+ continue;
6581
+ }
6582
+ add(value);
6583
+ const url = urlOf(value);
6584
+ if (url && isClickTracker(url)) {
6585
+ state.tooComplex = true;
6457
6586
  }
6458
6587
  continue;
6459
6588
  }
@@ -6475,42 +6604,61 @@ function carriedLinkHosts(mail, {
6475
6604
  });
6476
6605
  }
6477
6606
  };
6478
- for (const element of queue) {
6479
- if (more || late()) {
6607
+ for (let index = 0; ; index++) {
6608
+ if (full || late()) {
6480
6609
  break;
6481
6610
  }
6482
- ({ depth } = element);
6483
- const { document } = element;
6611
+ if (index >= queue.length) {
6612
+ const next = carried.next();
6613
+ if (next.done) {
6614
+ break;
6615
+ }
6616
+ queue.push({ document: next.value, depth: 0 });
6617
+ }
6618
+ ({ depth } = queue[index]);
6619
+ const { document } = queue[index];
6484
6620
  const html = withoutNul(typeof document?.html === "string" ? document.html : "");
6485
6621
  const text = withoutNul(typeof document?.text === "string" ? document.text : "");
6486
6622
  for (const [name, value] of attributesOf(html)) {
6487
- if (more) {
6623
+ if (done()) {
6488
6624
  break;
6489
6625
  }
6490
- if (LINK_ATTRIBUTES.has(name)) {
6491
- linksOf(name, value, add, embed);
6626
+ if (!LINK_ATTRIBUTES.has(name)) {
6627
+ continue;
6628
+ }
6629
+ if (spend(value.length)) {
6630
+ break;
6492
6631
  }
6632
+ linksOf(name, value, reader);
6493
6633
  }
6494
- lenientLinks(html, add, done, embed);
6634
+ lenientLinks(html, done, reader);
6495
6635
  for (const pattern of [CSS_URL, CSS_IMPORT]) {
6496
6636
  for (const [, link] of html.matchAll(pattern)) {
6497
- if (more) {
6637
+ if (done() || spend(link.length)) {
6498
6638
  break;
6499
6639
  }
6500
- add(decodeEntities(link, { attribute: true }));
6640
+ const cut = isCut(link);
6641
+ if (cut) {
6642
+ state.tooComplex = true;
6643
+ }
6644
+ add(decodeEntities(cut ? link.slice(0, MAX_VALUE2) : link, { attribute: true }));
6501
6645
  }
6502
6646
  }
6503
- if (!more) {
6504
- readText(text);
6647
+ for (const chunk2 of chunksOf(text)) {
6648
+ if (full || late()) {
6649
+ break;
6650
+ }
6651
+ readText(chunk2.text, { ...chunk2, exact: true });
6505
6652
  }
6506
6653
  for (const chunk2 of chunksOf(html)) {
6507
- if (more || late()) {
6654
+ if (full || late()) {
6508
6655
  break;
6509
6656
  }
6510
- readText(decodeEntities(chunk2.text), chunk2);
6657
+ const decoded = decodeEntities(chunk2.text);
6658
+ readText(decoded, { ...chunk2, exact: decoded.endsWith(chunk2.text.slice(-CHUNK_OVERLAP)) });
6511
6659
  }
6512
6660
  }
6513
- if (!more && !incomplete) {
6661
+ if (!more && !state.incomplete) {
6514
6662
  for (const name of loose) {
6515
6663
  if (hosts.size >= maxHosts) {
6516
6664
  break;
@@ -6524,8 +6672,8 @@ function carriedLinkHosts(mail, {
6524
6672
  return {
6525
6673
  hosts: [...hosts],
6526
6674
  more,
6527
- tooComplex,
6528
- incomplete
6675
+ tooComplex: state.tooComplex,
6676
+ incomplete: state.incomplete
6529
6677
  };
6530
6678
  }
6531
6679
 
@@ -8171,7 +8319,7 @@ function scoreResults(results, options) {
8171
8319
  var package_default = {
8172
8320
  name: "spamscanner",
8173
8321
  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",
8322
+ version: "7.0.13",
8175
8323
  author: "Forward Email (https://forwardemail.net)",
8176
8324
  bin: {
8177
8325
  spamscanner: "./dist/esm/cli.js"
@@ -8436,9 +8584,12 @@ function rewriteMessage(source, headers = [], options = {}) {
8436
8584
  }
8437
8585
  }
8438
8586
  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() : [];
8587
+ const mbox = kept.length > 0 && kept[0][0].startsWith("From ") ? kept.shift().slice(0, 1) : [];
8588
+ if (kept.length > 0 && /^[ \t]/.test(kept[0][0])) {
8589
+ kept.shift();
8590
+ }
8440
8591
  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]);
8592
+ return Buffer9.concat([Buffer9.from(head === "" ? newline : `${head}${newline}${newline}`, "latin1"), body]);
8442
8593
  }
8443
8594
 
8444
8595
  // src/train.js
@@ -8787,7 +8938,12 @@ import net2 from "node:net";
8787
8938
  var MAX_MESSAGE_SIZE = 25 * 1024 * 1024;
8788
8939
  var MAX_CONNECTIONS = 200;
8789
8940
  function serializeResult(result, options = {}) {
8790
- const { mail, tokens, ...rest } = result;
8941
+ const {
8942
+ mail,
8943
+ tokens,
8944
+ attached,
8945
+ ...rest
8946
+ } = result;
8791
8947
  const output = {
8792
8948
  ...rest,
8793
8949
  results: { ...result.results }
@@ -9448,6 +9604,7 @@ function reply(code, text, headers = [], body = null) {
9448
9604
  lines2.push(...headers.map(([name, value]) => `${name}: ${value}`), "", "");
9449
9605
  return body === null ? Buffer13.from(lines2.join("\r\n")) : Buffer13.concat([Buffer13.from(lines2.join("\r\n")), body]);
9450
9606
  }
9607
+ var oneLine2 = (text) => String(text ?? "").replaceAll(/[\p{Cc}\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]+/gu, " ");
9451
9608
  function report(result) {
9452
9609
  const lines2 = [
9453
9610
  `Spam detection software, running on the system "spamscanner", has ${result.isSpam ? "identified this incoming email as possible spam" : "scanned this incoming email"}.`,
@@ -9456,7 +9613,7 @@ function report(result) {
9456
9613
  "",
9457
9614
  " pts rule name description",
9458
9615
  "---- ---------------------- --------------------------------------------------",
9459
- ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${test.name.padEnd(22)} ${test.description}`)
9616
+ ...result.tests.map((test) => `${test.score.toFixed(1).padStart(4)} ${oneLine2(test.name).padEnd(22)} ${oneLine2(test.description)}`)
9460
9617
  ];
9461
9618
  return `${lines2.join("\r\n")}\r
9462
9619
  `;
@@ -10091,7 +10248,7 @@ var SpamScanner = class {
10091
10248
  // (`from`: the sender's domain; `carried`: the link hosts of the whole
10092
10249
  // message and of what it carries, see carriedLinkHosts)
10093
10250
  async checkLinks(links, config, hosts = [], options = {}) {
10094
- const { from = "", carried = null } = typeof options === "string" ? { from: options } : options;
10251
+ const { from = "", carried = null } = typeof options === "string" ? { from: options } : options ?? {};
10095
10252
  const phishing = [];
10096
10253
  const homographs = [];
10097
10254
  const seenHosts = /* @__PURE__ */ new Set();
@@ -10151,15 +10308,15 @@ var SpamScanner = class {
10151
10308
  for (const host of carriedHosts) {
10152
10309
  seenHosts.add(host);
10153
10310
  }
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])];
10311
+ const own = [...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean);
10155
10312
  const firsts = /* @__PURE__ */ new Map();
10156
- for (const host of ordered) {
10313
+ for (const host of [...own, ...carriedHosts]) {
10157
10314
  const domain = registrableDomain(host);
10158
10315
  if (!firsts.has(domain)) {
10159
10316
  firsts.set(domain, host);
10160
10317
  }
10161
10318
  }
10162
- const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...ordered])].slice(0, config.phishing.maxHosts);
10319
+ const checked = [.../* @__PURE__ */ new Set([...firsts.values(), ...own, ...carriedHosts])].slice(0, config.phishing.maxHosts);
10163
10320
  const domains = [...new Set(checked.map((host) => registrableDomain(host)))];
10164
10321
  const blocked = [];
10165
10322
  let answered = 0;