{"version":3,"sources":["jsdelivr-header.js","/npm/spamscanner@5.1.5/index.js"],"names":[],"mappings":"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","file":"/npm/spamscanner@5.1.5/index.js","sourceRoot":"","sourcesContent":["/**\n * Minified by jsDelivr using Terser v5.39.0.\n * Original file: /npm/spamscanner@5.1.5/index.js\n *\n * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files\n */\n","const dns = require('dns');\nconst fs = require('fs');\nconst path = require('path');\nconst process = require('process');\nconst { debuglog } = require('util');\n\n// eslint-disable-next-line n/no-deprecated-api\nconst punycode = require('punycode');\n\nconst ClamScan = require('clamscan');\nconst FileType = require('file-type');\nconst NaiveBayes = require('@ladjs/naivebayes');\nconst RE2 = require('re2');\nconst arrayJoinConjunction = require('array-join-conjunction');\nconst bitcoinRegex = require('bitcoin-regex');\nconst creditCardRegex = require('credit-card-regex');\nconst emailRegexSafe = require('email-regex-safe');\nconst emojiPatterns = require('emoji-patterns');\nconst escapeStringRegexp = require('escape-string-regexp');\nconst expandContractions = require('@stdlib/nlp-expand-contractions');\nconst fileExtension = require('file-extension');\nconst floatingPointRegex = require('floating-point-regex');\nconst franc = require('franc');\nconst getSymbolFromCurrency = require('currency-symbol-map');\nconst hasha = require('hasha');\nconst hexaColorRegex = require('hexa-color-regex');\nconst i18nLocales = require('i18n-locales');\nconst intoStream = require('into-stream');\nconst ipRegex = require('ip-regex');\nconst isBuffer = require('is-buffer');\nconst isSANB = require('is-string-and-not-blank');\nconst isStream = require('is-stream');\nconst isValidPath = require('is-valid-path');\nconst macRegex = require('mac-regex');\nconst macosVersion = require('macos-version');\nconst memoize = require('memoizee');\nconst mime = require('mime-types');\nconst ms = require('ms');\nconst natural = require('natural');\nconst normalizeUrl = require('normalize-url');\nconst phoneRegex = require('phone-regex');\nconst sanitizeHtml = require('sanitize-html');\nconst snowball = require('node-snowball');\nconst striptags = require('striptags');\nconst superagent = require('superagent');\nconst sw = require('stopword');\nconst toEmoji = require('gemoji/name-to-emoji');\nconst universalify = require('universalify');\nconst urlRegexSafe = require('url-regex-safe');\nconst validator = require('validator');\nconst which = require('which');\nconst { Iconv } = require('iconv');\nconst { codes } = require('currency-codes');\nconst { fromUrl, NO_HOSTNAME } = require('parse-domain');\nconst { parse } = require('node-html-parser');\nconst { simpleParser } = require('mailparser');\n\nconst debug = debuglog('spamscanner');\n\n//\n// NOTE: we periodically need to update this\n//\n// Source from: CC-CEDICT\n// Licensed under Creative Commons Attribution-ShareAlike 4.0 International License\n// <https://www.mdbg.net/chinese/dictionary?page=cc-cedict>\n//\n// <https://github.com/yishn/chinese-tokenizer>\n//\nconst chineseTokenizer = require('chinese-tokenizer').loadFile(\n  path.join(__dirname, 'cedict_1_0_ts_utf-8_mdbg.txt')\n);\n\nconst aggressiveTokenizer = new natural.AggressiveTokenizer();\nconst orthographyTokenizer = new natural.OrthographyTokenizer({\n  language: 'fi'\n});\nconst aggressiveTokenizerFa = new natural.AggressiveTokenizerFa();\nconst aggressiveTokenizerFr = new natural.AggressiveTokenizerFr();\nconst aggressiveTokenizerId = new natural.AggressiveTokenizerId();\nconst aggressiveTokenizerIt = new natural.AggressiveTokenizerIt();\nconst tokenizerJa = new natural.TokenizerJa();\nconst aggressiveTokenizerNo = new natural.AggressiveTokenizerNo();\nconst aggressiveTokenizerPl = new natural.AggressiveTokenizerPl();\nconst aggressiveTokenizerPt = new natural.AggressiveTokenizerPt();\nconst aggressiveTokenizerEs = new natural.AggressiveTokenizerEs();\nconst aggressiveTokenizerSv = new natural.AggressiveTokenizerSv();\nconst aggressiveTokenizerRu = new natural.AggressiveTokenizerRu();\nconst aggressiveTokenizerVi = new natural.AggressiveTokenizerVi();\n\nconst stopwordsEn = new Set([\n  ...require('natural/lib/natural/util/stopwords').words,\n  ...sw.eng\n]);\nconst stopwordsEs = new Set([\n  ...require('natural/lib/natural/util/stopwords_es').words,\n  ...sw.spa\n]);\nconst stopwordsFa = new Set([\n  ...require('natural/lib/natural/util/stopwords_fa').words,\n  ...sw.fas\n]);\nconst stopwordsFr = new Set([\n  ...require('natural/lib/natural/util/stopwords_fr').words,\n  ...sw.fra\n]);\nconst stopwordsId = new Set([\n  ...require('natural/lib/natural/util/stopwords_id').words,\n  ...sw.ind\n]);\nconst stopwordsJa = new Set([\n  ...require('natural/lib/natural/util/stopwords_ja').words,\n  ...sw.jpn\n]);\nconst stopwordsIt = new Set([\n  ...require('natural/lib/natural/util/stopwords_it').words,\n  ...sw.ita\n]);\nconst stopwordsNl = new Set([\n  ...require('natural/lib/natural/util/stopwords_nl').words,\n  ...sw.nld\n]);\nconst stopwordsNo = new Set([\n  ...require('natural/lib/natural/util/stopwords_no').words,\n  ...sw.nob\n]);\nconst stopwordsPl = new Set([\n  ...require('natural/lib/natural/util/stopwords_pl').words,\n  ...sw.pol\n]);\nconst stopwordsPt = new Set([\n  ...require('natural/lib/natural/util/stopwords_pt').words,\n  ...sw.por,\n  ...sw.porBr\n]);\nconst stopwordsRu = new Set([\n  ...require('natural/lib/natural/util/stopwords_ru').words,\n  ...sw.rus\n]);\nconst stopwordsSv = new Set([\n  ...require('natural/lib/natural/util/stopwords_sv').words,\n  ...sw.swe\n]);\nconst stopwordsZh = new Set([\n  ...require('natural/lib/natural/util/stopwords_zh').words,\n  ...sw.zho\n]);\n\nconst stopwordsRon = new Set(sw.ron);\nconst stopwordsTur = new Set(sw.tur);\nconst stopwordsVie = new Set(sw.vie);\nconst stopwordsDeu = new Set(sw.deu);\nconst stopwordsHun = new Set(sw.hun);\nconst stopwordsAra = new Set(sw.ara);\nconst stopwordsDan = new Set(sw.dan);\nconst stopwordsFin = new Set(sw.fin);\n\n// TODO: add stopword pairing for these langs:\n// afr\n// ben\n// bre\n// bul\n// cat\n// ces\n// ell\n// epo\n// est\n// eus\n// fra\n// gle\n// glg\n// guj\n// hau\n// heb\n// hin\n// hrv\n// hye\n// kor\n// kur\n// lat\n// lav\n// lgg\n// lggNd\n// lit\n// mar\n// msa\n// mya\n// panGu\n// slk\n// slv\n// som\n// sot\n// swa\n// tgl\n// tha\n// ukr\n// urd\n// yor\n// zul\n\n// <https://stackoverflow.com/a/41353282>\n// <https://www.ietf.org/rfc/rfc3986.txt>\nconst ENDING_RESERVED_REGEX = new RE2(\n  `[${escapeStringRegexp(\":/?#[]@!$&'()*+,;=\")}]+$`\n);\n\nconst PKG = require('./package.json');\n\nconst VOCABULARY_LIMIT = require('./vocabulary-limit.js');\n\n// TODO: convert this into a Map\nconst ISO_CODE_MAPPING = require('./iso-code-mapping.json');\n\nconst ISO_CODE_MAPPING_KEYS = Object.keys(ISO_CODE_MAPPING);\n\n// <https://kb.smarshmail.com/Article/23567>\nconst EXECUTABLES = new Set(require('./executables.json'));\n\nconst REPLACEMENT_WORDS = require('./replacement-words.json');\n\nconst locales = new Set(i18nLocales.map((l) => l.toLowerCase()));\n\nconst normalizeUrlOptions = {\n  stripProtocol: true,\n  stripWWW: false,\n  removeQueryParameters: [],\n  removeTrailingSlash: true,\n  sortQueryParameters: false\n};\n\n// <https://stackoverflow.com/a/15926317>\n// <https://github.com/uhop/node-re2#backreferences>\n// <https://stackoverflow.com/a/26764609>\n// <https://stackoverflow.com/a/9158444>\nconst ANCHOR_REGEX = new RE2(/<a.*?>.*?<\\/a>/gi);\n\n// <https://github.com/mathiasbynens/emoji-regex/issues/59#issuecomment-640418649>\nconst EMOJI_REGEX = new RE2(emojiPatterns.Emoji_All, 'gu');\nconst FLOATING_POINT_REGEX = new RE2(floatingPointRegex());\nconst CC_REGEX = new RE2(creditCardRegex());\nconst PHONE_REGEX = new RE2(phoneRegex());\nconst BITCOIN_REGEX = new RE2(bitcoinRegex());\nconst MAC_REGEX = new RE2(macRegex());\nconst HEXA_COLOR_REGEX = new RE2(hexaColorRegex());\n\n// <https://github.com/NaturalNode/natural/issues/523#issuecomment-623287047>\n// <https://github.com/yoshuawuyts/newline-remove>\nconst NEWLINE_REGEX = new RE2(/\\r\\n|\\n|\\r/gm);\n// <https://stackoverflow.com/a/5917217>\nconst NUMBER_REGEX = new RE2(/\\d[\\d,.]*/g);\n\n// NOTE: we use my package url-safe-regex instead\n// of url-regex due to CVE advisory among other issues\n// https://github.com/niftylettuce/url-regex-safe\nconst URL_REGEX = urlRegexSafe();\n\n// NOTE: we use my package email-safe-regex instead\n// of email-regex due to several issues\n// https://github.com/niftylettuce/email-regex-safe\nconst EMAIL_REGEX = emailRegexSafe();\n\n// <https://superuser.com/a/1182181>\nconst INITIALISM_REGEX = new RE2(/\\b(?:[A-Z][a-z]*){2,}/g);\n// <https://stackoverflow.com/q/35076016>\nconst ABBREVIATION_REGEX = new RE2(/\\b(?:[a-zA-Z]\\.){2,}/g);\n\nconst currencySymbols = [];\nfor (const code of codes()) {\n  const symbol = getSymbolFromCurrency(code);\n  if (\n    typeof symbol === 'string' &&\n    // eslint-disable-next-line unicorn/prefer-includes\n    currencySymbols.indexOf(symbol) === -1 &&\n    !new RE2(/^[a-z]+$/i).test(symbol)\n  )\n    currencySymbols.push(escapeStringRegexp(symbol));\n}\n\nconst CURRENCY_REGEX = new RE2(new RegExp(currencySymbols.join('|'), 'g'));\n\n// <https://spamassassin.apache.org/gtube/>\n// <https://spamassassin.apache.org/gtube/gtube.txt>\nconst GTUBE =\n  'XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X';\n\nconst MAIL_PHISHING_PROPS = ['subject', 'from', 'to', 'cc', 'bcc', 'text'];\nconst TOKEN_HEADERS = [...MAIL_PHISHING_PROPS, 'html'];\n\n// <https://github.com/sindresorhus/ip-regex>\nconst IP_REGEX = new RE2(ipRegex());\n\n// strip zero-width characters\n// <https://github.com/peerigon/parse-domain/issues/116>\nconst ZERO_WIDTH_REGEX = new RE2(/[\\u{200B}-\\u{200D}]/gu);\n\nconst isURLOptions = {\n  require_tld: false,\n  require_protocol: false,\n  require_host: false,\n  require_valid_protocol: false\n};\n\nclass SpamScanner {\n  constructor(config = {}) {\n    this.config = {\n      debug:\n        process.env.NODE_ENV === 'test' ||\n        process.env.NODE_ENV === 'development',\n      checkIDNHomographAttack: false,\n      // note that if you attempt to train an existing `scanner.classifier`\n      // then you will need to re-use these, so we suggest you store them\n      replacements: config.replacements || require('./replacements.js'),\n      // <https://nodemailer.com/extras/mailparser/>\n      // NOTE: `iconv` package's Iconv cannot be used in worker threads\n      // AND it can not also be shared in worker threads either (e.g. cloned)\n      // <https://github.com/bnoordhuis/node-iconv/issues/211>\n      // BUT we MUST use it because otherwise emails won't get parsed\n      simpleParser: { Iconv },\n      // <https://github.com/NaturalNode/natural#bayesian-and-logistic-regression>\n      // (ham) + a few other datasets\n      // `wget --mirror --passive-ftp ftp://ftp.ietf.org/ietf-mail-archive`\n      // `wget --mirror --passive-ftp ftp://ftp.ietf.org/concluded-wg-ietf-mail-archive`\n      // (spam dataset is private at the moment)\n      classifier: config.classifier || require('./get-classifier.js'),\n      // default locale validated against i18n-locales\n      locale: 'en',\n      // we recommend to use axe/cabin, see https://cabinjs.com\n      logger: console,\n      // <https://github.com/apostrophecms/sanitize-html#what-are-the-default-options>\n      // <https://developer.mozilla.org/en-US/docs/Web/HTML/Element>\n      sanitizeHtml: {\n        allowedTags: [\n          'address',\n          'article',\n          'aside',\n          'footer',\n          'header',\n          'h1',\n          'h2',\n          'h3',\n          'h4',\n          'h5',\n          'h6',\n          'hgroup',\n          'nav',\n          'section',\n          'blockquote',\n          'dd',\n          'div',\n          'dl',\n          'dt',\n          'figcaption',\n          'figure',\n          'hr',\n          'li',\n          'main',\n          'ol',\n          'p',\n          'pre',\n          'ul',\n          'a',\n          'abbr',\n          'b',\n          'bdi',\n          'bdo',\n          'br',\n          'cite',\n          'code',\n          'data',\n          'dfn',\n          'em',\n          'i',\n          'kbd',\n          'mark',\n          'q',\n          'rp',\n          'rt',\n          'rtc',\n          'ruby',\n          's',\n          'samp',\n          'span',\n          'strong',\n          'sub',\n          'sup',\n          'time',\n          'u',\n          'var',\n          'wbr',\n          // area\n          // audio\n          // img\n          // map\n          // track\n          // video\n          // embed\n          // iframe\n          // object\n          // param\n          // picture\n          // source\n          // canvas\n          // noscript\n          // script\n          'del',\n          'ins',\n          'caption',\n          'col',\n          'colgroup',\n          'table',\n          'tbody',\n          'td',\n          'tfoot',\n          'th',\n          'thead',\n          'tr',\n          // NO FORM STUFF\n          'details',\n          'dalog',\n          'menu',\n          'summary',\n          // slot\n          // template\n          'center',\n          'marquee',\n          'strike'\n        ],\n        allowedAttributes: false\n      },\n      userAgent: `${PKG.name}/${PKG.version}`,\n      timeout: ms('10s'),\n      clamscan: {\n        debugMode:\n          process.env.NODE_ENV === 'test' ||\n          process.env.NODE_ENV === 'development',\n        clamscan: {\n          path: which.sync('clamscan', { nothrow: true })\n        },\n        clamdscan: {\n          timeout: ms('10s'),\n          path: which.sync('clamdscan', { nothrow: true }),\n          socket: macosVersion.isMacOS\n            ? '/tmp/clamd.socket'\n            : '/var/run/clamav/clamd.ctl'\n        }\n      },\n      hasha: {\n        algorithm: 'sha256'\n      },\n      vocabularyLimit: VOCABULARY_LIMIT,\n      // <https://github.com/medikoo/memoizee#expire-cache-after-given-period-of-time>\n      memoize: {\n        ...config.memoize,\n        // override always\n        promise: true\n      },\n      // @ladjs/redis client instance\n      client: false,\n      cachePrefix: 'spamscanner',\n      ttlMs: ms('1h'),\n      // franc\n      franc: {\n        minLength: 5,\n        only: ISO_CODE_MAPPING_KEYS\n      },\n      // if franc detects multiple languages that have >= % threshold\n      // then if the locale detected was one of them, what is the probability\n      // it must have in order to override all the other matches\n      detectedLocaleOverrideProbability: 0.9,\n      ...config\n    };\n\n    // ensure all replacements are there\n    if (typeof this.config.replacements !== 'object')\n      throw new Error('Replacements missing');\n\n    for (const replacement of REPLACEMENT_WORDS) {\n      if (!isSANB(this.config.replacements[replacement]))\n        throw new Error(`Replacement for \"${replacement}\" missing`);\n    }\n\n    this.classifier =\n      typeof this.config.classifier === 'object'\n        ? this.config.classifier\n        : typeof this.config.classifier === 'string'\n        ? isValidPath(this.config.classifier)\n          ? require(this.config.classifier)\n          : JSON.parse(this.config.classifier)\n        : false;\n\n    this.classifier = NaiveBayes.fromJson(\n      this.classifier,\n      this.config.vocabularyLimit\n    );\n    // since we do tokenization ourselves\n    this.classifier.tokenizer = function (tokens) {\n      return tokens;\n    };\n\n    this.clamscan = this.config.clamscan === false ? false : new ClamScan();\n\n    this.getTokensAndMailFromSource = universalify.fromPromise(\n      this.getTokensAndMailFromSource.bind(this)\n    );\n    this.getPhishingResults = this.getPhishingResults.bind(this);\n    // this.getNSFWResuls = universalify.fromPromise(this.getNSFWResults.bind(this));\n    this.getExecutableResults = universalify.fromPromise(\n      this.getExecutableResults.bind(this)\n    );\n    this.scan = universalify.fromPromise(this.scan.bind(this));\n    this.getTokens = this.getTokens.bind(this);\n    this.parseLocale = this.parseLocale.bind(this);\n    this.getNormalizedUrl = this.getNormalizedUrl.bind(this);\n    this.getUrls = this.getUrls.bind(this);\n    this.malwareLookup = this.malwareLookup.bind(this);\n    this.isCloudflareBlocked = this.isCloudflareBlocked.bind(this);\n    this.getArbitraryResults = this.getArbitraryResults.bind(this);\n    this.getVirusResults = universalify.fromPromise(\n      this.getVirusResults.bind(this)\n    );\n    this.getHostname = this.getHostname.bind(this);\n    this.getClassification = this.getClassification.bind(this);\n\n    // memoized methods (either uses Redis or in-memory cache)\n    if (this.config.client)\n      this.memoizedIsCloudflareBlocked = async function (name) {\n        const key = `${this.config.cachePrefix}:${name}`;\n        try {\n          const value = await this.config.client.get(key);\n          if (value) {\n            let array = value.split(':');\n            if (array.length !== 2)\n              throw new Error('Length was not exactly two');\n\n            array = array.map((value_) => value_ === 'true');\n            return { isAdult: array[0], isMalware: array[1] };\n          }\n        } catch (err) {\n          this.config.logger.error(err);\n        }\n\n        const { isAdult, isMalware } = await this.isCloudflareBlocked(name);\n\n        // cache in the background\n        this.config.client\n          .set(key, `${isAdult}:${isMalware}`, 'PX', this.config.ttlMs)\n          .then(this.config.logger.info)\n          .catch(this.config.logger.error);\n        return { isAdult, isMalware };\n      };\n    else\n      this.memoizedIsCloudflareBlocked = memoize(\n        this.isCloudflareBlocked,\n        this.config.memoize\n      );\n\n    if (!locales.has(this.parseLocale(this.config.locale)))\n      throw new Error(\n        `Locale of ${this.config.locale} was not valid according to locales list.`\n      );\n\n    //\n    // set up regex helpers\n    //\n    this.EMAIL_REPLACEMENT_REGEX = new RE2(this.config.replacements.email, 'g');\n    const replacementRegexes = [];\n    for (const key of Object.keys(this.config.replacements)) {\n      replacementRegexes.push(\n        escapeStringRegexp(this.config.replacements[key])\n      );\n    }\n\n    this.REPLACEMENTS_REGEX = new RE2(\n      new RegExp(replacementRegexes.join('|'), 'g')\n    );\n\n    //\n    // set up helper Map and Sets for fast lookup\n    // (Set.has is 2x faster than includes, and 50% faster than indexOf)\n    //\n    this.WHITELISTED_WORDS = new Set(Object.values(this.config.replacements));\n  }\n\n  getHostname(link) {\n    link = link.trim().replace(/\\.+$/, '').toLowerCase();\n\n    // strip zero-width characters\n    // <https://github.com/peerigon/parse-domain/issues/116>\n    link = link.replace(ZERO_WIDTH_REGEX, '');\n\n    // if it was not a valid URL then ignore it\n    if (!validator.isURL(link, isURLOptions)) return;\n\n    // <https://github.com/peerigon/parse-domain/issues/114>\n    if (validator.isIP(link)) return link;\n\n    // uses `new Url` (e.g. it adds http:// if it does not exist)\n    let unicode = link;\n    try {\n      unicode = punycode.toUnicode(link);\n    } catch (err) {\n      /*\n      Overflow: input needs wider integers to process\n      RangeError: Overflow: input needs wider integers to process\n         at error (punycode.js:42:8)\n         at decode (punycode.js:241:5)\n         at punycode.js:389:6\n         at map (punycode.js:57:20)\n         at mapDomain (punycode.js:84:18)\n         at Object.toUnicode (punycode.js:387:9)\n         at SpamScanner.getHostname\n      */\n      this.config.logger.warn(err);\n    }\n\n    // NOTE: IPv6 is not currently working with parse-domain and I already filed an issue\n    // <https://github.com/peerigon/parse-domain/issues/114>\n    const url = fromUrl(unicode);\n    if (url === NO_HOSTNAME) {\n      // use ipv4 and ipv6 regex to get just the value\n      const matches = link.match(IP_REGEX) || [];\n      if (matches.length > 0) {\n        if (matches.length > 1)\n          this.config.logger.error(\n            new Error(\n              `${link} had more than one match for IPv4/IPv6: ${matches.join(\n                ', '\n              )}`\n            )\n          );\n        return matches[0];\n      }\n\n      // if there were still no matches, then check if it was a phone number\n      if (new RE2(phoneRegex()).test(link)) return;\n\n      // if it was a file path, then ignore it\n      if (isValidPath(link)) return;\n\n      // it was most likely invalid as it was just \"foo\" or it started with a slash like \"/newsletter/unsubscribe\"\n      // this code should never be reached, but just in case we should know if something is weird\n      this.config.logger.warn(\n        new Error(`${link} was invalid and did not have a hostname`)\n      );\n      return;\n    }\n\n    return url;\n  }\n\n  getClassification(tokens) {\n    return Promise.resolve(this.classifier.categorize(tokens, true));\n  }\n\n  async getVirusResults(mail) {\n    const messages = [];\n\n    if (!this.clamscan) {\n      debug('clamscan disabled');\n      return messages;\n    }\n\n    if (!Array.isArray(mail.attachments) || mail.attachments.length === 0)\n      return messages;\n\n    try {\n      // if it was already loaded, clamscan won't reload itself\n      // it has logic built-in to return early with the already initialized instance\n      const clamscan = await this.clamscan.init(this.config.clamscan);\n\n      await Promise.all(\n        mail.attachments.map(async (attachment, i) => {\n          try {\n            const stream = isStream(attachment.content)\n              ? attachment.content\n              : intoStream(attachment.content);\n            const { isInfected, viruses } = await clamscan.scanStream(stream);\n            const name = isSANB(attachment.filename)\n              ? `\"${attachment.filename}\"`\n              : `#${i + 1}`;\n            if (isInfected)\n              messages.push(\n                `Attachment ${name} was infected with ${arrayJoinConjunction(\n                  viruses\n                )}.`\n              );\n          } catch (err) {\n            this.config.logger.error(err);\n          }\n        })\n      );\n    } catch (err) {\n      this.config.logger.error(err);\n    }\n\n    return messages;\n  }\n\n  getArbitraryResults(mail) {\n    const messages = [];\n\n    let gtube = false;\n\n    // eslint-disable-next-line unicorn/prefer-includes\n    if (isSANB(mail.html) && mail.html.indexOf(GTUBE) !== -1) gtube = true;\n\n    // eslint-disable-next-line unicorn/prefer-includes\n    if (isSANB(mail.text) && !gtube && mail.text.indexOf(GTUBE) !== -1)\n      gtube = true;\n\n    if (gtube)\n      messages.push(\n        'Message detected to contain the GTUBE test from https://spamassassin.apache.org/gtube/.'\n      );\n\n    return messages;\n  }\n\n  // pass this a DNS over HTTPS endpoint to lookup for 0.0.0.0 result\n  async malwareLookup(endpoint, name) {\n    try {\n      const response = await superagent\n        .get(endpoint)\n        .query({\n          name,\n          type: 'A'\n        })\n        .timeout(this.config.timeout)\n        .set('Accept', 'application/dns-json')\n        .set('User-Agent', this.config.userAgent)\n        .send();\n      const body = JSON.parse(response.body);\n      return (\n        Array.isArray(body.Answer) &&\n        body.Answer.length === 1 &&\n        body.Answer[0].data === '0.0.0.0'\n      );\n    } catch (err) {\n      this.config.logger.error(err);\n      try {\n        //\n        // NOTE: note that in newer Node versions we will\n        // be able to configure a DNS lookup timeout\n        // and we should also support this DNS + fallback approach\n        // in Forward Email at some point in the future\n        // and additionally ensure that the DNS lookup fallback\n        // uses DNS over TLS with DNSSEC (+ documentation for self hosting)\n        //\n        const records = await dns.promises.resolve4(name);\n        return (\n          Array.isArray(records) &&\n          records.length === 1 &&\n          records[0] === '0.0.0.0'\n        );\n      } catch (err) {\n        this.config.logger.warn(err);\n        return false;\n      }\n    }\n  }\n\n  //\n  // DNS over HTTPS with Cloudflare for Family\n  // <https://one.one.one.one/family/>\n  // <https://developers.cloudflare.com/1.1.1.1/1.1.1.1-for-families/setup-instructions/dns-over-https/>\n  // <https://developers.cloudflare.com/1.1.1.1/dns-over-https/json-format/>\n  //\n  // curl -H 'accept: application/dns-json' 'https://family.cloudflare-dns.com/dns-query?name=phishing.example.com&type=A'\n  // {\"Status\":0,\"TC\":false,\"RD\":true,\"RA\":true,\"AD\":false,\"CD\":false,\"Question\":[{\"name\":\"phishing.example.com\",\"type\":1}],\"Answer\":[{\"name\":\"phishing.example.com\",\"type\":1,\"TTL\":60,\"data\":\"0.0.0.0\"}]}\n  //\n  async isCloudflareBlocked(name) {\n    //\n    // NOTE: this uses DNS over HTTPS with a fallback system-level DNS lookup\n    // (which would assume you are using either 1.1.1.2 + 1.0.0.2 OR 1.1.1.3 + 1.0.0.3)\n    // <https://developers.cloudflare.com/1.1.1.1/1.1.1.1-for-families>\n    //\n    // However we don't recommend this and therefore have our servers set to standard Cloudflare DNS\n    //\n    const [isAdult, isMalware] = await Promise.all([\n      this.malwareLookup('https://family.cloudflare-dns.com/dns-query', name),\n      this.malwareLookup('https://security.cloudflare-dns.com/dns-query', name)\n    ]);\n\n    return { isAdult, isMalware };\n  }\n\n  //\n  // due to this issue and the fact that PhishTank adds invalid URL's\n  // we have to implement our own workaround to normalize a valid/invalid URL\n  // <https://github.com/sindresorhus/normalize-url/issues/111>\n  //\n  getNormalizedUrl(url) {\n    url = url.trim().replace(/\\.+$/, '').toLowerCase();\n\n    // strip zero-width characters\n    // <https://github.com/peerigon/parse-domain/issues/116>\n    url = url.replace(ZERO_WIDTH_REGEX, '');\n\n    // don't return a URL if it was invalid after being trimmed\n    if (!validator.isURL(url, isURLOptions)) return;\n\n    let normalized = url;\n    try {\n      normalized = normalizeUrl(url, normalizeUrlOptions);\n    } catch (err) {\n      this.config.logger.error(err);\n      // <https://stackoverflow.com/questions/6680825/return-string-without-trailing-slash#comment11853012_6680877>\n      normalized = url\n        .replace('http://', '')\n        .replace('https://', '')\n        .replace(/\\/+$/, '');\n    }\n\n    const hostname = fromUrl(normalized);\n    if (hostname === NO_HOSTNAME) {\n      this.config.logger.error(\n        new Error(`No hostname (URL: ${url}, NORMALIZED: ${normalized}`)\n      );\n\n      // if there were still no matches, then check if it was a phone number\n      if (new RE2(phoneRegex()).test(normalized)) return;\n\n      // if it was a file path, then ignore it\n      if (isValidPath(normalized)) return;\n\n      return normalized;\n    }\n\n    let unicode = hostname;\n\n    try {\n      unicode = punycode.toUnicode(hostname);\n    } catch (err) {\n      this.config.logger.warn(err);\n    }\n\n    try {\n      const object = new URL(\n        `http://${unicode}${normalized.slice(hostname.length)}`\n      );\n\n      //\n      // NOTE: we must strip reserved characters from the end of the string\n      // (even Gmail does this practice)\n      // <https://github.com/kevva/url-regex/issues/71>\n      const pathname = object.pathname.replace(ENDING_RESERVED_REGEX, '');\n\n      //\n      // NOTE: we strip querystring and hash here as we don't consider them\n      // which may ultimately lead to false positives on mass emailing services\n      // however we are taking this approach because these services should\n      // be enforcing strict anti-spam policies and prevention measures anyways\n      // (e.g. we will send them 429 retry or spam complaint status code\n      // which will in turn alert them to the issue with whoever is spamming)\n      //\n      return `${object.hostname}${pathname === '/' ? '' : pathname}`;\n    } catch (err) {\n      this.config.logger.error(err, { url, normalized });\n      return normalized;\n    }\n  }\n\n  // <https://github.com/kevva/url-regex/issues/70\n  // <https://github.com/sindresorhus/get-urls/blob/master/index.js\n  // <https://github.com/kevva/url-regex/issues/71>\n  // <https://github.com/kevva/url-regex/pull/35>\n  //\n  getUrls(text) {\n    // before we filter for URL's, we need to replace email addresses\n    // with hostnames, otherwise there will be incorrect matches\n    // due to the inaccuracy (which is unpreventable) from url-regex-safe\n    // when parsing email addresses, e.g. foo.it.beep.mx.bar@gmail.com\n    // would normally get parsed to [ foo.it, beep.mx, gmail.com ]\n    // but it should only get parsed to gmail.com since that's the hostname\n    // <https://github.com/kevva/url-regex/pull/67>\n    const urls =\n      text\n        .replace(NEWLINE_REGEX, ' ')\n        .replace(\n          EMAIL_REGEX,\n          (match) =>\n            ` ${this.getHostname(match.slice(match.lastIndexOf('@') + 1))} `\n        )\n        .replace(URL_REGEX, (match, offset, string) => {\n          const nextChar = string.slice(\n            offset + match.length,\n            offset + match.length + 1\n          );\n          if (new RE2(/^\\w$/).test(nextChar)) return ' ';\n\n          // only return a match if the case was the same when converted\n          // (this matches Gmail's behavior in parsing/rendering URL's)\n          if (match.toLowerCase() !== match && match.toUpperCase() !== match)\n            return ' ';\n\n          return ` ${match} `;\n        })\n        .match(URL_REGEX) || [];\n\n    const array = new Set();\n    for (const url of urls) {\n      const normalized = this.getNormalizedUrl(url);\n\n      if (normalized) array.add(normalized);\n    }\n\n    return [...array];\n  }\n\n  parseLocale(locale) {\n    // convert `franc` locales here to their locale iso2 normalized name\n    return locale.toLowerCase().split('-')[0].split('_')[0];\n  }\n\n  // <https://medium.com/analytics-vidhya/building-a-spam-filter-from-scratch-using-machine-learning-fc58b178ea56>\n  // <https://towardsdatascience.com/empirical-analysis-on-email-classification-using-the-enron-dataset-19054d558697>\n  // <https://blog.logrocket.com/natural-language-processing-for-node-js/>\n  // <https://github.com/NaturalNode/natural#stemmers>\n  // eslint-disable-next-line complexity\n  async getTokens(string, locale, isHTML = false) {\n    //\n    // parse HTML for <html> tag with lang attr\n    // otherwise if that wasn't found then look for this\n    // <meta http-equiv=\"Content-Language\" content=\"en-us\">\n    //\n    if (!locale && isHTML) {\n      const root = parse(string);\n\n      const metas = root.querySelectorAll('meta');\n\n      for (const meta of metas) {\n        if (\n          meta.getAttribute('http-equiv') === 'Content-Language' &&\n          isSANB(meta.getAttribute('content')) &&\n          locales.has(this.parseLocale(meta.getAttribute('content')))\n        ) {\n          locale = this.parseLocale(meta.getAttribute('content'));\n          break;\n        }\n      }\n\n      const _metas = root.querySelectorAll('META');\n\n      for (const meta of _metas) {\n        if (\n          meta.getAttribute('http-equiv') === 'Content-Language' &&\n          isSANB(meta.getAttribute('content')) &&\n          locales.has(this.parseLocale(meta.getAttribute('content')))\n        ) {\n          locale = this.parseLocale(meta.getAttribute('content'));\n          break;\n        }\n      }\n\n      if (!locale) {\n        const html = root.querySelector('html') || root.querySelector('HTML');\n        if (\n          html &&\n          isSANB(html.getAttribute('lang')) &&\n          locales.has(this.parseLocale(html.getAttribute('lang')))\n        )\n          locale = this.parseLocale(html.getAttribute('lang'));\n      }\n    }\n\n    if (isHTML) string = sanitizeHtml(string, this.config.sanitizeHtml);\n\n    string = striptags(string, [], ' ')\n      .trim()\n      // replace newlines\n      .replace(NEWLINE_REGEX, ' ')\n      //\n      // attackers may try to inject our replacements into the message\n      // therefore we should strip all of them before doing any replacements\n      //\n      .replace(this.REPLACEMENTS_REGEX, ' ');\n\n    //\n    // we should instead use language detection to determine\n    // what language/locale this message is in (as opposed to relying on headers)\n    // which could get arbitrarily modified by an attacker\n    // <https://github.com/wooorm/franc/issues/86> (accurate with min length)\n    // <https://github.com/FGRibreau/node-language-detect> (not too accurate)\n    //\n    const detectedLanguages = franc.all(string, this.config.franc);\n    if (Array.isArray(detectedLanguages) && detectedLanguages.length > 0) {\n      let detected = this.config.locale;\n      let probability = 0;\n      for (const lang of detectedLanguages) {\n        // if it was undetermined then break out and revert to default (English)\n        if (lang[0] && lang[0] === 'und') break;\n\n        //\n        // otherwise only use detected languages that have >= 90% accuracy\n        // and if no matches were found, the revert to use English as it's most likely spam\n        // (we can assume that users would understand a different language sent to them is spam)\n        // (so we can assume that language is spoofed to bypass English, the most widely spoken)\n        //\n        if (lang[0] && ISO_CODE_MAPPING[lang[0]] && lang[1]) {\n          // we don't want to check anything lower than our threshold\n          if (lang[1] < this.config.detectedLocaleOverrideProbability) break;\n          if (probability >= lang[1]) {\n            // exit early since we found a match that matched the passed locale\n            // eslint-disable-next-line max-depth\n            if (locale && locale === ISO_CODE_MAPPING[lang[0]]) {\n              detected = locale;\n              probability = lang[1];\n              break;\n            }\n          } else {\n            detected = ISO_CODE_MAPPING[lang[0]];\n            probability = lang[1];\n          }\n        }\n      }\n\n      // override the locale based off detected\n      locale = detected;\n    }\n\n    locale = this.parseLocale(isSANB(locale) ? locale : this.config.locale);\n\n    // NOTE: \"in\" and \"po\" are valid locales but not from i18n\n    if (!locales.has(locale) && locale !== 'in' && locale !== 'po') {\n      debug(`Locale ${locale} was not valid and will use default`);\n      locale = this.parseLocale(this.config.locale);\n    }\n\n    // TODO: add new languages <https://github.com/hthetiot/node-snowball/pull/21/commits/3871acf1f38b00960929545bc8ab5f591f50c024>\n    // <https://github.com/hthetiot/node-snowball#supported-language-second-argument>\n    // <https://github.com/NaturalNode/natural#tokenizers>\n    let tokenizer = aggressiveTokenizer;\n    let stopwords = stopwordsEn;\n    let language = 'english';\n    let stemword = 'default';\n\n    switch (locale) {\n      case 'ar':\n        // arb\n        // ISO 639-3 = ara\n        stopwords = stopwordsAra;\n        language = 'arabic';\n        break;\n      case 'da':\n        // dan\n        language = 'danish';\n        stopwords = stopwordsDan;\n        break;\n      case 'nl':\n        // nld\n        stopwords = stopwordsNl;\n        language = 'dutch';\n        break;\n      case 'en':\n        // eng\n        language = 'english';\n        break;\n      case 'fi':\n        // fin\n        language = 'finnish';\n        tokenizer = orthographyTokenizer;\n        stopwords = stopwordsFin;\n        break;\n      case 'fa':\n        // fas (Persian/Farsi)\n        language = 'farsi';\n        tokenizer = aggressiveTokenizerFa;\n        stopwords = stopwordsFa;\n        stemword = natural.PorterStemmerFa.stem.bind(natural.PorterStemmerFa);\n        break;\n      case 'fr':\n        // fra\n        language = 'french';\n        tokenizer = aggressiveTokenizerFr;\n        stopwords = stopwordsFr;\n        break;\n      case 'de':\n        // deu\n        language = 'german';\n        stopwords = stopwordsDeu;\n        break;\n      case 'hu':\n        // hun\n        language = 'hungarian';\n        stopwords = stopwordsHun;\n        break;\n      case 'in':\n        // ind\n        language = 'indonesian';\n        tokenizer = aggressiveTokenizerId;\n        stopwords = stopwordsId;\n        break;\n      case 'it':\n        // ita\n        language = 'italian';\n        tokenizer = aggressiveTokenizerIt;\n        stopwords = stopwordsIt;\n        break;\n      case 'ja':\n        // jpn\n        tokenizer = tokenizerJa;\n        stopwords = stopwordsJa;\n        stemword = natural.StemmerJa.stem.bind(natural.StemmerJa);\n        break;\n      case 'nb':\n        // nob\n        language = 'norwegian';\n        tokenizer = aggressiveTokenizerNo;\n        stopwords = stopwordsNo;\n        break;\n      case 'nn':\n        // nno\n        // ISO 639-3 = nob\n        language = 'norwegian';\n        tokenizer = aggressiveTokenizerNo;\n        stopwords = stopwordsNo;\n        break;\n      case 'po':\n        // pol\n        language = 'polish';\n        tokenizer = aggressiveTokenizerPl;\n        stopwords = stopwordsPl;\n        stemword = false;\n        break;\n      case 'pt':\n        // por\n        language = 'portuguese';\n        tokenizer = aggressiveTokenizerPt;\n        stopwords = stopwordsPt;\n        break;\n      case 'es':\n        // spa\n        language = 'spanish';\n        tokenizer = aggressiveTokenizerEs;\n        stopwords = stopwordsEs;\n        break;\n      case 'sv':\n        // swe\n        language = 'swedish';\n        tokenizer = aggressiveTokenizerSv;\n        stopwords = stopwordsSv;\n        break;\n      case 'ro':\n        // ron\n        language = 'romanian';\n        stopwords = stopwordsRon;\n        break;\n      case 'ru':\n        // rus\n        language = 'russian';\n        tokenizer = aggressiveTokenizerRu;\n        stopwords = stopwordsRu;\n        break;\n      case 'ta':\n        // tam\n        // NOTE: no stopwords available\n        language = 'tamil';\n        break;\n      case 'tr':\n        // tur\n        language = 'turkish';\n        stopwords = stopwordsTur;\n        break;\n      case 'vi':\n        // vie\n        language = 'vietnamese';\n        tokenizer = aggressiveTokenizerVi;\n        stopwords = stopwordsVie;\n        stemword = false;\n        break;\n      case 'zh':\n        // cmn\n        // ISO 639-3 = zho (Chinese, Macrolanguage)\n        // https://github.com/yishn/chinese-tokenizer\n        tokenizer = {\n          tokenize: (str) =>\n            chineseTokenizer(str).map((results) => results.text)\n        };\n        language = 'chinese';\n        stopwords = stopwordsZh;\n        stemword = false;\n        break;\n      default:\n    }\n\n    if (stemword === 'default')\n      stemword = (t) => snowball.stemword(t, language);\n\n    string =\n      // handle emojis\n      // - convert github emojis to unicode 13 emojis\n      // - replace all unicode emojis\n      string\n        .split(' ')\n        .map((_string) =>\n          _string.indexOf(':') === 0 &&\n          _string.endsWith(':') &&\n          typeof toEmoji[_string.slice(1, -1)] === 'string'\n            ? toEmoji[_string.slice(1, -1)]\n            : _string\n        )\n        .join(' ')\n        .replace(EMOJI_REGEX, ` ${this.config.replacements.emoji} `)\n\n        // https://github.com/regexhq/mac-regex\n        .replace(MAC_REGEX, ` ${this.config.replacements.mac} `)\n\n        // https://github.com/kevva/credit-card-regex\n        .replace(CC_REGEX, ` ${this.config.replacements.cc} `)\n\n        // https://github.com/kevva/bitcoin-regex\n        .replace(BITCOIN_REGEX, ` ${this.config.replacements.bitcoin} `)\n\n        // https://github.com/regexhq/phone-regex\n        .replace(PHONE_REGEX, ` ${this.config.replacements.phone} `)\n\n        // handle hex colors\n        // https://github.com/regexhq/hexa-color-regex\n        .replace(HEXA_COLOR_REGEX, ` ${this.config.replacements.hexa} `)\n\n        // handle initialism(e.g. \"AFK\" -> \"abbrev$crypto$afk\")\n        .replace(\n          INITIALISM_REGEX,\n          (s) => ` ${this.config.replacements.initialism}${s} `\n        )\n\n        // handle abbreviations (e.g. \"u.s.\" -> \"abbrev$crypto$us\")\n        // (note we have to replace the periods here)\n        .replace(\n          ABBREVIATION_REGEX,\n          (s) =>\n            ` ${this.config.replacements.abbreviation}${s.split('.').join('')} `\n        )\n\n        // NOTE: replacement of email addresses must come BEFORE urls\n        // replace email addresses\n        .replace(EMAIL_REGEX, this.config.replacements.email)\n\n        // replace urls\n        .replace(URL_REGEX, ` ${this.config.replacements.url} `)\n\n        // now we ensure that URL's and EMAIL's are properly spaced out\n        // (e.g. in case ?email=some@email.com was in a URL)\n        .replace(\n          this.EMAIL_REPLACEMENT_REGEX,\n          ` ${this.config.replacements.email} `\n        )\n\n        // TODO: replace file paths, file dirs, dotfiles, and dotdirs\n\n        // replace numbers\n        // https://github.com/regexhq/floating-point-regex\n        .replace(FLOATING_POINT_REGEX, ` ${this.config.replacements.number} `)\n        .replace(NUMBER_REGEX, ` ${this.config.replacements.number} `)\n\n        // TODO: may want to do more from this list (and others?)\n        // <https://www.npmjs.com/package/f2e-tools#regexp>\n\n        // replace currency\n        .replace(CURRENCY_REGEX, ` ${this.config.replacements.currency} `);\n\n    //\n    // expand contractions so \"they're\" -> [ they, are ] vs. [ they, re ]\n    // <https://github.com/NaturalNode/natural/issues/533>\n    //\n    // NOTE: we're doing this for all languages now, not just en\n    //\n    string = expandContractions(string);\n\n    //\n    // Future research:\n    // - <https://github.com/NaturalNode/natural/issues/523>\n    // - <https://github.com/mplatt/fold-to-ascii>\n    // - <https://github.com/andrewrk/node-diacritics>)\n    // - <https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-word-delimiter-tokenfilter.html>\n    // - <https://www.elastic.co/guide/en/elasticsearch/reference/master/analysis-elision-tokenfilter.html>\n    //\n    const tokens = [];\n    for (const token of tokenizer.tokenize(string.toLowerCase())) {\n      // zh tokenizr yields empty strings\n      if (token === '' || token === ' ') continue;\n\n      // whitelist words from being stemmed (safeguard)\n      if (\n        this.WHITELISTED_WORDS.has(token) ||\n        token.indexOf(this.config.replacements.initialism) === 0 ||\n        token.indexOf(this.config.replacements.abbrevation) === 0\n      ) {\n        tokens.push(token);\n        continue;\n      }\n\n      if (stopwords.has(token) || (locale !== 'en' && stopwordsEn.has(token))) {\n        continue;\n      }\n\n      // locale specific stopwords to ignore\n      let localeStem;\n      if (typeof stemword === 'function') {\n        localeStem = stemword(token);\n        if (localeStem && stopwords.has(localeStem)) {\n          continue;\n        }\n      }\n\n      // always check against English stemwords\n      let englishStem;\n      if (locale !== 'en') {\n        englishStem = snowball.stemword(token, 'english');\n        if (englishStem && stopwordsEn.has(englishStem)) continue;\n      }\n\n      tokens.push(\n        localeStem && localeStem !== token ? localeStem : englishStem || token\n      );\n    }\n\n    debug('locale', locale, 'tokens', tokens);\n\n    if (this.config.debug) return tokens;\n\n    // we should sha256 all tokens with hasha if not in debug mode\n    return Promise.all(\n      tokens.map((token) => hasha.async(token, this.config.hasha))\n    );\n  }\n\n  async getTokensAndMailFromSource(string) {\n    let source = string;\n    if (isBuffer(string)) source = string.toString();\n    else if (typeof string === 'string' && isValidPath(string))\n      source = await fs.promises.readFile(string);\n\n    const tokens = [];\n    const mail = await simpleParser(source, this.config.simpleParser);\n\n    await Promise.all(\n      TOKEN_HEADERS.map(async (header) => {\n        try {\n          const string = isSANB(mail[header])\n            ? mail[header]\n            : typeof mail[header] === 'object' && isSANB(mail[header].text)\n            ? mail[header].text\n            : null;\n\n          if (!string) return;\n\n          const contentLanguage = mail.headers.get('content-language');\n          const isHTML = header === 'html';\n          const tokensFound = await this.getTokens(\n            string,\n            contentLanguage,\n            isHTML\n          );\n\n          for (const token of tokensFound) {\n            tokens.push(token);\n          }\n        } catch (err) {\n          this.config.logger.error(err);\n        }\n      })\n    );\n\n    return { tokens, mail };\n  }\n\n  // eslint-disable-next-line complexity\n  async getPhishingResults(mail) {\n    const messages = new Set();\n    //\n    // NOTE: all links pushed are lowercased\n    //\n    const links = new Set();\n\n    // parse <a> tags with different org domain in text vs the link\n    if (isSANB(mail.html)) {\n      //\n      // NOTE: It would be interested to see if Gmail is prone to an injection attack\n      // whereas elements they do not support get stripped out and then the returning\n      // elements concatenate to form a URL which is malicious or phishing\n      //\n      for (const link of this.getUrls(striptags(mail.html, [], ' ').trim())) {\n        links.add(link);\n      }\n\n      //\n      //  we strip the protocol in order to parse out the actual addresses\n      // <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/protocol_handlers>\n      //\n\n      //\n      // NOTE: we cannot use this approach because it does not parse all the links\n      //\n      // const root = parse(mail.html);\n      // const anchors = root.querySelectorAll('a');\n      //\n      const matches =\n        mail.html.replace(NEWLINE_REGEX, ' ').match(ANCHOR_REGEX) || [];\n\n      if (matches.length > 0) {\n        for (const match of matches) {\n          const root = parse(match);\n          // <https://github.com/taoqf/node-html-parser/issues/60>\n          const anchor = root.querySelector('a') || root.querySelector('A');\n\n          // there is an edge (not sure where/how) possibly with regex\n          // but the `anchor` will be `null` here sometimes so we\n          // should catch that and prevent an error from being thrown\n          if (!anchor) {\n            this.config.logger.error(\n              new Error(`Anchor not found for match: ${match}`)\n            );\n            continue;\n          }\n\n          const textContent = striptags(anchor.innerHTML, [], ' ').trim();\n          let href = anchor.getAttribute('href');\n          const hasHref = isSANB(href) && validator.isURL(href, isURLOptions);\n\n          if (hasHref) {\n            // parse out the first url\n            // (this is needed because some have \"Web:%20http://google.com\" for example in href tags)\n            [href] = this.getUrls(href);\n            // eslint-disable-next-line max-depth\n            if (href) links.add(href);\n          }\n\n          // the text content could contain multiple URL's\n          // so we need to parse them each out\n          if (\n            isSANB(textContent) &&\n            isSANB(href) &&\n            validator.isURL(href, isURLOptions)\n          ) {\n            const string = `Anchor link with href of ${href} and inner text value of \"${textContent}\"`;\n            // eslint-disable-next-line max-depth\n            if (this.config.checkIDNHomographAttack) {\n              const anchorUrlHostname = this.getHostname(href);\n              // eslint-disable-next-line max-depth\n              if (anchorUrlHostname) {\n                const anchorUrlHostnameToASCII =\n                  punycode.toASCII(anchorUrlHostname);\n                // eslint-disable-next-line max-depth\n                if (anchorUrlHostnameToASCII.indexOf('xn--') === 0)\n                  messages.add(\n                    `${string} has possible IDN homograph attack from anchor hostname.`\n                  );\n              }\n            }\n\n            // eslint-disable-next-line max-depth\n            for (const link of this.getUrls(textContent)) {\n              // this link should have already been included but just in case\n\n              links.add(link);\n\n              // eslint-disable-next-line max-depth\n              if (this.config.checkIDNHomographAttack) {\n                const innerTextUrlHostname = this.getHostname(link);\n                // eslint-disable-next-line max-depth\n                if (innerTextUrlHostname) {\n                  const innerTextUrlHostnameToASCII =\n                    punycode.toASCII(innerTextUrlHostname);\n                  // eslint-disable-next-line max-depth\n                  if (innerTextUrlHostnameToASCII.indexOf('xn--') === 0)\n                    messages.add(\n                      `${string} has possible IDN homograph attack from inner text hostname.`\n                    );\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n\n    // <https://docs.apwg.org/ecrimeresearch/2018/5359941.pdf>\n    // <https://www.wandera.com/punycode-attacks/>\n    for (const prop of MAIL_PHISHING_PROPS) {\n      if (isSANB(mail[prop])) {\n        for (const link of this.getUrls(mail[prop])) {\n          links.add(link);\n        }\n      }\n    }\n\n    if (this.config.checkIDNHomographAttack) {\n      for (const link of links) {\n        const urlHostname = this.getHostname(link);\n        if (urlHostname) {\n          const toASCII = punycode.toASCII(urlHostname);\n          if (toASCII.indexOf('xn--') === 0)\n            messages.add(\n              `Possible IDN homograph attack from link of ${link} with punycode converted hostname of ${toASCII}.`\n            );\n        }\n      }\n    }\n\n    // check against Cloudflare malware/phishing/adult DNS lookup\n    // if it returns `0.0.0.0` it means it was flagged\n    await Promise.all(\n      [...links].map(async (link) => {\n        try {\n          const urlHostname = this.getHostname(link);\n          if (urlHostname) {\n            const toASCII = punycode.toASCII(urlHostname);\n            const adultMessage = `Link hostname of ${toASCII} was detected by Cloudflare's Family DNS to contain adult-related content, phishing, and/or malware.`;\n            const malwareMessage = `Link hostname of ${toASCII} was detected by Cloudflare's Security DNS to contain phishing and/or malware.`;\n\n            // if it already included both messages then return early\n            if (messages.has(adultMessage) && messages.has(malwareMessage))\n              return;\n\n            const { isAdult, isMalware } =\n              await this.memoizedIsCloudflareBlocked(toASCII);\n\n            if (isAdult && !messages.has(adultMessage))\n              messages.add(adultMessage);\n            if (isMalware && !messages.has(malwareMessage))\n              messages.add(malwareMessage);\n          }\n        } catch (err) {\n          this.config.logger.error(err);\n        }\n      })\n    );\n\n    return { messages: [...messages], links: [...links] };\n  }\n\n  // getNSFWResults() {\n  //   return false;\n  // }\n\n  async getExecutableResults(mail) {\n    const messages = [];\n\n    if (!Array.isArray(mail.attachments)) return messages;\n\n    // NOTE: we don't inspect <a> or normal links in the message html/text\n\n    // if any attachments have an executable\n    await Promise.all(\n      mail.attachments.map(async (attachment) => {\n        if (isBuffer(attachment.content)) {\n          try {\n            const fileType = await FileType.fromBuffer(attachment.content);\n\n            if (fileType && fileType.ext && EXECUTABLES.has(fileType.ext))\n              messages.push(\n                `Attachment's \"magic number\" indicated it was a dangerous executable with a \".${fileType.ext}\" extension.`\n              );\n          } catch (err) {\n            this.config.logger.error(err);\n          }\n        }\n\n        if (isSANB(attachment.filename)) {\n          // run punycode on file name as well since file names can be spoofed\n          const filename = punycode.toASCII(\n            punycode.toUnicode(attachment.filename.split('?')[0])\n          );\n          const ext = fileExtension(filename);\n          if (ext && EXECUTABLES.has(ext))\n            messages.push(\n              `Attachment's file name indicated it was a dangerous executable with a \".${ext}\" extension.`\n            );\n        }\n\n        if (isSANB(attachment.contentType)) {\n          const ext = mime.extension(attachment.contentType);\n          if (isSANB(ext) && EXECUTABLES.has(ext))\n            messages.push(\n              `Attachment's Content-Type was a dangerous executable with a \".${ext}\" extension.`\n            );\n        }\n      })\n    );\n\n    return messages;\n  }\n\n  async scan(string) {\n    const { tokens, mail } = await this.getTokensAndMailFromSource(string);\n\n    const [\n      classification,\n      phishing,\n      // nsfw,\n      executables,\n      arbitrary,\n      viruses\n    ] = await Promise.all([\n      this.getClassification(tokens),\n      this.getPhishingResults(mail),\n      // Promise.resolve(this.getNSFWResults(mail)),\n      this.getExecutableResults(mail),\n      this.getArbitraryResults(mail),\n      this.getVirusResults(mail)\n    ]);\n\n    const messages = [];\n\n    if (classification && classification.category === 'spam')\n      messages.push('Spam detected from Naive Bayesian classifier.');\n\n    for (const message of phishing.messages) {\n      messages.push(message);\n    }\n\n    // for (const message of nsfw) {\n    //   messages.push(message);\n    // }\n\n    for (const message of executables) {\n      messages.push(message);\n    }\n\n    for (const message of arbitrary) {\n      messages.push(message);\n    }\n\n    return {\n      is_spam: messages.length > 0,\n      message:\n        messages.length === 0 ? 'Not detected as spam.' : messages.join(' '),\n      results: {\n        // classifier prediction\n        classification,\n        phishing: phishing.messages,\n        // nsfw,\n        executables,\n        arbitrary,\n        viruses\n      },\n      links: phishing.links,\n      ...(this.config.debug ? { tokens, mail } : {})\n    };\n  }\n}\n\nmodule.exports = SpamScanner;\n"]}