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.
- package/dist/cjs/arf.cjs.map +1 -1
- package/dist/cjs/index.cjs +197 -45
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/esm/arf.js.map +1 -1
- package/dist/esm/cli.js +184 -33
- package/dist/esm/cli.js.map +3 -3
- package/dist/esm/index.js +186 -34
- package/dist/esm/index.js.map +3 -3
- package/dist/types/dnsbl.d.ts +19 -3
- package/dist/types/index.d.ts +13 -3
- package/dist/types/parse.d.ts +1 -1
- package/dist/types/tokenizer.d.ts +2 -1
- package/package.json +1 -1
package/dist/esm/cli.js
CHANGED
|
@@ -2739,6 +2739,9 @@ function unwrapLink(address2) {
|
|
|
2739
2739
|
}
|
|
2740
2740
|
for (let depth = 0; depth < 3; depth++) {
|
|
2741
2741
|
const target = gatewayTarget(url);
|
|
2742
|
+
if (!target) {
|
|
2743
|
+
break;
|
|
2744
|
+
}
|
|
2742
2745
|
let next;
|
|
2743
2746
|
try {
|
|
2744
2747
|
next = new URL(target);
|
|
@@ -3001,13 +3004,15 @@ function endsInsideTag(html, end, link) {
|
|
|
3001
3004
|
return joined !== host && (!isHost(host) || isHost(joined) && registrableDomain(joined) !== registrableDomain(host));
|
|
3002
3005
|
}
|
|
3003
3006
|
var MAX_SHOWN_HTML = 2e5;
|
|
3004
|
-
function extractLinks(text, html, { maxLength = 5e5 } = {}) {
|
|
3007
|
+
function extractLinks(text, html, { maxLength = 5e5, bareHosts = false } = {}) {
|
|
3005
3008
|
text = typeof text === "string" ? foldToAscii(text) : text;
|
|
3006
3009
|
html = typeof html === "string" ? foldToAscii(html) : html;
|
|
3007
3010
|
const links = [];
|
|
3008
3011
|
const seen = /* @__PURE__ */ new Set();
|
|
3012
|
+
const linkHosts = /* @__PURE__ */ new Set();
|
|
3013
|
+
const bareFound = [];
|
|
3009
3014
|
const names = /* @__PURE__ */ new Map();
|
|
3010
|
-
const add = (raw, shown = null) => {
|
|
3015
|
+
const add = (raw, shown = null, bare = false) => {
|
|
3011
3016
|
let href = trimEndOf(trimControls(raw.replaceAll(/[\t\n\r]/g, "")).trim(), `).,;:!?'"]`);
|
|
3012
3017
|
if (/^www\./i.test(href)) {
|
|
3013
3018
|
href = `http://${href}`;
|
|
@@ -3031,11 +3036,13 @@ ${shown || ""}`;
|
|
|
3031
3036
|
}
|
|
3032
3037
|
seen.add(key);
|
|
3033
3038
|
const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[(.*)]$/, "$1");
|
|
3039
|
+
linkHosts.add(host);
|
|
3034
3040
|
links.push({
|
|
3035
3041
|
url: url.href.slice(0, MAX_URL),
|
|
3036
3042
|
host,
|
|
3037
3043
|
text: shown,
|
|
3038
|
-
userinfo: Boolean(url.username || url.password)
|
|
3044
|
+
userinfo: Boolean(url.username || url.password),
|
|
3045
|
+
...bare && { bare: true }
|
|
3039
3046
|
});
|
|
3040
3047
|
};
|
|
3041
3048
|
if (typeof html === "string" && html) {
|
|
@@ -3064,10 +3071,92 @@ ${shown || ""}`;
|
|
|
3064
3071
|
}
|
|
3065
3072
|
add(link);
|
|
3066
3073
|
}
|
|
3074
|
+
if (bareHosts && index < 2) {
|
|
3075
|
+
bareLinkHosts(read, (host) => {
|
|
3076
|
+
bareFound.push(host);
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
for (const host of bareFound) {
|
|
3082
|
+
if (!linkHosts.has(host)) {
|
|
3083
|
+
add(`http://${host}/`, null, true);
|
|
3067
3084
|
}
|
|
3068
3085
|
}
|
|
3069
3086
|
return links;
|
|
3070
3087
|
}
|
|
3088
|
+
var IN_WORD = /[\p{L}\p{N}\p{M}_%]/u;
|
|
3089
|
+
var MAX_BARE_NAME = 760;
|
|
3090
|
+
function isHostCode(code) {
|
|
3091
|
+
return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
|
|
3092
|
+
}
|
|
3093
|
+
function endsLink(char) {
|
|
3094
|
+
return /[\s<>"'\u0060]/u.test(char);
|
|
3095
|
+
}
|
|
3096
|
+
function bareLinkHosts(text, add) {
|
|
3097
|
+
const found = /* @__PURE__ */ new Set();
|
|
3098
|
+
let previous = -1;
|
|
3099
|
+
let read = 0;
|
|
3100
|
+
let space = -1;
|
|
3101
|
+
let scheme = -1;
|
|
3102
|
+
for (let slash = text.indexOf("/"); slash !== -1; slash = text.indexOf("/", slash + 1)) {
|
|
3103
|
+
const floor = Math.max(previous + 1, slash - MAX_BARE_NAME);
|
|
3104
|
+
previous = slash;
|
|
3105
|
+
let end = slash;
|
|
3106
|
+
while (end > floor && text.codePointAt(end - 1) >= 48 && text.codePointAt(end - 1) <= 57) {
|
|
3107
|
+
end--;
|
|
3108
|
+
}
|
|
3109
|
+
end = end > floor && text[end - 1] === ":" ? end - 1 : slash;
|
|
3110
|
+
let start = end;
|
|
3111
|
+
while (start > floor && isHostCode(text.codePointAt(start - 1))) {
|
|
3112
|
+
start--;
|
|
3113
|
+
}
|
|
3114
|
+
if (start === end || start > 0 && (text[start - 1] === "/" || IN_WORD.test(text[start - 1]))) {
|
|
3115
|
+
continue;
|
|
3116
|
+
}
|
|
3117
|
+
for (; read < start; read++) {
|
|
3118
|
+
if (endsLink(text[read])) {
|
|
3119
|
+
space = read;
|
|
3120
|
+
} else if (text[read] === "/" && text[read - 1] === "/" && text[read - 2] === ":") {
|
|
3121
|
+
scheme = read;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
if (scheme > space) {
|
|
3125
|
+
continue;
|
|
3126
|
+
}
|
|
3127
|
+
while (end > start && text[end - 1] === ".") {
|
|
3128
|
+
end--;
|
|
3129
|
+
}
|
|
3130
|
+
const labels = text.slice(start, end).split(".");
|
|
3131
|
+
const tld = labels.pop();
|
|
3132
|
+
if (!/^(?:[a-z]{2,24}|xn--[a-z\d-]{1,59})$/i.test(tld)) {
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
const parts = [];
|
|
3136
|
+
while (labels.length > 0 && parts.length < 11) {
|
|
3137
|
+
const label = labels.pop();
|
|
3138
|
+
if (!/^[a-z\d-]{1,63}$/i.test(label)) {
|
|
3139
|
+
break;
|
|
3140
|
+
}
|
|
3141
|
+
parts.unshift(label);
|
|
3142
|
+
}
|
|
3143
|
+
if (parts.length === 0) {
|
|
3144
|
+
continue;
|
|
3145
|
+
}
|
|
3146
|
+
parts[0] = parts[0].replace(/^-+/, "");
|
|
3147
|
+
if (!parts[0]) {
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
const host = `${parts.join(".")}.${tld}`.toLowerCase();
|
|
3151
|
+
if (found.has(host)) {
|
|
3152
|
+
continue;
|
|
3153
|
+
}
|
|
3154
|
+
found.add(host);
|
|
3155
|
+
if (isHost(host)) {
|
|
3156
|
+
add(host);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3071
3160
|
function hostOf(link) {
|
|
3072
3161
|
const authority = link.replace(/^https?:\/\//i, "").split(/[/?#\\]/)[0];
|
|
3073
3162
|
return trimEndOf(authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, ""), `).,;:!?'"]`);
|
|
@@ -5065,24 +5154,38 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
|
|
|
5065
5154
|
}
|
|
5066
5155
|
}
|
|
5067
5156
|
}
|
|
5068
|
-
async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
|
|
5157
|
+
async function expandAttachments(attachments = [], errors = [], parse = parseMessage, unopened = []) {
|
|
5069
5158
|
const all = [];
|
|
5070
5159
|
const deadline = Date.now() + PARSE_TIMEOUT;
|
|
5071
5160
|
let budget = MAX_NESTED_BYTES;
|
|
5072
5161
|
let nested = 0;
|
|
5162
|
+
let tooMany = false;
|
|
5073
5163
|
const visit = async (list, prefix, depth) => {
|
|
5074
5164
|
for (const attachment of list) {
|
|
5075
5165
|
if (prefix) {
|
|
5076
5166
|
if (nested >= MAX_ATTACHMENTS) {
|
|
5167
|
+
if (!tooMany) {
|
|
5168
|
+
tooMany = true;
|
|
5169
|
+
unopened.push({ filename: prefix, reason: `more than ${MAX_ATTACHMENTS} attachments in attached messages` });
|
|
5170
|
+
}
|
|
5077
5171
|
return;
|
|
5078
5172
|
}
|
|
5079
5173
|
nested++;
|
|
5080
5174
|
}
|
|
5081
5175
|
const name = attachment.filename || (prefix ? "unnamed attachment" : "");
|
|
5176
|
+
const filename = prefix ? `${prefix}/${name}` : name || "attached message";
|
|
5082
5177
|
all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
|
|
5083
5178
|
const { content } = attachment;
|
|
5084
5179
|
const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
|
|
5085
|
-
if (attached &&
|
|
5180
|
+
if (attached && Buffer5.isBuffer(content) && depth >= MAX_NESTED_DEPTH) {
|
|
5181
|
+
unopened.push({ filename, reason: `nested more than ${MAX_NESTED_DEPTH} deep` });
|
|
5182
|
+
continue;
|
|
5183
|
+
}
|
|
5184
|
+
if (attached && Buffer5.isBuffer(content) && content.length > budget) {
|
|
5185
|
+
unopened.push({ filename, reason: `past ${MAX_NESTED_BYTES / 1024 / 1024} MB of attached messages` });
|
|
5186
|
+
continue;
|
|
5187
|
+
}
|
|
5188
|
+
if (attached && Buffer5.isBuffer(content)) {
|
|
5086
5189
|
budget -= content.length;
|
|
5087
5190
|
let inner;
|
|
5088
5191
|
try {
|
|
@@ -5104,17 +5207,47 @@ async function expandAttachments(attachments = [], errors = [], parse = parseMes
|
|
|
5104
5207
|
|
|
5105
5208
|
// src/reputation.js
|
|
5106
5209
|
import { isIP as isIP2 } from "node:net";
|
|
5107
|
-
import { domainToASCII } from "node:url";
|
|
5210
|
+
import { domainToASCII as domainToASCII2 } from "node:url";
|
|
5108
5211
|
|
|
5109
5212
|
// src/dnsbl.js
|
|
5110
5213
|
import dns from "node:dns";
|
|
5111
5214
|
import { isIP } from "node:net";
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5215
|
+
import { domainToASCII } from "node:url";
|
|
5216
|
+
import { getPublicSuffix } from "tldts";
|
|
5217
|
+
var LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
|
|
5218
|
+
function lookupName(host) {
|
|
5219
|
+
if (typeof host !== "string") {
|
|
5220
|
+
return null;
|
|
5221
|
+
}
|
|
5222
|
+
let end = host.length;
|
|
5223
|
+
while (end > 0 && host[end - 1] === ".") {
|
|
5224
|
+
end--;
|
|
5225
|
+
}
|
|
5226
|
+
let name = host.slice(0, end).toLowerCase();
|
|
5227
|
+
if (/[\u0080-\u{10FFFF}]/u.test(name)) {
|
|
5228
|
+
name = domainToASCII(name) || name;
|
|
5115
5229
|
}
|
|
5116
|
-
|
|
5117
|
-
|
|
5230
|
+
if (isIP(name.replaceAll(/^\[|]$/g, "")) !== 0) {
|
|
5231
|
+
return null;
|
|
5232
|
+
}
|
|
5233
|
+
const labels = name.split(".");
|
|
5234
|
+
let start = labels.length;
|
|
5235
|
+
let length = -1;
|
|
5236
|
+
while (start > 0 && LOOKUP_LABEL.test(labels[start - 1]) && length + 1 + labels[start - 1].length <= 253) {
|
|
5237
|
+
start--;
|
|
5238
|
+
length += 1 + labels[start].length;
|
|
5239
|
+
}
|
|
5240
|
+
if (start === labels.length) {
|
|
5241
|
+
return null;
|
|
5242
|
+
}
|
|
5243
|
+
if (start === 0) {
|
|
5244
|
+
return name;
|
|
5245
|
+
}
|
|
5246
|
+
const suffix = labels.slice(start).join(".");
|
|
5247
|
+
if (start === labels.length - 1 || getPublicSuffix(suffix, { allowPrivateDomains: true }) === suffix) {
|
|
5248
|
+
return null;
|
|
5249
|
+
}
|
|
5250
|
+
return suffix;
|
|
5118
5251
|
}
|
|
5119
5252
|
var CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
|
|
5120
5253
|
var CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
|
|
@@ -5313,9 +5446,10 @@ var DnsChecker = class {
|
|
|
5313
5446
|
return results.filter(Boolean);
|
|
5314
5447
|
}
|
|
5315
5448
|
/**
|
|
5316
|
-
* Ask Cloudflare's filtering resolvers about host names
|
|
5317
|
-
*
|
|
5318
|
-
* resolver is "
|
|
5449
|
+
* Ask Cloudflare's filtering resolvers about host names, each by the name
|
|
5450
|
+
* it is looked up as (see lookupName), once; the findings name it. A name
|
|
5451
|
+
* blocked by the malware resolver is "malware"; one blocked only by the
|
|
5452
|
+
* family resolver is "adult". The hosts that got no answer (from the malware
|
|
5319
5453
|
* resolver, or from the family resolver when asked) are listed in the
|
|
5320
5454
|
* result's (non-enumerable) `unanswered` property.
|
|
5321
5455
|
* @param {string[]} hosts
|
|
@@ -5328,10 +5462,8 @@ var DnsChecker = class {
|
|
|
5328
5462
|
async checkCloudflare(hosts = [], options = {}) {
|
|
5329
5463
|
const { adult = true, malwareServers = CLOUDFLARE_MALWARE, familyServers = CLOUDFLARE_FAMILY } = options;
|
|
5330
5464
|
const unanswered = [];
|
|
5331
|
-
const
|
|
5332
|
-
|
|
5333
|
-
return null;
|
|
5334
|
-
}
|
|
5465
|
+
const names = [...new Set(hosts.map((host) => lookupName(host)).filter(Boolean))];
|
|
5466
|
+
const results = await Promise.all(names.map(async (host) => {
|
|
5335
5467
|
const malware = await this.answer(host, malwareServers);
|
|
5336
5468
|
if (malware?.includes("0.0.0.0")) {
|
|
5337
5469
|
return { host, category: "malware" };
|
|
@@ -5366,7 +5498,7 @@ function asciiHostname(value) {
|
|
|
5366
5498
|
if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
|
|
5367
5499
|
return null;
|
|
5368
5500
|
}
|
|
5369
|
-
const ascii =
|
|
5501
|
+
const ascii = domainToASCII2(lower);
|
|
5370
5502
|
return ascii.length <= 253 && /^[\w-]{1,63}(?:\.[\w-]{1,63})*$/.test(ascii) ? ascii : null;
|
|
5371
5503
|
}
|
|
5372
5504
|
function normalizeEntry(value) {
|
|
@@ -5945,7 +6077,7 @@ import net from "node:net";
|
|
|
5945
6077
|
var package_default = {
|
|
5946
6078
|
name: "spamscanner",
|
|
5947
6079
|
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.",
|
|
5948
|
-
version: "7.0.
|
|
6080
|
+
version: "7.0.8",
|
|
5949
6081
|
author: "Forward Email (https://forwardemail.net)",
|
|
5950
6082
|
bin: {
|
|
5951
6083
|
spamscanner: "./dist/esm/cli.js"
|
|
@@ -8747,14 +8879,16 @@ var SpamScanner = class {
|
|
|
8747
8879
|
* a header field ("Name: value", or an mbox "From " line) is read as the
|
|
8748
8880
|
* body of a message with no header, so its text is the input; other
|
|
8749
8881
|
* input mailparser cannot read gives a message with no text. Other errors
|
|
8750
|
-
* are thrown.
|
|
8882
|
+
* are thrown. `error` is the parser's limit the message exceeds, so that
|
|
8883
|
+
* only its first parts were read (as scan() reports it, see parseError),
|
|
8884
|
+
* or null.
|
|
8751
8885
|
* @param {Buffer|string|Uint8Array|AsyncIterable} source
|
|
8752
|
-
* @returns {Promise<{raw: Buffer, mail: object}>}
|
|
8886
|
+
* @returns {Promise<{raw: Buffer, mail: object, error: string|null}>}
|
|
8753
8887
|
*/
|
|
8754
8888
|
async parse(source) {
|
|
8755
8889
|
const raw = await toBuffer(source);
|
|
8756
|
-
const { mail } = await parseWithin(withHeader(raw));
|
|
8757
|
-
return { raw, mail };
|
|
8890
|
+
const { mail, error } = await parseWithin(withHeader(raw));
|
|
8891
|
+
return { raw, mail, error: error ?? null };
|
|
8758
8892
|
}
|
|
8759
8893
|
/**
|
|
8760
8894
|
* Classifier features of a parsed message.
|
|
@@ -8887,15 +9021,23 @@ var SpamScanner = class {
|
|
|
8887
9021
|
message: `A link shows "${link.text}" but goes to ${link.host}`
|
|
8888
9022
|
}));
|
|
8889
9023
|
}
|
|
8890
|
-
|
|
9024
|
+
const name = lookupName(destination(link));
|
|
9025
|
+
if (name) {
|
|
9026
|
+
seenHosts.add(name);
|
|
9027
|
+
}
|
|
8891
9028
|
}
|
|
8892
|
-
|
|
9029
|
+
const found = links.filter((link) => !link.bare);
|
|
9030
|
+
const bare = links.filter((link) => link.bare);
|
|
9031
|
+
for (const link of [...found.filter((link2) => link2.text), ...found.filter((link2) => !link2.text)]) {
|
|
8893
9032
|
lookalike(destination(link), { url: link.url });
|
|
8894
9033
|
}
|
|
9034
|
+
for (const link of bare) {
|
|
9035
|
+
lookalike(link.host, { source: "text" });
|
|
9036
|
+
}
|
|
8895
9037
|
for (const host of hosts) {
|
|
8896
9038
|
lookalike(host, { source: "text" });
|
|
8897
9039
|
}
|
|
8898
|
-
const ordered = [...new Set([...
|
|
9040
|
+
const ordered = [...new Set([...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean))];
|
|
8899
9041
|
const firsts = /* @__PURE__ */ new Map();
|
|
8900
9042
|
for (const host of ordered) {
|
|
8901
9043
|
const domain = registrableDomain(host);
|
|
@@ -8909,9 +9051,9 @@ var SpamScanner = class {
|
|
|
8909
9051
|
let answered = 0;
|
|
8910
9052
|
const cloudflare = async () => {
|
|
8911
9053
|
await withTimeout(Promise.all(checked.map(async (host) => {
|
|
8912
|
-
const
|
|
8913
|
-
blocked.push(...
|
|
8914
|
-
if (!
|
|
9054
|
+
const found2 = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
|
|
9055
|
+
blocked.push(...found2);
|
|
9056
|
+
if (!found2.unanswered?.length) {
|
|
8915
9057
|
answered++;
|
|
8916
9058
|
}
|
|
8917
9059
|
})), config.timeout, null);
|
|
@@ -9095,7 +9237,7 @@ var SpamScanner = class {
|
|
|
9095
9237
|
const linkLength = Number.isFinite(config.phishing.maxLength) && config.phishing.maxLength > 0 ? config.phishing.maxLength : 1e6;
|
|
9096
9238
|
const text = typeof mail.text === "string" ? mail.text : "";
|
|
9097
9239
|
const html = typeof mail.html === "string" ? mail.html : "";
|
|
9098
|
-
const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2 });
|
|
9240
|
+
const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2, bareHosts: true });
|
|
9099
9241
|
const linksTruncated = text.length > linkLength || html.length > linkLength * 2;
|
|
9100
9242
|
const plainObfuscation = obfuscationOf(`${mail.subject || ""}
|
|
9101
9243
|
${typeof mail.text === "string" ? mail.text : ""}`, { maxLength: config.maxLength });
|
|
@@ -9106,7 +9248,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
|
|
|
9106
9248
|
mixed: Math.max(plainObfuscation.mixed, shownObfuscation.mixed),
|
|
9107
9249
|
styled: plainObfuscation.styled || shownObfuscation.styled
|
|
9108
9250
|
};
|
|
9109
|
-
const
|
|
9251
|
+
const unopened = [];
|
|
9252
|
+
const attached = await expandAttachments(mail.attachments, parseErrors, void 0, unopened);
|
|
9110
9253
|
const attachments = config.attachments === false ? [] : inspectAttachments(attached).filter((item) => config.macros !== false || !["macro", "pdf_active", "rtf_object"].includes(item.type)).map((item) => finding(item));
|
|
9111
9254
|
const [links, authentication, checkedReputation, dnsbl, virusScan, toxicity, nsfw] = await Promise.all([
|
|
9112
9255
|
this.checkLinks(checkedLinks, config, extracted.hosts, fromDomain(mail)),
|
|
@@ -9227,6 +9370,13 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
|
|
|
9227
9370
|
// scanned, as {filename, reason}: the same on every try, so not a
|
|
9228
9371
|
// failure.
|
|
9229
9372
|
virusScanSkipped: virusScan.skipped,
|
|
9373
|
+
// Attached messages not opened, past the limits attached messages
|
|
9374
|
+
// are read within (nested more than three deep, more than 100
|
|
9375
|
+
// attachments in them, or more than 25 MB of them), as
|
|
9376
|
+
// {filename, reason}: their attachments were not inspected. Not
|
|
9377
|
+
// scored (forwarded messages can be nested so), for a caller that
|
|
9378
|
+
// needs a message read whole, e.g. to block what it carries.
|
|
9379
|
+
unopenedAttachments: unopened,
|
|
9230
9380
|
// Whether the message has more text than phishing.maxLength, whose
|
|
9231
9381
|
// links were not read.
|
|
9232
9382
|
linksTruncated,
|
|
@@ -9236,7 +9386,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
|
|
|
9236
9386
|
riskScore: Math.max(0, ...links.homographs.map((item) => item.riskScore))
|
|
9237
9387
|
}
|
|
9238
9388
|
},
|
|
9239
|
-
|
|
9389
|
+
// (the links of the message, not hosts found without one)
|
|
9390
|
+
links: checkedLinks.filter((link) => !link.bare).map((link) => link.url),
|
|
9240
9391
|
language: extracted.language,
|
|
9241
9392
|
tokens: extracted.words,
|
|
9242
9393
|
mail,
|