spamscanner 7.0.6 → 7.0.8

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.
@@ -2541,6 +2541,9 @@ function unwrapLink(address2) {
2541
2541
  }
2542
2542
  for (let depth = 0; depth < 3; depth++) {
2543
2543
  const target = gatewayTarget(url);
2544
+ if (!target) {
2545
+ break;
2546
+ }
2544
2547
  let next;
2545
2548
  try {
2546
2549
  next = new URL(target);
@@ -2787,13 +2790,15 @@ function endsInsideTag(html2, end, link) {
2787
2790
  const joined = trimEndOf(link.replace(/^https?:\/\//i, "") + after, ".-");
2788
2791
  return joined !== host && (!isHost(host) || isHost(joined) && registrableDomain(joined) !== registrableDomain(host));
2789
2792
  }
2790
- function extractLinks(text, html2, { maxLength = 5e5 } = {}) {
2793
+ function extractLinks(text, html2, { maxLength = 5e5, bareHosts = false } = {}) {
2791
2794
  text = typeof text === "string" ? foldToAscii(text) : text;
2792
2795
  html2 = typeof html2 === "string" ? foldToAscii(html2) : html2;
2793
2796
  const links = [];
2794
2797
  const seen = /* @__PURE__ */ new Set();
2798
+ const linkHosts = /* @__PURE__ */ new Set();
2799
+ const bareFound = [];
2795
2800
  const names = /* @__PURE__ */ new Map();
2796
- const add = (raw, shown = null) => {
2801
+ const add = (raw, shown = null, bare = false) => {
2797
2802
  let href = trimEndOf(trimControls(raw.replaceAll(/[\t\n\r]/g, "")).trim(), `).,;:!?'"]`);
2798
2803
  if (/^www\./i.test(href)) {
2799
2804
  href = `http://${href}`;
@@ -2817,11 +2822,13 @@ ${shown || ""}`;
2817
2822
  }
2818
2823
  seen.add(key);
2819
2824
  const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[(.*)]$/, "$1");
2825
+ linkHosts.add(host);
2820
2826
  links.push({
2821
2827
  url: url.href.slice(0, MAX_URL),
2822
2828
  host,
2823
2829
  text: shown,
2824
- userinfo: Boolean(url.username || url.password)
2830
+ userinfo: Boolean(url.username || url.password),
2831
+ ...bare && { bare: true }
2825
2832
  });
2826
2833
  };
2827
2834
  if (typeof html2 === "string" && html2) {
@@ -2850,10 +2857,90 @@ ${shown || ""}`;
2850
2857
  }
2851
2858
  add(link);
2852
2859
  }
2860
+ if (bareHosts && index < 2) {
2861
+ bareLinkHosts(read, (host) => {
2862
+ bareFound.push(host);
2863
+ });
2864
+ }
2865
+ }
2866
+ }
2867
+ for (const host of bareFound) {
2868
+ if (!linkHosts.has(host)) {
2869
+ add(`http://${host}/`, null, true);
2853
2870
  }
2854
2871
  }
2855
2872
  return links;
2856
2873
  }
2874
+ function isHostCode(code) {
2875
+ return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
2876
+ }
2877
+ function endsLink(char) {
2878
+ return /[\s<>"'\u0060]/u.test(char);
2879
+ }
2880
+ function bareLinkHosts(text, add) {
2881
+ const found = /* @__PURE__ */ new Set();
2882
+ let previous = -1;
2883
+ let read = 0;
2884
+ let space = -1;
2885
+ let scheme = -1;
2886
+ for (let slash = text.indexOf("/"); slash !== -1; slash = text.indexOf("/", slash + 1)) {
2887
+ const floor = Math.max(previous + 1, slash - MAX_BARE_NAME);
2888
+ previous = slash;
2889
+ let end = slash;
2890
+ while (end > floor && text.codePointAt(end - 1) >= 48 && text.codePointAt(end - 1) <= 57) {
2891
+ end--;
2892
+ }
2893
+ end = end > floor && text[end - 1] === ":" ? end - 1 : slash;
2894
+ let start = end;
2895
+ while (start > floor && isHostCode(text.codePointAt(start - 1))) {
2896
+ start--;
2897
+ }
2898
+ if (start === end || start > 0 && (text[start - 1] === "/" || IN_WORD.test(text[start - 1]))) {
2899
+ continue;
2900
+ }
2901
+ for (; read < start; read++) {
2902
+ if (endsLink(text[read])) {
2903
+ space = read;
2904
+ } else if (text[read] === "/" && text[read - 1] === "/" && text[read - 2] === ":") {
2905
+ scheme = read;
2906
+ }
2907
+ }
2908
+ if (scheme > space) {
2909
+ continue;
2910
+ }
2911
+ while (end > start && text[end - 1] === ".") {
2912
+ end--;
2913
+ }
2914
+ const labels = text.slice(start, end).split(".");
2915
+ const tld = labels.pop();
2916
+ if (!/^(?:[a-z]{2,24}|xn--[a-z\d-]{1,59})$/i.test(tld)) {
2917
+ continue;
2918
+ }
2919
+ const parts = [];
2920
+ while (labels.length > 0 && parts.length < 11) {
2921
+ const label = labels.pop();
2922
+ if (!/^[a-z\d-]{1,63}$/i.test(label)) {
2923
+ break;
2924
+ }
2925
+ parts.unshift(label);
2926
+ }
2927
+ if (parts.length === 0) {
2928
+ continue;
2929
+ }
2930
+ parts[0] = parts[0].replace(/^-+/, "");
2931
+ if (!parts[0]) {
2932
+ continue;
2933
+ }
2934
+ const host = `${parts.join(".")}.${tld}`.toLowerCase();
2935
+ if (found.has(host)) {
2936
+ continue;
2937
+ }
2938
+ found.add(host);
2939
+ if (isHost(host)) {
2940
+ add(host);
2941
+ }
2942
+ }
2943
+ }
2857
2944
  function hostOf(link) {
2858
2945
  const authority = link.replace(/^https?:\/\//i, "").split(/[/?#\\]/)[0];
2859
2946
  return trimEndOf(authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, ""), `).,;:!?'"]`);
@@ -3392,7 +3479,7 @@ ${body}`);
3392
3479
  hidden: visible.hidden
3393
3480
  };
3394
3481
  }
3395
- 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, BRAND_NAMES, SHORTENERS, FREEMAIL, segmenters, LATIN_LOOKALIKES, LATIN, CYRILLIC_OR_GREEK, INLINE_TAG, HOST_CHAR, MAX_SHOWN_HTML, LINKED_BRANDS, SIGN_IN_PART, signInPath, SENDING_DOMAINS, ADDRESS_SOURCE, NAMED_ADDRESS, MIN_SHOWN_WORDS, PART_WORD;
3482
+ 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, 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;
3396
3483
  var init_tokenizer = __esm({
3397
3484
  "src/tokenizer.js"() {
3398
3485
  import_tldts2 = require("tldts");
@@ -3515,6 +3602,8 @@ var init_tokenizer = __esm({
3515
3602
  INLINE_TAG = /^<\/?(?:abbr|b|bdi|bdo|big|cite|code|data|del|dfn|em|font|i|ins|kbd|mark|nobr|q|s|samp|small|span|strike|strong|sub|sup|time|tt|u|var|wbr)(?=[\t\n\f\r />])|^<!/i;
3516
3603
  HOST_CHAR = /[\p{L}\p{N}.-]/u;
3517
3604
  MAX_SHOWN_HTML = 2e5;
3605
+ IN_WORD = /[\p{L}\p{N}\p{M}_%]/u;
3606
+ MAX_BARE_NAME = 760;
3518
3607
  LINKED_BRANDS = /* @__PURE__ */ new Set(["airbnb", "aliexpress", "alibaba", "bestbuy", "booking", "costco", "discord", "ebay", "facebook", "github", "gitlab", "instagram", "linkedin", "roblox", "shopify", "skype", "spotify", "steam", "telegram", "tiktok", "twitter", "uber", "walmart", "whatsapp", "zoom"]);
3519
3608
  SIGN_IN_PART = /^(?:your|my|service|secure)?(?:login|logon|signin|signon|accounts?|verify|verification|security|secure|auth|authenticate|authorize|oauth2?|billing|password|recover|recovery|unlock|confirm|update|wallet|webscr)(?:verification|payment|info|help|page|\d{1,2})?$/;
3520
3609
  signInPath = (path4) => path4.toLowerCase().replaceAll(/\b(log|sign)-(in|on)\b/g, "$1$2").split(/[/?&=._-]/).some((part) => SIGN_IN_PART.test(part));
@@ -4314,24 +4403,38 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
4314
4403
  }
4315
4404
  }
4316
4405
  }
4317
- async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
4406
+ async function expandAttachments(attachments = [], errors = [], parse = parseMessage, unopened = []) {
4318
4407
  const all = [];
4319
4408
  const deadline = Date.now() + PARSE_TIMEOUT;
4320
4409
  let budget = MAX_NESTED_BYTES;
4321
4410
  let nested = 0;
4411
+ let tooMany = false;
4322
4412
  const visit = async (list, prefix, depth) => {
4323
4413
  for (const attachment of list) {
4324
4414
  if (prefix) {
4325
4415
  if (nested >= MAX_ATTACHMENTS) {
4416
+ if (!tooMany) {
4417
+ tooMany = true;
4418
+ unopened.push({ filename: prefix, reason: `more than ${MAX_ATTACHMENTS} attachments in attached messages` });
4419
+ }
4326
4420
  return;
4327
4421
  }
4328
4422
  nested++;
4329
4423
  }
4330
4424
  const name = attachment.filename || (prefix ? "unnamed attachment" : "");
4425
+ const filename = prefix ? `${prefix}/${name}` : name || "attached message";
4331
4426
  all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
4332
4427
  const { content } = attachment;
4333
4428
  const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
4334
- if (attached && depth < MAX_NESTED_DEPTH && import_node_buffer2.Buffer.isBuffer(content) && content.length <= budget) {
4429
+ if (attached && import_node_buffer2.Buffer.isBuffer(content) && depth >= MAX_NESTED_DEPTH) {
4430
+ unopened.push({ filename, reason: `nested more than ${MAX_NESTED_DEPTH} deep` });
4431
+ continue;
4432
+ }
4433
+ if (attached && import_node_buffer2.Buffer.isBuffer(content) && content.length > budget) {
4434
+ unopened.push({ filename, reason: `past ${MAX_NESTED_BYTES / 1024 / 1024} MB of attached messages` });
4435
+ continue;
4436
+ }
4437
+ if (attached && import_node_buffer2.Buffer.isBuffer(content)) {
4335
4438
  budget -= content.length;
4336
4439
  let inner;
4337
4440
  try {
@@ -4441,12 +4544,39 @@ var init_parse = __esm({
4441
4544
  });
4442
4545
 
4443
4546
  // src/dnsbl.js
4444
- function isLookupName(name) {
4445
- if (typeof name !== "string") {
4446
- return false;
4547
+ function lookupName(host) {
4548
+ if (typeof host !== "string") {
4549
+ return null;
4550
+ }
4551
+ let end = host.length;
4552
+ while (end > 0 && host[end - 1] === ".") {
4553
+ end--;
4554
+ }
4555
+ let name = host.slice(0, end).toLowerCase();
4556
+ if (/[\u0080-\u{10FFFF}]/u.test(name)) {
4557
+ name = (0, import_node_url2.domainToASCII)(name) || name;
4558
+ }
4559
+ if ((0, import_node_net.isIP)(name.replaceAll(/^\[|]$/g, "")) !== 0) {
4560
+ return null;
4561
+ }
4562
+ const labels = name.split(".");
4563
+ let start = labels.length;
4564
+ let length = -1;
4565
+ while (start > 0 && LOOKUP_LABEL.test(labels[start - 1]) && length + 1 + labels[start - 1].length <= 253) {
4566
+ start--;
4567
+ length += 1 + labels[start].length;
4568
+ }
4569
+ if (start === labels.length) {
4570
+ return null;
4447
4571
  }
4448
- const host = name.replaceAll(/^\[|]$/g, "").replace(/\.$/, "");
4449
- return host !== "" && (0, import_node_net.isIP)(host) === 0 && host.length <= 253 && host.split(".").every((label) => label.length > 0 && label.length <= 63);
4572
+ if (start === 0) {
4573
+ return name;
4574
+ }
4575
+ const suffix = labels.slice(start).join(".");
4576
+ if (start === labels.length - 1 || (0, import_tldts3.getPublicSuffix)(suffix, { allowPrivateDomains: true }) === suffix) {
4577
+ return null;
4578
+ }
4579
+ return suffix;
4450
4580
  }
4451
4581
  function ipv6Groups(address2) {
4452
4582
  const expanded = address2.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/, (...match) => {
@@ -4523,11 +4653,14 @@ function isListedAnswer(address2, zone) {
4523
4653
  }
4524
4654
  return true;
4525
4655
  }
4526
- var import_node_dns, import_node_net, CLOUDFLARE_MALWARE, CLOUDFLARE_FAMILY, DnsChecker;
4656
+ var import_node_dns, import_node_net, import_node_url2, import_tldts3, LOOKUP_LABEL, CLOUDFLARE_MALWARE, CLOUDFLARE_FAMILY, DnsChecker;
4527
4657
  var init_dnsbl = __esm({
4528
4658
  "src/dnsbl.js"() {
4529
4659
  import_node_dns = __toESM(require("node:dns"), 1);
4530
4660
  import_node_net = require("node:net");
4661
+ import_node_url2 = require("node:url");
4662
+ import_tldts3 = require("tldts");
4663
+ LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
4531
4664
  CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
4532
4665
  CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
4533
4666
  DnsChecker = class {
@@ -4650,9 +4783,10 @@ var init_dnsbl = __esm({
4650
4783
  return results.filter(Boolean);
4651
4784
  }
4652
4785
  /**
4653
- * Ask Cloudflare's filtering resolvers about host names. A name blocked by
4654
- * the malware resolver is "malware"; one blocked only by the family
4655
- * resolver is "adult". The hosts that got no answer (from the malware
4786
+ * Ask Cloudflare's filtering resolvers about host names, each by the name
4787
+ * it is looked up as (see lookupName), once; the findings name it. A name
4788
+ * blocked by the malware resolver is "malware"; one blocked only by the
4789
+ * family resolver is "adult". The hosts that got no answer (from the malware
4656
4790
  * resolver, or from the family resolver when asked) are listed in the
4657
4791
  * result's (non-enumerable) `unanswered` property.
4658
4792
  * @param {string[]} hosts
@@ -4665,10 +4799,8 @@ var init_dnsbl = __esm({
4665
4799
  async checkCloudflare(hosts = [], options = {}) {
4666
4800
  const { adult = true, malwareServers = CLOUDFLARE_MALWARE, familyServers = CLOUDFLARE_FAMILY } = options;
4667
4801
  const unanswered = [];
4668
- const results = await Promise.all(hosts.map(async (host) => {
4669
- if (!isLookupName(host)) {
4670
- return null;
4671
- }
4802
+ const names = [...new Set(hosts.map((host) => lookupName(host)).filter(Boolean))];
4803
+ const results = await Promise.all(names.map(async (host) => {
4672
4804
  const malware = await this.answer(host, malwareServers);
4673
4805
  if (malware?.includes("0.0.0.0")) {
4674
4806
  return { host, category: "malware" };
@@ -4771,7 +4903,7 @@ function asciiHostname(value) {
4771
4903
  if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
4772
4904
  return null;
4773
4905
  }
4774
- const ascii = (0, import_node_url2.domainToASCII)(lower);
4906
+ const ascii = (0, import_node_url3.domainToASCII)(lower);
4775
4907
  return ascii.length <= 253 && /^[\w-]{1,63}(?:\.[\w-]{1,63})*$/.test(ascii) ? ascii : null;
4776
4908
  }
4777
4909
  function normalizeEntry(value) {
@@ -4920,11 +5052,11 @@ function trustedReputationValues(mail = {}, session = {}, authentication = null)
4920
5052
  envelope: trustedSender ? { mailFrom: { address: sender } } : void 0
4921
5053
  }), ...fromValues];
4922
5054
  }
4923
- var import_node_net2, import_node_url2, EMPTY, ReputationChecker, domainOf, sameDomain;
5055
+ var import_node_net2, import_node_url3, EMPTY, ReputationChecker, domainOf, sameDomain;
4924
5056
  var init_reputation = __esm({
4925
5057
  "src/reputation.js"() {
4926
5058
  import_node_net2 = require("node:net");
4927
- import_node_url2 = require("node:url");
5059
+ import_node_url3 = require("node:url");
4928
5060
  init_dnsbl();
4929
5061
  init_http();
4930
5062
  init_tokenizer();
@@ -5920,13 +6052,13 @@ function joinsDisplay(brand, display, name) {
5920
6052
  return false;
5921
6053
  }
5922
6054
  function isOfficial({ brand, official }, domain, display, shared = true) {
5923
- const parsed = (0, import_tldts3.parse)(domain);
6055
+ const parsed = (0, import_tldts4.parse)(domain);
5924
6056
  const name = parsed.domainWithoutSuffix ?? "";
5925
6057
  const compact = brand.replaceAll(" ", "");
5926
6058
  if (parsed.publicSuffix === compact || compact.length >= 5 && joinsDisplay(brand, display, name) && !JOINED_WORDS.some((word) => name.slice(compact.length).includes(word))) {
5927
6059
  return true;
5928
6060
  }
5929
- return official.some((entry) => entry instanceof RegExp ? entry.test(registrableDomain(domain)) : entry.includes(".") ? (shared || (0, import_tldts3.parse)(entry).domain !== null) && (domain === entry || domain.endsWith(`.${entry}`)) : name === entry);
6061
+ return official.some((entry) => entry instanceof RegExp ? entry.test(registrableDomain(domain)) : entry.includes(".") ? (shared || (0, import_tldts4.parse)(entry).domain !== null) && (domain === entry || domain.endsWith(`.${entry}`)) : name === entry);
5930
6062
  }
5931
6063
  function headerString(mail, name) {
5932
6064
  const value = mail.headers?.get?.(name);
@@ -6025,7 +6157,7 @@ ${withoutHelp(markup)}`) || attributeText(markup).some(({ text: text2, start, en
6025
6157
  }
6026
6158
  } else if (claimed && isWholeName(name, claimed)) {
6027
6159
  const domain = claimed[1];
6028
- const brand = MAILBOXES.has((0, import_tldts3.parse)(domain).domainWithoutSuffix) ? null : DISPLAY_BRANDS.find((entry) => isOfficial(entry, domain, "", false) && !isOfficial(entry, session.originalFromAddressDomain, ""));
6160
+ const brand = MAILBOXES.has((0, import_tldts4.parse)(domain).domainWithoutSuffix) ? null : DISPLAY_BRANDS.find((entry) => isOfficial(entry, domain, "", false) && !isOfficial(entry, session.originalFromAddressDomain, ""));
6029
6161
  if (brand) {
6030
6162
  hit("FROM_NAME_BRAND", 2, `Display name is an address of "${brand.brand}" but the message is from ${fromRoot}`, "PHISHING");
6031
6163
  }
@@ -6058,10 +6190,10 @@ ${withoutHelp(markup)}`) || attributeText(markup).some(({ text: text2, start, en
6058
6190
  category
6059
6191
  };
6060
6192
  }
6061
- var import_tldts3, GTUBE, BLOCKED_SUBJECT, HACKED_SUBJECT, COIN, CODE, CURRENCY, MONEY, CRYPTOCURRENCY, AMOUNT_DEMAND, AMOUNT_GIVEN, HARM_VERB, HARMED, RECORDING, THREAT, HOURS, COUNT, DEADLINE, isPaymentDemand, BITCOIN_ADDRESS, IGNORE_INSTRUCTIONS, AI_ACTION, AI_OBJECT, AI_ACTION_OBJECT, AI_NEAR, VERDICT, VERDICT_WORD, VERDICT_ORDER, VERDICT_CLAIM, CLASSIFY, SYSTEM_PROMPT, YOU_ARE, QUOTE, CONDITION, DISCUSSED, DISCUSSED_SHOWN, DISCUSSED_NEAR, isDiscussed, isInjection, QUOTE_OR_SPACE, SHOWN_NEAR, isOrder, PAYPAL_DOMAIN, PAYPAL_SPAM_TYPE_IDS, PAYPAL_OWN_REQUEST, PAYPAL_INVOICE_SUBJECT, MS_SPAM_VERDICT, MS_SPAM_CATEGORY, APPLE_NEXT, APPLE_FILLER, APPLE_STRONG, APPLE_BUSINESS, APPLE_ALONE, APPLE_BESIDE, APPLE_PART, NEXT_WORD, MAX_AFTER, APPLE, DISPLAY_BRANDS, ADDRESSES, LIST_HOSTS, VIA_LIST, MAILBOXES, BEFORE_ADDRESS, AFTER_ADDRESS, isWholeName, JOINED_WORDS, MAX_DISPLAY;
6193
+ var import_tldts4, GTUBE, BLOCKED_SUBJECT, HACKED_SUBJECT, COIN, CODE, CURRENCY, MONEY, CRYPTOCURRENCY, AMOUNT_DEMAND, AMOUNT_GIVEN, HARM_VERB, HARMED, RECORDING, THREAT, HOURS, COUNT, DEADLINE, isPaymentDemand, BITCOIN_ADDRESS, IGNORE_INSTRUCTIONS, AI_ACTION, AI_OBJECT, AI_ACTION_OBJECT, AI_NEAR, VERDICT, VERDICT_WORD, VERDICT_ORDER, VERDICT_CLAIM, CLASSIFY, SYSTEM_PROMPT, YOU_ARE, QUOTE, CONDITION, DISCUSSED, DISCUSSED_SHOWN, DISCUSSED_NEAR, isDiscussed, isInjection, QUOTE_OR_SPACE, SHOWN_NEAR, isOrder, PAYPAL_DOMAIN, PAYPAL_SPAM_TYPE_IDS, PAYPAL_OWN_REQUEST, PAYPAL_INVOICE_SUBJECT, MS_SPAM_VERDICT, MS_SPAM_CATEGORY, APPLE_NEXT, APPLE_FILLER, APPLE_STRONG, APPLE_BUSINESS, APPLE_ALONE, APPLE_BESIDE, APPLE_PART, NEXT_WORD, MAX_AFTER, APPLE, DISPLAY_BRANDS, ADDRESSES, LIST_HOSTS, VIA_LIST, MAILBOXES, BEFORE_ADDRESS, AFTER_ADDRESS, isWholeName, JOINED_WORDS, MAX_DISPLAY;
6062
6194
  var init_is_arbitrary = __esm({
6063
6195
  "src/is-arbitrary.js"() {
6064
- import_tldts3 = require("tldts");
6196
+ import_tldts4 = require("tldts");
6065
6197
  init_homograph();
6066
6198
  init_html();
6067
6199
  init_linear();
@@ -7195,7 +7327,7 @@ Answer with exactly one word: ham, spam, phishing, scam or malware.`;
7195
7327
 
7196
7328
  // src/model.js
7197
7329
  function moduleDirectory() {
7198
- return import_node_path.default.dirname((0, import_node_url3.fileURLToPath)(__importMetaUrl));
7330
+ return import_node_path.default.dirname((0, import_node_url4.fileURLToPath)(__importMetaUrl));
7199
7331
  }
7200
7332
  function defaultModelPath(here = moduleDirectory()) {
7201
7333
  if (import_node_process2.default.env.SPAMSCANNER_MODEL) {
@@ -7276,7 +7408,7 @@ function saveModel(classifier, file, options = {}) {
7276
7408
  }
7277
7409
  loaded.delete(resolved);
7278
7410
  }
7279
- var import_node_fs2, import_node_crypto3, import_node_module2, import_node_path, import_node_process2, import_node_url3, loaded;
7411
+ var import_node_fs2, import_node_crypto3, import_node_module2, import_node_path, import_node_process2, import_node_url4, loaded;
7280
7412
  var init_model = __esm({
7281
7413
  "src/model.js"() {
7282
7414
  import_node_fs2 = require("node:fs");
@@ -7284,7 +7416,7 @@ var init_model = __esm({
7284
7416
  import_node_module2 = require("node:module");
7285
7417
  import_node_path = __toESM(require("node:path"), 1);
7286
7418
  import_node_process2 = __toESM(require("node:process"), 1);
7287
- import_node_url3 = require("node:url");
7419
+ import_node_url4 = require("node:url");
7288
7420
  init_classifier();
7289
7421
  loaded = /* @__PURE__ */ new Map();
7290
7422
  }
@@ -7514,7 +7646,7 @@ var init_package = __esm({
7514
7646
  package_default = {
7515
7647
  name: "spamscanner",
7516
7648
  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.",
7517
- version: "7.0.6",
7649
+ version: "7.0.8",
7518
7650
  author: "Forward Email (https://forwardemail.net)",
7519
7651
  bin: {
7520
7652
  spamscanner: "./dist/esm/cli.js"
@@ -9165,7 +9297,8 @@ __export(src_exports, {
9165
9297
  scoreResults: () => scoreResults,
9166
9298
  segmentWords: () => segmentWords,
9167
9299
  spamHeaders: () => spamHeaders,
9168
- train: () => train
9300
+ train: () => train,
9301
+ unwrapLink: () => unwrapLink
9169
9302
  });
9170
9303
  function finding(fields) {
9171
9304
  Object.defineProperty(fields, "toString", {
@@ -9434,14 +9567,16 @@ var init_src = __esm({
9434
9567
  * a header field ("Name: value", or an mbox "From " line) is read as the
9435
9568
  * body of a message with no header, so its text is the input; other
9436
9569
  * input mailparser cannot read gives a message with no text. Other errors
9437
- * are thrown.
9570
+ * are thrown. `error` is the parser's limit the message exceeds, so that
9571
+ * only its first parts were read (as scan() reports it, see parseError),
9572
+ * or null.
9438
9573
  * @param {Buffer|string|Uint8Array|AsyncIterable} source
9439
- * @returns {Promise<{raw: Buffer, mail: object}>}
9574
+ * @returns {Promise<{raw: Buffer, mail: object, error: string|null}>}
9440
9575
  */
9441
9576
  async parse(source) {
9442
9577
  const raw = await toBuffer(source);
9443
- const { mail } = await parseWithin(withHeader(raw));
9444
- return { raw, mail };
9578
+ const { mail, error } = await parseWithin(withHeader(raw));
9579
+ return { raw, mail, error: error ?? null };
9445
9580
  }
9446
9581
  /**
9447
9582
  * Classifier features of a parsed message.
@@ -9574,15 +9709,23 @@ var init_src = __esm({
9574
9709
  message: `A link shows "${link.text}" but goes to ${link.host}`
9575
9710
  }));
9576
9711
  }
9577
- seenHosts.add(destination(link));
9712
+ const name = lookupName(destination(link));
9713
+ if (name) {
9714
+ seenHosts.add(name);
9715
+ }
9578
9716
  }
9579
- for (const link of [...links.filter((link2) => link2.text), ...links.filter((link2) => !link2.text)]) {
9717
+ const found = links.filter((link) => !link.bare);
9718
+ const bare = links.filter((link) => link.bare);
9719
+ for (const link of [...found.filter((link2) => link2.text), ...found.filter((link2) => !link2.text)]) {
9580
9720
  lookalike(destination(link), { url: link.url });
9581
9721
  }
9722
+ for (const link of bare) {
9723
+ lookalike(link.host, { source: "text" });
9724
+ }
9582
9725
  for (const host of hosts) {
9583
9726
  lookalike(host, { source: "text" });
9584
9727
  }
9585
- const ordered = [...new Set([...links.filter((link) => link.text), ...links.filter((link) => !link.text)].map((link) => destination(link)))];
9728
+ const ordered = [...new Set([...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean))];
9586
9729
  const firsts = /* @__PURE__ */ new Map();
9587
9730
  for (const host of ordered) {
9588
9731
  const domain = registrableDomain(host);
@@ -9596,9 +9739,9 @@ var init_src = __esm({
9596
9739
  let answered = 0;
9597
9740
  const cloudflare = async () => {
9598
9741
  await withTimeout(Promise.all(checked.map(async (host) => {
9599
- const found = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
9600
- blocked.push(...found);
9601
- if (!found.unanswered?.length) {
9742
+ const found2 = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
9743
+ blocked.push(...found2);
9744
+ if (!found2.unanswered?.length) {
9602
9745
  answered++;
9603
9746
  }
9604
9747
  })), config.timeout, null);
@@ -9782,7 +9925,7 @@ var init_src = __esm({
9782
9925
  const linkLength = Number.isFinite(config.phishing.maxLength) && config.phishing.maxLength > 0 ? config.phishing.maxLength : 1e6;
9783
9926
  const text = typeof mail.text === "string" ? mail.text : "";
9784
9927
  const html2 = typeof mail.html === "string" ? mail.html : "";
9785
- const checkedLinks = extractLinks(text.slice(0, linkLength), html2.slice(0, linkLength * 2), { maxLength: linkLength * 2 });
9928
+ const checkedLinks = extractLinks(text.slice(0, linkLength), html2.slice(0, linkLength * 2), { maxLength: linkLength * 2, bareHosts: true });
9786
9929
  const linksTruncated = text.length > linkLength || html2.length > linkLength * 2;
9787
9930
  const plainObfuscation = obfuscationOf(`${mail.subject || ""}
9788
9931
  ${typeof mail.text === "string" ? mail.text : ""}`, { maxLength: config.maxLength });
@@ -9793,7 +9936,8 @@ ${visibleText(html2.slice(0, config.maxLength * 2)).text}`, { maxLength: config.
9793
9936
  mixed: Math.max(plainObfuscation.mixed, shownObfuscation.mixed),
9794
9937
  styled: plainObfuscation.styled || shownObfuscation.styled
9795
9938
  };
9796
- const attached = await expandAttachments(mail.attachments, parseErrors);
9939
+ const unopened = [];
9940
+ const attached = await expandAttachments(mail.attachments, parseErrors, void 0, unopened);
9797
9941
  const attachments = config.attachments === false ? [] : inspectAttachments(attached).filter((item) => config.macros !== false || !["macro", "pdf_active", "rtf_object"].includes(item.type)).map((item) => finding(item));
9798
9942
  const [links, authentication, checkedReputation, dnsbl, virusScan, toxicity, nsfw] = await Promise.all([
9799
9943
  this.checkLinks(checkedLinks, config, extracted.hosts, fromDomain(mail)),
@@ -9914,6 +10058,13 @@ ${visibleText(html2.slice(0, config.maxLength * 2)).text}`, { maxLength: config.
9914
10058
  // scanned, as {filename, reason}: the same on every try, so not a
9915
10059
  // failure.
9916
10060
  virusScanSkipped: virusScan.skipped,
10061
+ // Attached messages not opened, past the limits attached messages
10062
+ // are read within (nested more than three deep, more than 100
10063
+ // attachments in them, or more than 25 MB of them), as
10064
+ // {filename, reason}: their attachments were not inspected. Not
10065
+ // scored (forwarded messages can be nested so), for a caller that
10066
+ // needs a message read whole, e.g. to block what it carries.
10067
+ unopenedAttachments: unopened,
9917
10068
  // Whether the message has more text than phishing.maxLength, whose
9918
10069
  // links were not read.
9919
10070
  linksTruncated,
@@ -9923,7 +10074,8 @@ ${visibleText(html2.slice(0, config.maxLength * 2)).text}`, { maxLength: config.
9923
10074
  riskScore: Math.max(0, ...links.homographs.map((item) => item.riskScore))
9924
10075
  }
9925
10076
  },
9926
- links: checkedLinks.map((link) => link.url),
10077
+ // (the links of the message, not hosts found without one)
10078
+ links: checkedLinks.filter((link) => !link.bare).map((link) => link.url),
9927
10079
  language: extracted.language,
9928
10080
  tokens: extracted.words,
9929
10081
  mail,