spamscanner 6.0.0 → 6.1.0

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.
@@ -0,0 +1,4729 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+
32
+ // replacement-words.json
33
+ var replacement_words_default;
34
+ var init_replacement_words = __esm({
35
+ "replacement-words.json"() {
36
+ replacement_words_default = [
37
+ "url",
38
+ "email",
39
+ "number",
40
+ "currency",
41
+ "initialism",
42
+ "abbreviation",
43
+ "emoji",
44
+ "hexa",
45
+ "mac",
46
+ "phone",
47
+ "bitcoin",
48
+ "cc"
49
+ ];
50
+ }
51
+ });
52
+
53
+ // replacements.js
54
+ var replacements_exports = {};
55
+ __export(replacements_exports, {
56
+ default: () => replacements_default
57
+ });
58
+ var import_node_util5, import_node_fs, import_crypto_random_string, debug5, randomOptions, replacements, replacements_default;
59
+ var init_replacements = __esm({
60
+ "replacements.js"() {
61
+ import_node_util5 = require("node:util");
62
+ import_node_fs = require("node:fs");
63
+ import_crypto_random_string = __toESM(require("crypto-random-string"), 1);
64
+ init_replacement_words();
65
+ debug5 = (0, import_node_util5.debuglog)("spamscanner");
66
+ randomOptions = {
67
+ length: 10,
68
+ characters: "abcdefghijklmnopqrstuvwxyz"
69
+ };
70
+ replacements = {};
71
+ try {
72
+ replacements = JSON.parse((0, import_node_fs.readFileSync)("./replacements.json", "utf8"));
73
+ } catch (error) {
74
+ debug5(error);
75
+ for (const replacement of replacement_words_default) {
76
+ replacements[replacement] = `${replacement}${(0, import_crypto_random_string.default)(randomOptions)}`;
77
+ }
78
+ }
79
+ replacements_default = replacements;
80
+ }
81
+ });
82
+
83
+ // get-classifier.js
84
+ var get_classifier_exports = {};
85
+ __export(get_classifier_exports, {
86
+ default: () => get_classifier_default
87
+ });
88
+ var import_node_util6, import_node_fs2, import_naivebayes, debug6, classifier, get_classifier_default;
89
+ var init_get_classifier = __esm({
90
+ "get-classifier.js"() {
91
+ import_node_util6 = require("node:util");
92
+ import_node_fs2 = require("node:fs");
93
+ import_naivebayes = __toESM(require("@ladjs/naivebayes"), 1);
94
+ debug6 = (0, import_node_util6.debuglog)("spamscanner");
95
+ classifier = new import_naivebayes.default().toJsonObject();
96
+ try {
97
+ classifier = JSON.parse((0, import_node_fs2.readFileSync)("./classifier.json", "utf8"));
98
+ } catch (error) {
99
+ debug6(error);
100
+ }
101
+ get_classifier_default = classifier;
102
+ }
103
+ });
104
+
105
+ // src/enhanced-idn-detector.js
106
+ var enhanced_idn_detector_exports = {};
107
+ __export(enhanced_idn_detector_exports, {
108
+ default: () => enhanced_idn_detector_default
109
+ });
110
+ var import_node_crypto, import_confusables, CONFUSABLE_CHARS, LEGITIMATE_IDN_DOMAINS, POPULAR_BRANDS, EnhancedIDNDetector, enhanced_idn_detector_default;
111
+ var init_enhanced_idn_detector = __esm({
112
+ "src/enhanced-idn-detector.js"() {
113
+ import_node_crypto = require("node:crypto");
114
+ import_confusables = __toESM(require("confusables"), 1);
115
+ CONFUSABLE_CHARS = /* @__PURE__ */ new Map([
116
+ // Cyrillic to Latin confusables
117
+ ["\u0430", "a"],
118
+ ["\u0435", "e"],
119
+ ["\u043E", "o"],
120
+ ["\u0440", "p"],
121
+ ["\u0441", "c"],
122
+ ["\u0445", "x"],
123
+ ["\u0443", "y"],
124
+ ["\u0410", "A"],
125
+ ["\u0412", "B"],
126
+ ["\u0415", "E"],
127
+ ["\u041A", "K"],
128
+ ["\u041C", "M"],
129
+ ["\u041D", "H"],
130
+ ["\u041E", "O"],
131
+ ["\u0420", "P"],
132
+ ["\u0421", "C"],
133
+ ["\u0422", "T"],
134
+ ["\u0425", "X"],
135
+ ["\u0423", "Y"],
136
+ // Greek to Latin confusables
137
+ ["\u03B1", "a"],
138
+ ["\u03BF", "o"],
139
+ ["\u03C1", "p"],
140
+ ["\u03C5", "u"],
141
+ ["\u03BD", "v"],
142
+ ["\u03B9", "i"],
143
+ ["\u0391", "A"],
144
+ ["\u0392", "B"],
145
+ ["\u0395", "E"],
146
+ ["\u0396", "Z"],
147
+ ["\u0397", "H"],
148
+ ["\u0399", "I"],
149
+ ["\u039A", "K"],
150
+ ["\u039C", "M"],
151
+ ["\u039D", "N"],
152
+ ["\u039F", "O"],
153
+ ["\u03A1", "P"],
154
+ ["\u03A4", "T"],
155
+ ["\u03A5", "Y"],
156
+ // Mathematical symbols
157
+ ["\u{1D41A}", "a"],
158
+ ["\u{1D41B}", "b"],
159
+ ["\u{1D41C}", "c"],
160
+ ["\u{1D41D}", "d"],
161
+ ["\u{1D41E}", "e"],
162
+ ["\u{1D7CE}", "0"],
163
+ ["\u{1D7CF}", "1"],
164
+ ["\u{1D7D0}", "2"],
165
+ ["\u{1D7D1}", "3"],
166
+ ["\u{1D7D2}", "4"],
167
+ // Other common confusables
168
+ ["\u212F", "e"],
169
+ ["\u210A", "g"],
170
+ ["\u210E", "h"],
171
+ ["\u2113", "l"],
172
+ ["\u2134", "o"],
173
+ ["\u212F", "e"],
174
+ ["\u2170", "i"],
175
+ ["\u2171", "ii"],
176
+ ["\u2172", "iii"],
177
+ ["\u2173", "iv"],
178
+ ["\u2174", "v"]
179
+ ]);
180
+ LEGITIMATE_IDN_DOMAINS = /* @__PURE__ */ new Set([
181
+ "xn--fsq.xn--0zwm56d",
182
+ // 中国
183
+ "xn--fiqs8s",
184
+ // 中国
185
+ "xn--fiqz9s",
186
+ // 中囯
187
+ "xn--j6w193g",
188
+ // 香港
189
+ "xn--55qx5d",
190
+ // 公司
191
+ "xn--io0a7i"
192
+ // 网络
193
+ // Add more legitimate domains as needed
194
+ ]);
195
+ POPULAR_BRANDS = [
196
+ "google",
197
+ "facebook",
198
+ "amazon",
199
+ "apple",
200
+ "microsoft",
201
+ "twitter",
202
+ "instagram",
203
+ "linkedin",
204
+ "youtube",
205
+ "netflix",
206
+ "paypal",
207
+ "ebay",
208
+ "yahoo",
209
+ "adobe",
210
+ "salesforce",
211
+ "oracle",
212
+ "ibm",
213
+ "cisco",
214
+ "intel",
215
+ "nvidia",
216
+ "tesla",
217
+ "citibank",
218
+ "bankofamerica",
219
+ "wellsfargo",
220
+ "chase",
221
+ "americanexpress"
222
+ ];
223
+ EnhancedIDNDetector = class {
224
+ constructor(options = {}) {
225
+ this.options = {
226
+ strictMode: false,
227
+ enableWhitelist: true,
228
+ enableBrandProtection: true,
229
+ enableContextAnalysis: true,
230
+ maxSimilarityThreshold: 0.8,
231
+ minDomainAge: 30,
232
+ // Days
233
+ ...options
234
+ };
235
+ this.cache = /* @__PURE__ */ new Map();
236
+ }
237
+ /**
238
+ * Main detection method with comprehensive analysis
239
+ */
240
+ detectHomographAttack(domain, context = {}) {
241
+ const cacheKey = this.getCacheKey(domain, context);
242
+ if (this.cache.has(cacheKey)) {
243
+ return this.cache.get(cacheKey);
244
+ }
245
+ const result = this.analyzeComprehensive(domain, context);
246
+ this.cache.set(cacheKey, result);
247
+ return result;
248
+ }
249
+ /**
250
+ * Comprehensive analysis combining multiple detection methods
251
+ */
252
+ analyzeComprehensive(domain, context) {
253
+ const analysis = {
254
+ domain,
255
+ isIDN: this.isIDNDomain(domain),
256
+ riskScore: 0,
257
+ riskFactors: [],
258
+ recommendations: [],
259
+ confidence: 0
260
+ };
261
+ if (this.options.enableWhitelist && this.isWhitelisted(domain)) {
262
+ analysis.riskScore = 0;
263
+ analysis.confidence = 1;
264
+ analysis.recommendations.push("Domain is whitelisted as legitimate");
265
+ return analysis;
266
+ }
267
+ if (analysis.isIDN) {
268
+ analysis.riskScore += 0.3;
269
+ analysis.riskFactors.push("Contains non-ASCII characters");
270
+ }
271
+ const confusableAnalysis = this.analyzeConfusableCharacters(domain);
272
+ analysis.riskScore += confusableAnalysis.score;
273
+ analysis.riskFactors.push(...confusableAnalysis.factors);
274
+ if (this.options.enableBrandProtection) {
275
+ const brandAnalysis = this.analyzeBrandSimilarity(domain);
276
+ analysis.riskScore += brandAnalysis.score;
277
+ analysis.riskFactors.push(...brandAnalysis.factors);
278
+ }
279
+ const scriptAnalysis = this.analyzeScriptMixing(domain);
280
+ analysis.riskScore += scriptAnalysis.score;
281
+ analysis.riskFactors.push(...scriptAnalysis.factors);
282
+ if (this.options.enableContextAnalysis && context) {
283
+ const contextAnalysis = this.analyzeContext(domain, context);
284
+ analysis.riskScore += contextAnalysis.score;
285
+ analysis.riskFactors.push(...contextAnalysis.factors);
286
+ }
287
+ if (domain.includes("xn--")) {
288
+ const punycodeAnalysis = this.analyzePunycode(domain);
289
+ analysis.riskScore += punycodeAnalysis.score;
290
+ analysis.riskFactors.push(...punycodeAnalysis.factors);
291
+ }
292
+ analysis.confidence = Math.min(analysis.riskScore, 1);
293
+ analysis.recommendations = this.generateRecommendations(analysis);
294
+ return analysis;
295
+ }
296
+ /**
297
+ * Detect if domain contains IDN characters
298
+ */
299
+ isIDNDomain(domain) {
300
+ return domain.includes("xn--") || /[^\u0000-\u007F]/.test(domain);
301
+ }
302
+ /**
303
+ * Check if domain is in whitelist
304
+ */
305
+ isWhitelisted(domain) {
306
+ const normalized = domain.toLowerCase();
307
+ return LEGITIMATE_IDN_DOMAINS.has(normalized);
308
+ }
309
+ /**
310
+ * Analyze confusable characters
311
+ */
312
+ analyzeConfusableCharacters(domain) {
313
+ const analysis = { score: 0, factors: [] };
314
+ let confusableCount = 0;
315
+ let totalChars = 0;
316
+ try {
317
+ const normalized = (0, import_confusables.default)(domain);
318
+ if (normalized !== domain) {
319
+ for (const char of domain) {
320
+ totalChars++;
321
+ const normalizedChar = (0, import_confusables.default)(char);
322
+ if (normalizedChar !== char) {
323
+ confusableCount++;
324
+ analysis.factors.push(`Confusable character: ${char} \u2192 ${normalizedChar}`);
325
+ }
326
+ }
327
+ if (confusableCount > 0) {
328
+ const ratio = confusableCount / totalChars;
329
+ analysis.score = Math.min(ratio * 0.8, 0.6);
330
+ analysis.factors.push(`${confusableCount}/${totalChars} characters are confusable`, `Normalized domain: ${normalized}`);
331
+ }
332
+ }
333
+ } catch {
334
+ for (const char of domain) {
335
+ totalChars++;
336
+ if (CONFUSABLE_CHARS.has(char)) {
337
+ confusableCount++;
338
+ analysis.factors.push(`Confusable character: ${char} \u2192 ${CONFUSABLE_CHARS.get(char)}`);
339
+ }
340
+ }
341
+ if (confusableCount > 0) {
342
+ const ratio = confusableCount / totalChars;
343
+ analysis.score = Math.min(ratio * 0.8, 0.6);
344
+ analysis.factors.push(`${confusableCount}/${totalChars} characters are confusable`);
345
+ }
346
+ }
347
+ return analysis;
348
+ }
349
+ /**
350
+ * Analyze similarity to popular brands
351
+ */
352
+ analyzeBrandSimilarity(domain) {
353
+ const analysis = { score: 0, factors: [] };
354
+ const cleanDomain = this.normalizeDomain(domain);
355
+ for (const brand of POPULAR_BRANDS) {
356
+ const similarity = this.calculateSimilarity(cleanDomain, brand);
357
+ if (similarity > this.options.maxSimilarityThreshold) {
358
+ analysis.score = Math.max(analysis.score, similarity * 0.7);
359
+ analysis.factors.push(`High similarity to ${brand}: ${(similarity * 100).toFixed(1)}%`);
360
+ }
361
+ }
362
+ return analysis;
363
+ }
364
+ /**
365
+ * Analyze script mixing patterns
366
+ */
367
+ analyzeScriptMixing(domain) {
368
+ const analysis = { score: 0, factors: [] };
369
+ const scripts = this.detectScripts(domain);
370
+ if (scripts.size > 1) {
371
+ const scriptList = [...scripts].join(", ");
372
+ analysis.factors.push(`Mixed scripts detected: ${scriptList}`);
373
+ if (scripts.has("Latin") && (scripts.has("Cyrillic") || scripts.has("Greek"))) {
374
+ analysis.score += 0.4;
375
+ analysis.factors.push("Suspicious Latin/Cyrillic or Latin/Greek mixing");
376
+ } else {
377
+ analysis.score += 0.2;
378
+ }
379
+ }
380
+ return analysis;
381
+ }
382
+ /**
383
+ * Analyze context (email headers, content, etc.)
384
+ */
385
+ analyzeContext(domain, context) {
386
+ const analysis = { score: 0, factors: [] };
387
+ if (context.displayText && context.displayText !== domain) {
388
+ analysis.score += 0.3;
389
+ analysis.factors.push("Display text differs from actual domain");
390
+ }
391
+ if (context.senderReputation && context.senderReputation < 0.5) {
392
+ analysis.score += 0.2;
393
+ analysis.factors.push("Low sender reputation");
394
+ }
395
+ if (context.emailContent) {
396
+ const suspiciousPatterns = [
397
+ /urgent/i,
398
+ /verify.*account/i,
399
+ /suspended/i,
400
+ /click.*here/i,
401
+ /limited.*time/i,
402
+ /act.*now/i,
403
+ /confirm.*identity/i
404
+ ];
405
+ for (const pattern of suspiciousPatterns) {
406
+ if (pattern.test(context.emailContent)) {
407
+ analysis.score += 0.1;
408
+ analysis.factors.push(`Suspicious email pattern: ${pattern.source}`);
409
+ }
410
+ }
411
+ }
412
+ return analysis;
413
+ }
414
+ /**
415
+ * Analyze punycode domains
416
+ */
417
+ analyzePunycode(domain) {
418
+ const analysis = { score: 0, factors: [] };
419
+ try {
420
+ const decoded = this.decodePunycode(domain);
421
+ analysis.factors.push(`Punycode decoded: ${decoded}`);
422
+ const decodedAnalysis = this.analyzeConfusableCharacters(decoded);
423
+ analysis.score += decodedAnalysis.score * 0.8;
424
+ analysis.factors.push(...decodedAnalysis.factors);
425
+ } catch {
426
+ analysis.score += 0.2;
427
+ analysis.factors.push("Invalid punycode encoding");
428
+ }
429
+ return analysis;
430
+ }
431
+ /**
432
+ * Normalize domain for comparison
433
+ */
434
+ normalizeDomain(domain) {
435
+ let normalized = domain.toLowerCase();
436
+ try {
437
+ normalized = (0, import_confusables.default)(normalized);
438
+ } catch {
439
+ for (const [confusable, latin] of CONFUSABLE_CHARS) {
440
+ normalized = normalized.replaceAll(confusable, latin);
441
+ }
442
+ }
443
+ normalized = normalized.replace(/\.(com|org|net|edu|gov)$/, "");
444
+ return normalized;
445
+ }
446
+ /**
447
+ * Calculate string similarity using Levenshtein distance
448
+ */
449
+ calculateSimilarity(string1, string2) {
450
+ const matrix = [];
451
+ const length1 = string1.length;
452
+ const length2 = string2.length;
453
+ for (let i = 0; i <= length2; i++) {
454
+ matrix[i] = [i];
455
+ }
456
+ for (let j = 0; j <= length1; j++) {
457
+ matrix[0][j] = j;
458
+ }
459
+ for (let i = 1; i <= length2; i++) {
460
+ for (let j = 1; j <= length1; j++) {
461
+ if (string2.charAt(i - 1) === string1.charAt(j - 1)) {
462
+ matrix[i][j] = matrix[i - 1][j - 1];
463
+ } else {
464
+ matrix[i][j] = Math.min(
465
+ matrix[i - 1][j - 1] + 1,
466
+ matrix[i][j - 1] + 1,
467
+ matrix[i - 1][j] + 1
468
+ );
469
+ }
470
+ }
471
+ }
472
+ const maxLength = Math.max(length1, length2);
473
+ return maxLength === 0 ? 1 : (maxLength - matrix[length2][length1]) / maxLength;
474
+ }
475
+ /**
476
+ * Detect scripts used in domain
477
+ */
478
+ detectScripts(domain) {
479
+ const scripts = /* @__PURE__ */ new Set();
480
+ for (const char of domain) {
481
+ const code = char.codePointAt(0);
482
+ if (code >= 65 && code <= 90 || code >= 97 && code <= 122) {
483
+ scripts.add("Latin");
484
+ } else if (code >= 1024 && code <= 1279) {
485
+ scripts.add("Cyrillic");
486
+ } else if (code >= 880 && code <= 1023) {
487
+ scripts.add("Greek");
488
+ } else if (code >= 19968 && code <= 40959) {
489
+ scripts.add("CJK");
490
+ } else if (code >= 1424 && code <= 1535) {
491
+ scripts.add("Hebrew");
492
+ } else if (code >= 1536 && code <= 1791) {
493
+ scripts.add("Arabic");
494
+ }
495
+ }
496
+ return scripts;
497
+ }
498
+ /**
499
+ * Simple punycode decoder (basic implementation)
500
+ */
501
+ decodePunycode(domain) {
502
+ try {
503
+ const url = new URL(`http://${domain}`);
504
+ return url.hostname;
505
+ } catch {
506
+ return domain;
507
+ }
508
+ }
509
+ /**
510
+ * Generate recommendations based on analysis
511
+ */
512
+ generateRecommendations(analysis) {
513
+ const recommendations = [];
514
+ if (analysis.riskScore > 0.8) {
515
+ recommendations.push("HIGH RISK: Likely homograph attack - block or quarantine");
516
+ } else if (analysis.riskScore > 0.6) {
517
+ recommendations.push("MEDIUM RISK: Suspicious domain - flag for review");
518
+ } else if (analysis.riskScore > 0.3) {
519
+ recommendations.push("LOW RISK: Monitor domain activity");
520
+ } else {
521
+ recommendations.push("SAFE: Domain appears legitimate");
522
+ }
523
+ if (analysis.isIDN) {
524
+ recommendations.push("Consider displaying punycode representation to users");
525
+ }
526
+ if (analysis.riskFactors.some((f) => f.includes("brand"))) {
527
+ recommendations.push("Verify domain authenticity through official channels");
528
+ }
529
+ return recommendations;
530
+ }
531
+ /**
532
+ * Generate cache key
533
+ */
534
+ getCacheKey(domain, context) {
535
+ const contextHash = (0, import_node_crypto.createHash)("md5").update(JSON.stringify(context)).digest("hex").slice(0, 8);
536
+ return `${domain}:${contextHash}`;
537
+ }
538
+ };
539
+ enhanced_idn_detector_default = EnhancedIDNDetector;
540
+ }
541
+ });
542
+
543
+ // src/cli.js
544
+ var import_node_buffer3 = require("node:buffer");
545
+ var import_node_fs4 = require("node:fs");
546
+ var import_node_net = require("node:net");
547
+ var import_node_os = require("node:os");
548
+ var import_node_path2 = __toESM(require("node:path"), 1);
549
+ var import_node_process2 = __toESM(require("node:process"), 1);
550
+ var import_node_url2 = require("node:url");
551
+
552
+ // src/index.js
553
+ var import_node_buffer2 = require("node:buffer");
554
+ var import_node_fs3 = __toESM(require("node:fs"), 1);
555
+ var import_node_path = __toESM(require("node:path"), 1);
556
+ var import_node_process = __toESM(require("node:process"), 1);
557
+ var import_node_crypto2 = require("node:crypto");
558
+ var import_node_util7 = require("node:util");
559
+ var import_node_url = require("node:url");
560
+ var import_auto_bind = __toESM(require("auto-bind"), 1);
561
+ var import_ascii_fullwidth_halfwidth_convert = __toESM(require("ascii-fullwidth-halfwidth-convert"), 1);
562
+ var import_clamscan = __toESM(require("clamscan"), 1);
563
+ var import_naivebayes2 = __toESM(require("@ladjs/naivebayes"), 1);
564
+ var import_array_join_conjunction = __toESM(require("array-join-conjunction"), 1);
565
+ var import_bitcoin_regex = __toESM(require("bitcoin-regex"), 1);
566
+ var import_credit_card_regex = __toESM(require("credit-card-regex"), 1);
567
+ var import_email_regex_safe = __toESM(require("email-regex-safe"), 1);
568
+ var import_escape_string_regexp = __toESM(require("escape-string-regexp"), 1);
569
+ var import_nlp_expand_contractions = __toESM(require("@stdlib/nlp-expand-contractions"), 1);
570
+ var import_file_extension = __toESM(require("file-extension"), 1);
571
+ var import_floating_point_regex = __toESM(require("floating-point-regex"), 1);
572
+ var import_lande = __toESM(require("lande"), 1);
573
+ var import_hexa_color_regex = __toESM(require("hexa-color-regex"), 1);
574
+ var import_tldts = require("tldts");
575
+ var import_ip_regex = __toESM(require("ip-regex"), 1);
576
+ var import_is_buffer = __toESM(require("is-buffer"), 1);
577
+ var import_is_string_and_not_blank = __toESM(require("is-string-and-not-blank"), 1);
578
+ var import_mac_regex = __toESM(require("mac-regex"), 1);
579
+ var import_natural = __toESM(require("natural"), 1);
580
+ var import_normalize_url = __toESM(require("normalize-url"), 1);
581
+ var import_phone_regex = __toESM(require("phone-regex"), 1);
582
+ var import_node_snowball = __toESM(require("node-snowball"), 1);
583
+ var import_striptags = __toESM(require("striptags"), 1);
584
+ var import_superagent = __toESM(require("superagent"), 1);
585
+ var import_stopword = __toESM(require("stopword"), 1);
586
+ var import_url_regex_safe = __toESM(require("url-regex-safe"), 1);
587
+ var import_mailparser = require("mailparser");
588
+ var import_file_type = require("file-type");
589
+
590
+ // src/auth.js
591
+ var import_node_buffer = require("node:buffer");
592
+ var import_node_util = require("node:util");
593
+ var import_node_dns = __toESM(require("node:dns"), 1);
594
+ var debug = (0, import_node_util.debuglog)("spamscanner:auth");
595
+ var mailauth;
596
+ var getMailauth = async () => {
597
+ mailauth ||= await import("mailauth");
598
+ return mailauth;
599
+ };
600
+ var createResolver = (timeout = 1e4) => {
601
+ const resolver = new import_node_dns.default.promises.Resolver();
602
+ resolver.setServers(["8.8.8.8", "1.1.1.1"]);
603
+ return async (name, type) => {
604
+ try {
605
+ const controller = new AbortController();
606
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
607
+ let result;
608
+ switch (type) {
609
+ case "TXT": {
610
+ result = await resolver.resolveTxt(name);
611
+ result = result.map((r) => Array.isArray(r) ? r.join("") : r);
612
+ break;
613
+ }
614
+ case "MX": {
615
+ result = await resolver.resolveMx(name);
616
+ break;
617
+ }
618
+ case "A": {
619
+ result = await resolver.resolve4(name);
620
+ break;
621
+ }
622
+ case "AAAA": {
623
+ result = await resolver.resolve6(name);
624
+ break;
625
+ }
626
+ case "PTR": {
627
+ result = await resolver.resolvePtr(name);
628
+ break;
629
+ }
630
+ case "CNAME": {
631
+ result = await resolver.resolveCname(name);
632
+ break;
633
+ }
634
+ default: {
635
+ result = await resolver.resolve(name, type);
636
+ }
637
+ }
638
+ clearTimeout(timeoutId);
639
+ return result;
640
+ } catch (error) {
641
+ debug("DNS lookup failed for %s %s: %s", type, name, error.message);
642
+ throw error;
643
+ }
644
+ };
645
+ };
646
+ async function authenticate(message, options = {}) {
647
+ const {
648
+ ip,
649
+ helo,
650
+ mta,
651
+ sender,
652
+ resolver = createResolver(options.timeout || 1e4)
653
+ } = options;
654
+ const defaultResult = {
655
+ dkim: {
656
+ results: [],
657
+ status: { result: "none", comment: "No DKIM signature found" }
658
+ },
659
+ spf: {
660
+ status: { result: "none", comment: "SPF check not performed" },
661
+ domain: null
662
+ },
663
+ dmarc: {
664
+ status: { result: "none", comment: "DMARC check not performed" },
665
+ policy: null,
666
+ domain: null
667
+ },
668
+ arc: {
669
+ status: { result: "none", comment: "No ARC chain found" },
670
+ chain: []
671
+ },
672
+ bimi: {
673
+ status: { result: "none", comment: "No BIMI record found" },
674
+ location: null,
675
+ authority: null
676
+ },
677
+ receivedChain: [],
678
+ headers: {}
679
+ };
680
+ if (!ip) {
681
+ debug("No IP address provided, skipping authentication");
682
+ return defaultResult;
683
+ }
684
+ try {
685
+ const { authenticate: mailauthAuthenticate } = await getMailauth();
686
+ const messageBuffer = import_node_buffer.Buffer.isBuffer(message) ? message : import_node_buffer.Buffer.from(message);
687
+ const authResult = await mailauthAuthenticate(messageBuffer, {
688
+ ip,
689
+ helo: helo || "unknown",
690
+ mta: mta || "spamscanner",
691
+ sender,
692
+ resolver
693
+ });
694
+ debug("Authentication result: %o", authResult);
695
+ return {
696
+ dkim: normalizeResult(authResult.dkim, "dkim"),
697
+ spf: normalizeResult(authResult.spf, "spf"),
698
+ dmarc: normalizeResult(authResult.dmarc, "dmarc"),
699
+ arc: normalizeResult(authResult.arc, "arc"),
700
+ bimi: normalizeResult(authResult.bimi, "bimi"),
701
+ receivedChain: authResult.receivedChain || [],
702
+ headers: authResult.headers || {}
703
+ };
704
+ } catch (error) {
705
+ debug("Authentication failed: %s", error.message);
706
+ return defaultResult;
707
+ }
708
+ }
709
+ function normalizeResult(result, type) {
710
+ if (!result) {
711
+ return {
712
+ status: { result: "none", comment: `No ${type.toUpperCase()} result` }
713
+ };
714
+ }
715
+ switch (type) {
716
+ case "dkim": {
717
+ return {
718
+ results: result.results || [],
719
+ status: result.status || { result: "none", comment: "No DKIM signature found" }
720
+ };
721
+ }
722
+ case "spf": {
723
+ return {
724
+ status: result.status || { result: "none", comment: "SPF check not performed" },
725
+ domain: result.domain || null,
726
+ explanation: result.explanation || null
727
+ };
728
+ }
729
+ case "dmarc": {
730
+ return {
731
+ status: result.status || { result: "none", comment: "DMARC check not performed" },
732
+ policy: result.policy || null,
733
+ domain: result.domain || null,
734
+ p: result.p || null,
735
+ sp: result.sp || null,
736
+ pct: result.pct || null
737
+ };
738
+ }
739
+ case "arc": {
740
+ return {
741
+ status: result.status || { result: "none", comment: "No ARC chain found" },
742
+ chain: result.chain || [],
743
+ i: result.i || null
744
+ };
745
+ }
746
+ case "bimi": {
747
+ return {
748
+ status: result.status || { result: "none", comment: "No BIMI record found" },
749
+ location: result.location || null,
750
+ authority: result.authority || null,
751
+ selector: result.selector || null
752
+ };
753
+ }
754
+ default: {
755
+ return result;
756
+ }
757
+ }
758
+ }
759
+ function calculateAuthScore(authResult, weights = {}) {
760
+ const defaultWeights = {
761
+ dkimPass: -2,
762
+ // Reduce spam score if DKIM passes
763
+ dkimFail: 3,
764
+ // Increase spam score if DKIM fails
765
+ spfPass: -1,
766
+ spfFail: 2,
767
+ spfSoftfail: 1,
768
+ dmarcPass: -2,
769
+ dmarcFail: 4,
770
+ arcPass: -1,
771
+ arcFail: 1,
772
+ ...weights
773
+ };
774
+ let score = 0;
775
+ const tests = [];
776
+ const dkimResult = authResult.dkim?.status?.result;
777
+ if (dkimResult === "pass") {
778
+ score += defaultWeights.dkimPass;
779
+ tests.push(`DKIM_PASS(${defaultWeights.dkimPass})`);
780
+ } else if (dkimResult === "fail") {
781
+ score += defaultWeights.dkimFail;
782
+ tests.push(`DKIM_FAIL(${defaultWeights.dkimFail})`);
783
+ }
784
+ const spfResult = authResult.spf?.status?.result;
785
+ switch (spfResult) {
786
+ case "pass": {
787
+ score += defaultWeights.spfPass;
788
+ tests.push(`SPF_PASS(${defaultWeights.spfPass})`);
789
+ break;
790
+ }
791
+ case "fail": {
792
+ score += defaultWeights.spfFail;
793
+ tests.push(`SPF_FAIL(${defaultWeights.spfFail})`);
794
+ break;
795
+ }
796
+ case "softfail": {
797
+ score += defaultWeights.spfSoftfail;
798
+ tests.push(`SPF_SOFTFAIL(${defaultWeights.spfSoftfail})`);
799
+ break;
800
+ }
801
+ }
802
+ const dmarcResult = authResult.dmarc?.status?.result;
803
+ if (dmarcResult === "pass") {
804
+ score += defaultWeights.dmarcPass;
805
+ tests.push(`DMARC_PASS(${defaultWeights.dmarcPass})`);
806
+ } else if (dmarcResult === "fail") {
807
+ score += defaultWeights.dmarcFail;
808
+ tests.push(`DMARC_FAIL(${defaultWeights.dmarcFail})`);
809
+ }
810
+ const arcResult = authResult.arc?.status?.result;
811
+ if (arcResult === "pass") {
812
+ score += defaultWeights.arcPass;
813
+ tests.push(`ARC_PASS(${defaultWeights.arcPass})`);
814
+ } else if (arcResult === "fail") {
815
+ score += defaultWeights.arcFail;
816
+ tests.push(`ARC_FAIL(${defaultWeights.arcFail})`);
817
+ }
818
+ return {
819
+ score,
820
+ tests,
821
+ details: {
822
+ dkim: dkimResult || "none",
823
+ spf: spfResult || "none",
824
+ dmarc: dmarcResult || "none",
825
+ arc: arcResult || "none"
826
+ }
827
+ };
828
+ }
829
+ function formatAuthResultsHeader(authResult, hostname = "spamscanner") {
830
+ const parts = [hostname];
831
+ if (authResult.dkim?.status?.result) {
832
+ const dkimResult = authResult.dkim.status.result;
833
+ let dkimPart = `dkim=${dkimResult}`;
834
+ if (authResult.dkim.results?.[0]?.signingDomain) {
835
+ dkimPart += ` header.d=${authResult.dkim.results[0].signingDomain}`;
836
+ }
837
+ parts.push(dkimPart);
838
+ }
839
+ if (authResult.spf?.status?.result) {
840
+ let spfPart = `spf=${authResult.spf.status.result}`;
841
+ if (authResult.spf.domain) {
842
+ spfPart += ` smtp.mailfrom=${authResult.spf.domain}`;
843
+ }
844
+ parts.push(spfPart);
845
+ }
846
+ if (authResult.dmarc?.status?.result) {
847
+ let dmarcPart = `dmarc=${authResult.dmarc.status.result}`;
848
+ if (authResult.dmarc.domain) {
849
+ dmarcPart += ` header.from=${authResult.dmarc.domain}`;
850
+ }
851
+ parts.push(dmarcPart);
852
+ }
853
+ if (authResult.arc?.status?.result) {
854
+ parts.push(`arc=${authResult.arc.status.result}`);
855
+ }
856
+ return parts.join(";\n ");
857
+ }
858
+
859
+ // src/reputation.js
860
+ var import_node_util2 = require("node:util");
861
+ var debug2 = (0, import_node_util2.debuglog)("spamscanner:reputation");
862
+ var DEFAULT_API_URL = "https://api.forwardemail.net/v1/reputation";
863
+ var cache = /* @__PURE__ */ new Map();
864
+ var CACHE_TTL = 5 * 60 * 1e3;
865
+ async function checkReputation(value, options = {}) {
866
+ const {
867
+ apiUrl = DEFAULT_API_URL,
868
+ timeout = 1e4
869
+ } = options;
870
+ if (!value || typeof value !== "string") {
871
+ return {
872
+ isTruthSource: false,
873
+ truthSourceValue: null,
874
+ isAllowlisted: false,
875
+ allowlistValue: null,
876
+ isDenylisted: false,
877
+ denylistValue: null
878
+ };
879
+ }
880
+ const cacheKey = `${apiUrl}:${value}`;
881
+ const cached = cache.get(cacheKey);
882
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
883
+ debug2("Cache hit for %s", value);
884
+ return cached.result;
885
+ }
886
+ try {
887
+ const url = new URL(apiUrl);
888
+ url.searchParams.set("q", value);
889
+ const controller = new AbortController();
890
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
891
+ const response = await fetch(url.toString(), {
892
+ method: "GET",
893
+ headers: {
894
+ Accept: "application/json",
895
+ "User-Agent": "SpamScanner/6.0"
896
+ },
897
+ signal: controller.signal
898
+ });
899
+ clearTimeout(timeoutId);
900
+ if (!response.ok) {
901
+ debug2("API returned status %d for %s", response.status, value);
902
+ return {
903
+ isTruthSource: false,
904
+ truthSourceValue: null,
905
+ isAllowlisted: false,
906
+ allowlistValue: null,
907
+ isDenylisted: false,
908
+ denylistValue: null
909
+ };
910
+ }
911
+ const result = await response.json();
912
+ const normalizedResult = {
913
+ isTruthSource: Boolean(result.isTruthSource),
914
+ truthSourceValue: result.truthSourceValue || null,
915
+ isAllowlisted: Boolean(result.isAllowlisted),
916
+ allowlistValue: result.allowlistValue || null,
917
+ isDenylisted: Boolean(result.isDenylisted),
918
+ denylistValue: result.denylistValue || null
919
+ };
920
+ cache.set(cacheKey, {
921
+ result: normalizedResult,
922
+ timestamp: Date.now()
923
+ });
924
+ debug2("Reputation check for %s: %o", value, normalizedResult);
925
+ return normalizedResult;
926
+ } catch (error) {
927
+ debug2("Reputation check failed for %s: %s", value, error.message);
928
+ return {
929
+ isTruthSource: false,
930
+ truthSourceValue: null,
931
+ isAllowlisted: false,
932
+ allowlistValue: null,
933
+ isDenylisted: false,
934
+ denylistValue: null
935
+ };
936
+ }
937
+ }
938
+ async function checkReputationBatch(values, options = {}) {
939
+ const uniqueValues = [...new Set(values.filter(Boolean))];
940
+ const results = await Promise.all(uniqueValues.map(async (value) => {
941
+ const result = await checkReputation(value, options);
942
+ return [value, result];
943
+ }));
944
+ return new Map(results);
945
+ }
946
+ function aggregateReputationResults(results) {
947
+ const aggregated = {
948
+ isTruthSource: false,
949
+ truthSourceValue: null,
950
+ isAllowlisted: false,
951
+ allowlistValue: null,
952
+ isDenylisted: false,
953
+ denylistValue: null
954
+ };
955
+ for (const result of results) {
956
+ if (result.isTruthSource) {
957
+ aggregated.isTruthSource = true;
958
+ aggregated.truthSourceValue ||= result.truthSourceValue;
959
+ }
960
+ if (result.isAllowlisted) {
961
+ aggregated.isAllowlisted = true;
962
+ aggregated.allowlistValue ||= result.allowlistValue;
963
+ }
964
+ if (result.isDenylisted) {
965
+ aggregated.isDenylisted = true;
966
+ aggregated.denylistValue ||= result.denylistValue;
967
+ }
968
+ }
969
+ return aggregated;
970
+ }
971
+
972
+ // src/is-arbitrary.js
973
+ var import_node_util3 = require("node:util");
974
+ var debug3 = (0, import_node_util3.debuglog)("spamscanner:arbitrary");
975
+ var BLOCKED_PHRASES_PATTERN = /cheecck y0ur acc0untt|recorded you|you've been hacked|account is hacked|personal data has leaked|private information has been stolen/im;
976
+ var SYSADMIN_SUBJECT_PATTERN = /please moderate|mdadm monitoring|weekly report|wordfence|wordpress|wpforms|docker|graylog|digest|event notification|package update manager|event alert|system events|monit alert|ping|monitor|cron|yum|sendmail|exim|backup|logwatch|unattended-upgrades/im;
977
+ var SPAM_PATTERNS = {
978
+ // Subject line patterns
979
+ subjectPatterns: [
980
+ // Urgency patterns
981
+ /\b(urgent|immediate|action required|act now|limited time|expires?|deadline)\b/i,
982
+ // Money patterns
983
+ /\b(free|winner|won|prize|lottery|million|billion|cash|money|investment|profit)\b/i,
984
+ // Phishing patterns
985
+ /\b(verify|confirm|update|suspend|locked|unusual activity|security alert)\b/i,
986
+ // Adult content
987
+ /\b(viagra|cialis|pharmacy|pills|medication|prescription)\b/i,
988
+ // Crypto spam
989
+ /\b(bitcoin|crypto|btc|eth|nft|blockchain|wallet)\b/i
990
+ ],
991
+ // Body patterns
992
+ bodyPatterns: [
993
+ // Nigerian prince / advance fee fraud
994
+ /\b(nigerian?|prince|inheritance|beneficiary|next of kin|deceased|unclaimed)\b/i,
995
+ // Lottery scams
996
+ /\b(congratulations.*won|you have been selected|claim your prize)\b/i,
997
+ // Phishing
998
+ /\b(click here to verify|confirm your identity|update your account|suspended.*account)\b/i,
999
+ // Urgency
1000
+ /\b(act now|limited time offer|expires in \d+|only \d+ left)\b/i,
1001
+ // Financial scams
1002
+ /\b(wire transfer|western union|moneygram|bank transfer|routing number)\b/i,
1003
+ // Adult/pharma spam
1004
+ /\b(enlarge|enhancement|erectile|dysfunction|weight loss|diet pills)\b/i
1005
+ ],
1006
+ // Suspicious sender patterns
1007
+ senderPatterns: [
1008
+ // Random numbers in email
1009
+ /^[a-z]+\d{4,}@/i,
1010
+ // Very long local parts
1011
+ /^.{30,}@/,
1012
+ // Suspicious domains
1013
+ /@.*(\.ru|\.cn|\.tk|\.ml|\.ga|\.cf|\.gq)$/i,
1014
+ // Numeric domains
1015
+ /@(?:\d+\.){3}\d+/
1016
+ ]
1017
+ };
1018
+ var SUSPICIOUS_TLDS = /* @__PURE__ */ new Set([
1019
+ "tk",
1020
+ "ml",
1021
+ "ga",
1022
+ "cf",
1023
+ "gq",
1024
+ // Free TLDs often abused
1025
+ "xyz",
1026
+ "top",
1027
+ "wang",
1028
+ "win",
1029
+ "bid",
1030
+ "loan",
1031
+ "click",
1032
+ "link",
1033
+ "work",
1034
+ "date",
1035
+ "racing",
1036
+ "download",
1037
+ "stream",
1038
+ "trade"
1039
+ ]);
1040
+ var SPAM_KEYWORDS = /* @__PURE__ */ new Map([
1041
+ ["free", 1],
1042
+ ["winner", 2],
1043
+ ["prize", 2],
1044
+ ["lottery", 3],
1045
+ ["urgent", 1],
1046
+ ["act now", 2],
1047
+ ["limited time", 1],
1048
+ ["click here", 1],
1049
+ ["unsubscribe", -1],
1050
+ // Legitimate emails often have this
1051
+ ["verify your account", 2],
1052
+ ["suspended", 2],
1053
+ ["inheritance", 3],
1054
+ ["million dollars", 3],
1055
+ ["wire transfer", 3],
1056
+ ["western union", 3],
1057
+ ["nigerian", 3],
1058
+ ["prince", 2],
1059
+ ["beneficiary", 2],
1060
+ ["congratulations", 1],
1061
+ ["selected", 1],
1062
+ ["viagra", 3],
1063
+ ["cialis", 3],
1064
+ ["pharmacy", 2],
1065
+ ["bitcoin", 1],
1066
+ ["crypto", 1],
1067
+ ["investment opportunity", 2],
1068
+ ["guaranteed", 1],
1069
+ ["risk free", 2],
1070
+ ["no obligation", 1],
1071
+ ["dear friend", 2],
1072
+ ["dear customer", 1],
1073
+ ["dear user", 1]
1074
+ ]);
1075
+ var PAYPAL_SPAM_TYPE_IDS = /* @__PURE__ */ new Set(["PPC001017", "RT000238", "RT000542", "RT002947"]);
1076
+ var MS_SPAM_CATEGORIES = {
1077
+ // High-confidence threats (highest priority)
1078
+ highConfidence: ["cat:malw", "cat:hphsh", "cat:hphish", "cat:hspm"],
1079
+ // Impersonation attempts
1080
+ impersonation: ["cat:bimp", "cat:dimp", "cat:gimp", "cat:uimp"],
1081
+ // Phishing and spoofing
1082
+ phishingAndSpoofing: ["cat:phsh", "cat:spoof"],
1083
+ // Spam classifications
1084
+ spam: ["cat:ospm", "cat:spm"]
1085
+ };
1086
+ var MS_SPAM_VERDICTS = ["sfv:spm", "sfv:skb", "sfv:sks"];
1087
+ function isArbitrary(parsed, options = {}) {
1088
+ const {
1089
+ threshold = 5,
1090
+ checkSubject = true,
1091
+ checkBody = true,
1092
+ checkSender = true,
1093
+ checkHeaders = true,
1094
+ checkLinks = true,
1095
+ checkMicrosoftHeaders = true,
1096
+ checkVendorSpam = true,
1097
+ checkSpoofing = true,
1098
+ session = {}
1099
+ } = options;
1100
+ const reasons = [];
1101
+ let score = 0;
1102
+ let category = null;
1103
+ const getHeader = (name) => {
1104
+ if (parsed.headers?.get) {
1105
+ return parsed.headers.get(name);
1106
+ }
1107
+ if (parsed.headerLines) {
1108
+ const header = parsed.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
1109
+ return header?.line?.split(":").slice(1).join(":").trim();
1110
+ }
1111
+ return null;
1112
+ };
1113
+ const subject = parsed.subject || getHeader("subject") || "";
1114
+ const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
1115
+ const sessionInfo = buildSessionInfo(parsed, session, getHeader);
1116
+ if (subject && BLOCKED_PHRASES_PATTERN.test(subject)) {
1117
+ reasons.push("BLOCKED_PHRASE_IN_SUBJECT");
1118
+ score += 10;
1119
+ category = "SPAM";
1120
+ }
1121
+ if (checkMicrosoftHeaders) {
1122
+ const msResult = checkMicrosoftExchangeHeaders(getHeader, sessionInfo);
1123
+ if (msResult.blocked) {
1124
+ reasons.push(...msResult.reasons);
1125
+ score += msResult.score;
1126
+ category = msResult.category || category;
1127
+ }
1128
+ }
1129
+ if (checkVendorSpam) {
1130
+ const vendorResult = checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from);
1131
+ if (vendorResult.blocked) {
1132
+ reasons.push(...vendorResult.reasons);
1133
+ score += vendorResult.score;
1134
+ category = vendorResult.category || category;
1135
+ }
1136
+ }
1137
+ if (checkSpoofing) {
1138
+ const spoofResult = checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject);
1139
+ if (spoofResult.blocked) {
1140
+ reasons.push(...spoofResult.reasons);
1141
+ score += spoofResult.score;
1142
+ category = spoofResult.category || category;
1143
+ }
1144
+ }
1145
+ if (checkSubject && subject) {
1146
+ const subjectResult = checkSubjectLine(subject);
1147
+ score += subjectResult.score;
1148
+ reasons.push(...subjectResult.reasons);
1149
+ }
1150
+ if (checkBody) {
1151
+ const bodyText = parsed.text || "";
1152
+ const bodyHtml = parsed.html || "";
1153
+ const bodyResult = checkBodyContent(bodyText, bodyHtml);
1154
+ score += bodyResult.score;
1155
+ reasons.push(...bodyResult.reasons);
1156
+ }
1157
+ if (checkSender) {
1158
+ const replyTo = parsed.replyTo?.value?.[0]?.address || parsed.replyTo?.text || "";
1159
+ const senderResult = checkSenderPatterns(from, replyTo);
1160
+ score += senderResult.score;
1161
+ reasons.push(...senderResult.reasons);
1162
+ }
1163
+ if (checkHeaders) {
1164
+ const headerResult = checkHeaderAnomalies(parsed, getHeader);
1165
+ score += headerResult.score;
1166
+ reasons.push(...headerResult.reasons);
1167
+ }
1168
+ if (checkLinks) {
1169
+ const bodyHtml = parsed.html || parsed.text || "";
1170
+ const linkResult = checkSuspiciousLinks(bodyHtml);
1171
+ score += linkResult.score;
1172
+ reasons.push(...linkResult.reasons);
1173
+ }
1174
+ const isArbitrarySpam = score >= threshold;
1175
+ debug3(
1176
+ "Arbitrary check result: score=%d, threshold=%d, isArbitrary=%s, category=%s, reasons=%o",
1177
+ score,
1178
+ threshold,
1179
+ isArbitrarySpam,
1180
+ category,
1181
+ reasons
1182
+ );
1183
+ return {
1184
+ isArbitrary: isArbitrarySpam,
1185
+ reasons,
1186
+ score,
1187
+ category
1188
+ };
1189
+ }
1190
+ function buildSessionInfo(parsed, session, getHeader) {
1191
+ const info = { ...session };
1192
+ const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
1193
+ if (from && !info.originalFromAddress) {
1194
+ info.originalFromAddress = from.toLowerCase();
1195
+ const atIndex = from.indexOf("@");
1196
+ if (atIndex > 0) {
1197
+ info.originalFromAddressDomain = from.slice(atIndex + 1).toLowerCase();
1198
+ info.originalFromAddressRootDomain = getRootDomain(info.originalFromAddressDomain);
1199
+ }
1200
+ }
1201
+ if (!info.resolvedClientHostname) {
1202
+ info.resolvedClientHostname = extractClientHostname(parsed);
1203
+ if (info.resolvedClientHostname) {
1204
+ info.resolvedRootClientHostname = getRootDomain(info.resolvedClientHostname);
1205
+ }
1206
+ }
1207
+ info.remoteAddress ||= extractRemoteIp(parsed);
1208
+ return info;
1209
+ }
1210
+ function checkMicrosoftExchangeHeaders(getHeader, sessionInfo) {
1211
+ const result = {
1212
+ blocked: false,
1213
+ reasons: [],
1214
+ score: 0,
1215
+ category: null
1216
+ };
1217
+ const isFromMicrosoft = sessionInfo.resolvedClientHostname && sessionInfo.resolvedClientHostname.endsWith(".outbound.protection.outlook.com");
1218
+ if (!isFromMicrosoft) {
1219
+ return result;
1220
+ }
1221
+ const msAuthHeader = getHeader("x-ms-exchange-authentication-results");
1222
+ const forefrontHeader = getHeader("x-forefront-antispam-report");
1223
+ if (forefrontHeader) {
1224
+ const lowerForefront = forefrontHeader.toLowerCase();
1225
+ const sclMatch = lowerForefront.match(/scl:(\d+)/);
1226
+ const scl = sclMatch ? Number.parseInt(sclMatch[1], 10) : null;
1227
+ const sfvNotSpam = lowerForefront.includes("sfv:nspm");
1228
+ const microsoftSaysNotSpam = sfvNotSpam || scl !== null && scl <= 2;
1229
+ if (!microsoftSaysNotSpam && msAuthHeader) {
1230
+ const lowerMsAuth = msAuthHeader.toLowerCase();
1231
+ const spfPass = lowerMsAuth.includes("spf=pass");
1232
+ const dkimPass = lowerMsAuth.includes("dkim=pass");
1233
+ const dmarcPass = lowerMsAuth.includes("dmarc=pass");
1234
+ if (!spfPass && !dkimPass && !dmarcPass) {
1235
+ const spfFailed = lowerMsAuth.includes("spf=fail");
1236
+ const dkimFailed = lowerMsAuth.includes("dkim=fail");
1237
+ const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
1238
+ if (spfFailed || dkimFailed || dmarcFailed) {
1239
+ result.blocked = true;
1240
+ result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
1241
+ result.score += 10;
1242
+ result.category = "AUTHENTICATION_FAILURE";
1243
+ return result;
1244
+ }
1245
+ }
1246
+ }
1247
+ for (const cat of MS_SPAM_CATEGORIES.highConfidence) {
1248
+ if (lowerForefront.includes(cat)) {
1249
+ result.blocked = true;
1250
+ result.reasons.push(`MS_HIGH_CONFIDENCE_THREAT: ${cat.toUpperCase()}`);
1251
+ result.score += 15;
1252
+ result.category = cat.includes("malw") ? "MALWARE" : cat.includes("phish") || cat.includes("phsh") ? "PHISHING" : "HIGH_CONFIDENCE_SPAM";
1253
+ return result;
1254
+ }
1255
+ }
1256
+ for (const cat of MS_SPAM_CATEGORIES.impersonation) {
1257
+ if (lowerForefront.includes(cat)) {
1258
+ result.blocked = true;
1259
+ result.reasons.push(`MS_IMPERSONATION: ${cat.toUpperCase()}`);
1260
+ result.score += 12;
1261
+ result.category = "IMPERSONATION";
1262
+ return result;
1263
+ }
1264
+ }
1265
+ for (const cat of MS_SPAM_CATEGORIES.phishingAndSpoofing) {
1266
+ if (lowerForefront.includes(cat)) {
1267
+ result.blocked = true;
1268
+ result.reasons.push(`MS_PHISHING_SPOOF: ${cat.toUpperCase()}`);
1269
+ result.score += 12;
1270
+ result.category = cat.includes("phsh") ? "PHISHING" : "SPOOFING";
1271
+ return result;
1272
+ }
1273
+ }
1274
+ for (const verdict of MS_SPAM_VERDICTS) {
1275
+ if (lowerForefront.includes(verdict)) {
1276
+ result.blocked = true;
1277
+ result.reasons.push(`MS_SPAM_VERDICT: ${verdict.toUpperCase()}`);
1278
+ result.score += 10;
1279
+ result.category = "SPAM";
1280
+ return result;
1281
+ }
1282
+ }
1283
+ for (const cat of MS_SPAM_CATEGORIES.spam) {
1284
+ if (lowerForefront.includes(cat)) {
1285
+ result.blocked = true;
1286
+ result.reasons.push(`MS_SPAM_CATEGORY: ${cat.toUpperCase()}`);
1287
+ result.score += 10;
1288
+ result.category = "SPAM";
1289
+ return result;
1290
+ }
1291
+ }
1292
+ if (scl !== null && scl >= 5) {
1293
+ result.blocked = true;
1294
+ result.reasons.push(`MS_HIGH_SCL: ${scl}`);
1295
+ result.score += 8;
1296
+ result.category = "SPAM";
1297
+ return result;
1298
+ }
1299
+ } else if (msAuthHeader) {
1300
+ const lowerMsAuth = msAuthHeader.toLowerCase();
1301
+ const spfPass = lowerMsAuth.includes("spf=pass");
1302
+ const dkimPass = lowerMsAuth.includes("dkim=pass");
1303
+ const dmarcPass = lowerMsAuth.includes("dmarc=pass");
1304
+ if (!spfPass && !dkimPass && !dmarcPass) {
1305
+ const spfFailed = lowerMsAuth.includes("spf=fail");
1306
+ const dkimFailed = lowerMsAuth.includes("dkim=fail");
1307
+ const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
1308
+ if (spfFailed || dkimFailed || dmarcFailed) {
1309
+ result.blocked = true;
1310
+ result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
1311
+ result.score += 10;
1312
+ result.category = "AUTHENTICATION_FAILURE";
1313
+ }
1314
+ }
1315
+ }
1316
+ return result;
1317
+ }
1318
+ function checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from) {
1319
+ const result = {
1320
+ blocked: false,
1321
+ reasons: [],
1322
+ score: 0,
1323
+ category: null
1324
+ };
1325
+ const fromLower = from.toLowerCase();
1326
+ if (sessionInfo.originalFromAddressRootDomain === "paypal.com" && getHeader("x-email-type-id")) {
1327
+ const typeId = getHeader("x-email-type-id");
1328
+ if (PAYPAL_SPAM_TYPE_IDS.has(typeId)) {
1329
+ result.blocked = true;
1330
+ result.reasons.push(`PAYPAL_INVOICE_SPAM: ${typeId}`);
1331
+ result.score += 15;
1332
+ result.category = "VENDOR_SPAM";
1333
+ return result;
1334
+ }
1335
+ }
1336
+ if (sessionInfo.originalFromAddress === "invoice@authorize.net" && sessionInfo.resolvedRootClientHostname === "visa.com") {
1337
+ result.blocked = true;
1338
+ result.reasons.push("AUTHORIZE_VISA_PHISHING");
1339
+ result.score += 15;
1340
+ result.category = "PHISHING";
1341
+ return result;
1342
+ }
1343
+ if (fromLower.includes("amazon.co.jp") && (!sessionInfo.resolvedRootClientHostname || !sessionInfo.resolvedRootClientHostname.startsWith("amazon."))) {
1344
+ result.blocked = true;
1345
+ result.reasons.push("AMAZON_JP_IMPERSONATION");
1346
+ result.score += 12;
1347
+ result.category = "IMPERSONATION";
1348
+ return result;
1349
+ }
1350
+ if (subject && subject.includes("pCloud") && sessionInfo.originalFromAddressRootDomain !== "pcloud.com" && fromLower.includes("pcloud")) {
1351
+ result.blocked = true;
1352
+ result.reasons.push("PCLOUD_IMPERSONATION");
1353
+ result.score += 12;
1354
+ result.category = "IMPERSONATION";
1355
+ return result;
1356
+ }
1357
+ if ((sessionInfo.originalFromAddress === "postmaster@outlook.com" || sessionInfo.resolvedClientHostname && sessionInfo.resolvedClientHostname.endsWith(".outbound.protection.outlook.com") || sessionInfo.originalFromAddress?.startsWith("postmaster@") && sessionInfo.originalFromAddress?.endsWith(".onmicrosoft.com")) && isAutoReply(getHeader) && subject && (subject.startsWith("Undeliverable: ") || subject.startsWith("No se puede entregar: "))) {
1358
+ result.blocked = true;
1359
+ result.reasons.push("MS_BOUNCE_SPAM");
1360
+ result.score += 10;
1361
+ result.category = "BOUNCE_SPAM";
1362
+ return result;
1363
+ }
1364
+ if (sessionInfo.originalFromAddress === "postmaster@163.com" && subject && subject.includes("\u7CFB\u7EDF\u9000\u4FE1")) {
1365
+ result.blocked = true;
1366
+ result.reasons.push("163_BOUNCE_SPAM");
1367
+ result.score += 10;
1368
+ result.category = "BOUNCE_SPAM";
1369
+ return result;
1370
+ }
1371
+ if (sessionInfo.originalFromAddress === "dse_na4@docusign.net" && sessionInfo.spf?.domain && (sessionInfo.spf.domain.endsWith(".onmicrosoft.com") || sessionInfo.spf.domain === "onmicrosoft.com")) {
1372
+ result.blocked = true;
1373
+ result.reasons.push("DOCUSIGN_MS_SCAM");
1374
+ result.score += 12;
1375
+ result.category = "PHISHING";
1376
+ return result;
1377
+ }
1378
+ return result;
1379
+ }
1380
+ function checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject) {
1381
+ const result = {
1382
+ blocked: false,
1383
+ reasons: [],
1384
+ score: 0,
1385
+ category: null
1386
+ };
1387
+ if (sessionInfo.hadAlignedAndPassingDKIM || sessionInfo.isAllowlisted) {
1388
+ return result;
1389
+ }
1390
+ if (sessionInfo.hasSameHostnameAsFrom) {
1391
+ return result;
1392
+ }
1393
+ const rcptTo = sessionInfo.envelope?.rcptTo || [];
1394
+ const fromRootDomain = sessionInfo.originalFromAddressRootDomain;
1395
+ if (!fromRootDomain || rcptTo.length === 0) {
1396
+ return result;
1397
+ }
1398
+ const hasSameRcptToAsFrom = rcptTo.some((to) => {
1399
+ if (!to.address) {
1400
+ return false;
1401
+ }
1402
+ const toRootDomain = getRootDomain(parseHostFromAddress(to.address));
1403
+ return toRootDomain === fromRootDomain;
1404
+ });
1405
+ if (!hasSameRcptToAsFrom) {
1406
+ return result;
1407
+ }
1408
+ const spfResult = sessionInfo.spfFromHeader?.status?.result;
1409
+ if (spfResult === "pass") {
1410
+ return result;
1411
+ }
1412
+ sessionInfo.isPotentialPhishing = true;
1413
+ const xPhpScript = getHeader("x-php-script");
1414
+ const xMailer = getHeader("x-mailer");
1415
+ if (xPhpScript) {
1416
+ return result;
1417
+ }
1418
+ if (xMailer) {
1419
+ const mailerLower = xMailer.toLowerCase();
1420
+ if (mailerLower.includes("php") || mailerLower.includes("drupal")) {
1421
+ return result;
1422
+ }
1423
+ }
1424
+ if (subject && SYSADMIN_SUBJECT_PATTERN.test(subject)) {
1425
+ return result;
1426
+ }
1427
+ result.blocked = true;
1428
+ result.reasons.push("SPOOFING_ATTACK");
1429
+ result.score += 12;
1430
+ result.category = "SPOOFING";
1431
+ return result;
1432
+ }
1433
+ function isAutoReply(getHeader) {
1434
+ const autoSubmitted = getHeader("auto-submitted");
1435
+ if (autoSubmitted && autoSubmitted !== "no") {
1436
+ return true;
1437
+ }
1438
+ const autoResponseSuppress = getHeader("x-auto-response-suppress");
1439
+ if (autoResponseSuppress) {
1440
+ return true;
1441
+ }
1442
+ const precedence = getHeader("precedence");
1443
+ if (precedence && ["bulk", "junk", "list", "auto_reply"].includes(precedence.toLowerCase())) {
1444
+ return true;
1445
+ }
1446
+ if (getHeader("list-unsubscribe")) {
1447
+ return true;
1448
+ }
1449
+ return false;
1450
+ }
1451
+ function checkSubjectLine(subject) {
1452
+ const reasons = [];
1453
+ let score = 0;
1454
+ for (const pattern of SPAM_PATTERNS.subjectPatterns) {
1455
+ if (pattern.test(subject)) {
1456
+ const match = subject.match(pattern);
1457
+ reasons.push(`SUBJECT_SPAM_PATTERN: ${match[0]}`);
1458
+ score += 1;
1459
+ }
1460
+ }
1461
+ const upperCount = (subject.match(/[A-Z]/g) || []).length;
1462
+ const letterCount = (subject.match(/[a-zA-Z]/g) || []).length;
1463
+ if (letterCount > 10 && upperCount / letterCount > 0.7) {
1464
+ reasons.push("SUBJECT_ALL_CAPS");
1465
+ score += 2;
1466
+ }
1467
+ const punctCount = (subject.match(/[!?$]/g) || []).length;
1468
+ if (punctCount >= 3) {
1469
+ reasons.push("SUBJECT_EXCESSIVE_PUNCTUATION");
1470
+ score += 1;
1471
+ }
1472
+ if (/^(re|fw|fwd):/i.test(subject) && subject.length < 20) {
1473
+ reasons.push("SUBJECT_FAKE_REPLY");
1474
+ score += 1;
1475
+ }
1476
+ return { score, reasons };
1477
+ }
1478
+ function checkBodyContent(text, html) {
1479
+ const reasons = [];
1480
+ let score = 0;
1481
+ const content = text || html || "";
1482
+ const contentLower = content.toLowerCase();
1483
+ for (const pattern of SPAM_PATTERNS.bodyPatterns) {
1484
+ if (pattern.test(content)) {
1485
+ const match = content.match(pattern);
1486
+ reasons.push(`BODY_SPAM_PATTERN: ${match[0].slice(0, 50)}`);
1487
+ score += 1;
1488
+ }
1489
+ }
1490
+ for (const [keyword, weight] of SPAM_KEYWORDS) {
1491
+ if (contentLower.includes(keyword.toLowerCase())) {
1492
+ reasons.push(`SPAM_KEYWORD: ${keyword}`);
1493
+ score += weight;
1494
+ }
1495
+ }
1496
+ if (html) {
1497
+ if (/color:\s*#fff|color:\s*white|font-size:\s*[01]px/i.test(html)) {
1498
+ reasons.push("HIDDEN_TEXT");
1499
+ score += 3;
1500
+ }
1501
+ const imgCount = (html.match(/<img/gi) || []).length;
1502
+ const textLength = (text || "").length;
1503
+ if (imgCount > 5 && textLength < 100) {
1504
+ reasons.push("IMAGE_HEAVY_LOW_TEXT");
1505
+ score += 2;
1506
+ }
1507
+ }
1508
+ if (/data:image\/[^;]+;base64,/i.test(html || "")) {
1509
+ reasons.push("BASE64_IMAGES");
1510
+ score += 1;
1511
+ }
1512
+ const shortenerPatterns = /\b(bit\.ly|tinyurl|goo\.gl|t\.co|ow\.ly|is\.gd|buff\.ly|adf\.ly|j\.mp)\b/i;
1513
+ if (shortenerPatterns.test(content)) {
1514
+ reasons.push("URL_SHORTENER");
1515
+ score += 2;
1516
+ }
1517
+ return { score, reasons };
1518
+ }
1519
+ function checkSenderPatterns(from, replyTo) {
1520
+ const reasons = [];
1521
+ let score = 0;
1522
+ if (!from) {
1523
+ reasons.push("MISSING_FROM");
1524
+ score += 2;
1525
+ return { score, reasons };
1526
+ }
1527
+ for (const pattern of SPAM_PATTERNS.senderPatterns) {
1528
+ if (pattern.test(from)) {
1529
+ reasons.push("SUSPICIOUS_SENDER_PATTERN");
1530
+ score += 2;
1531
+ break;
1532
+ }
1533
+ }
1534
+ const tldMatch = from.match(/@[^.]+\.([a-z]+)$/i);
1535
+ if (tldMatch && SUSPICIOUS_TLDS.has(tldMatch[1].toLowerCase())) {
1536
+ reasons.push(`SUSPICIOUS_TLD: ${tldMatch[1]}`);
1537
+ score += 2;
1538
+ }
1539
+ if (replyTo && from) {
1540
+ const fromDomain = from.split("@")[1]?.toLowerCase();
1541
+ const replyDomain = replyTo.split("@")[1]?.toLowerCase();
1542
+ if (fromDomain && replyDomain && fromDomain !== replyDomain) {
1543
+ reasons.push("FROM_REPLY_TO_MISMATCH");
1544
+ score += 2;
1545
+ }
1546
+ }
1547
+ const spoofPatterns = /^(paypal|amazon|apple|microsoft|google|bank|security)/i;
1548
+ if (spoofPatterns.test(from) && !/@(paypal|amazon|apple|microsoft|google)\.com$/i.test(from)) {
1549
+ reasons.push("DISPLAY_NAME_SPOOFING");
1550
+ score += 3;
1551
+ }
1552
+ return { score, reasons };
1553
+ }
1554
+ function checkHeaderAnomalies(parsed, getHeader) {
1555
+ const reasons = [];
1556
+ let score = 0;
1557
+ if (!parsed.messageId && !getHeader("message-id")) {
1558
+ reasons.push("MISSING_MESSAGE_ID");
1559
+ score += 1;
1560
+ }
1561
+ if (parsed.date) {
1562
+ const messageDate = new Date(parsed.date);
1563
+ const now = /* @__PURE__ */ new Date();
1564
+ if (messageDate > now) {
1565
+ const hoursDiff = (messageDate - now) / (1e3 * 60 * 60);
1566
+ if (hoursDiff > 24) {
1567
+ reasons.push("FUTURE_DATE");
1568
+ score += 2;
1569
+ }
1570
+ }
1571
+ const daysDiff = (now - messageDate) / (1e3 * 60 * 60 * 24);
1572
+ if (daysDiff > 365) {
1573
+ reasons.push("VERY_OLD_DATE");
1574
+ score += 1;
1575
+ }
1576
+ } else {
1577
+ reasons.push("MISSING_DATE");
1578
+ score += 1;
1579
+ }
1580
+ const xMailer = getHeader("x-mailer") || "";
1581
+ if (xMailer) {
1582
+ const suspiciousMailers = /mass mail|bulk mail|email blast/i;
1583
+ if (suspiciousMailers.test(xMailer)) {
1584
+ reasons.push("SUSPICIOUS_MAILER");
1585
+ score += 1;
1586
+ }
1587
+ }
1588
+ const mimeVersion = getHeader("mime-version");
1589
+ if (!mimeVersion && (parsed.html || parsed.attachments?.length > 0)) {
1590
+ reasons.push("MISSING_MIME_VERSION");
1591
+ score += 1;
1592
+ }
1593
+ const toCount = parsed.to?.value?.length || 0;
1594
+ const ccCount = parsed.cc?.value?.length || 0;
1595
+ if (toCount + ccCount > 50) {
1596
+ reasons.push("EXCESSIVE_RECIPIENTS");
1597
+ score += 2;
1598
+ }
1599
+ return { score, reasons };
1600
+ }
1601
+ function checkSuspiciousLinks(content) {
1602
+ const reasons = [];
1603
+ let score = 0;
1604
+ const urlPattern = /https?:\/\/[^\s<>"']+/gi;
1605
+ const urls = content.match(urlPattern) || [];
1606
+ if (urls.length === 0) {
1607
+ return { score, reasons };
1608
+ }
1609
+ const suspiciousUrls = /* @__PURE__ */ new Set();
1610
+ for (const url of urls) {
1611
+ try {
1612
+ const parsed = new URL(url);
1613
+ const hostname = parsed.hostname.toLowerCase();
1614
+ if (/^(?:\d+\.){3}\d+$/.test(hostname)) {
1615
+ suspiciousUrls.add("IP_ADDRESS_URL");
1616
+ }
1617
+ const tld = hostname.split(".").pop();
1618
+ if (SUSPICIOUS_TLDS.has(tld)) {
1619
+ suspiciousUrls.add(`SUSPICIOUS_URL_TLD: ${tld}`);
1620
+ }
1621
+ if (parsed.port && !["80", "443", ""].includes(parsed.port)) {
1622
+ suspiciousUrls.add("URL_WITH_PORT");
1623
+ }
1624
+ if (url.length > 200) {
1625
+ suspiciousUrls.add("VERY_LONG_URL");
1626
+ }
1627
+ const subdomainCount = hostname.split(".").length - 2;
1628
+ if (subdomainCount > 3) {
1629
+ suspiciousUrls.add("EXCESSIVE_SUBDOMAINS");
1630
+ }
1631
+ if (/%[\da-f]{2}/i.test(url) && /%[\da-f]{2}.*%[\da-f]{2}/i.test(url)) {
1632
+ suspiciousUrls.add("URL_OBFUSCATION");
1633
+ }
1634
+ } catch {
1635
+ suspiciousUrls.add("INVALID_URL");
1636
+ }
1637
+ }
1638
+ for (const reason of suspiciousUrls) {
1639
+ reasons.push(reason);
1640
+ score += 1;
1641
+ }
1642
+ const linkPattern = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi;
1643
+ let match;
1644
+ while ((match = linkPattern.exec(content)) !== null) {
1645
+ const href = match[1];
1646
+ const text = match[2];
1647
+ if (/^https?:\/\//i.test(text)) {
1648
+ try {
1649
+ const textUrl = new URL(text);
1650
+ const hrefUrl = new URL(href);
1651
+ if (textUrl.hostname.toLowerCase() !== hrefUrl.hostname.toLowerCase()) {
1652
+ reasons.push("LINK_TEXT_URL_MISMATCH");
1653
+ score += 3;
1654
+ break;
1655
+ }
1656
+ } catch {
1657
+ }
1658
+ }
1659
+ }
1660
+ return { score, reasons };
1661
+ }
1662
+ function getRootDomain(hostname) {
1663
+ if (!hostname) {
1664
+ return "";
1665
+ }
1666
+ const parts = hostname.toLowerCase().split(".");
1667
+ if (parts.length <= 2) {
1668
+ return hostname.toLowerCase();
1669
+ }
1670
+ const multiPartTlds = ["co.uk", "com.au", "co.nz", "co.jp", "com.br", "co.in"];
1671
+ const lastTwo = parts.slice(-2).join(".");
1672
+ if (multiPartTlds.includes(lastTwo)) {
1673
+ return parts.slice(-3).join(".");
1674
+ }
1675
+ return parts.slice(-2).join(".");
1676
+ }
1677
+ function parseHostFromAddress(address) {
1678
+ if (!address) {
1679
+ return "";
1680
+ }
1681
+ const atIndex = address.indexOf("@");
1682
+ if (atIndex === -1) {
1683
+ return "";
1684
+ }
1685
+ return address.slice(atIndex + 1).toLowerCase();
1686
+ }
1687
+ function extractClientHostname(parsed) {
1688
+ let receivedHeaders = null;
1689
+ if (parsed.headers?.get) {
1690
+ receivedHeaders = parsed.headers.get("received");
1691
+ } else if (parsed.headerLines) {
1692
+ const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
1693
+ receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
1694
+ }
1695
+ if (!receivedHeaders) {
1696
+ return null;
1697
+ }
1698
+ const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
1699
+ if (!received) {
1700
+ return null;
1701
+ }
1702
+ const fromMatch = received.match(/from\s+([^\s(]+)/i);
1703
+ if (fromMatch) {
1704
+ return fromMatch[1].toLowerCase();
1705
+ }
1706
+ return null;
1707
+ }
1708
+ function extractRemoteIp(parsed) {
1709
+ let receivedHeaders = null;
1710
+ if (parsed.headers?.get) {
1711
+ receivedHeaders = parsed.headers.get("received");
1712
+ } else if (parsed.headerLines) {
1713
+ const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
1714
+ receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
1715
+ }
1716
+ if (!receivedHeaders) {
1717
+ return null;
1718
+ }
1719
+ const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
1720
+ if (!received) {
1721
+ return null;
1722
+ }
1723
+ const ipv4Match = received.match(/\[((?:\d+\.){3}\d+)]/);
1724
+ if (ipv4Match) {
1725
+ return ipv4Match[1];
1726
+ }
1727
+ const ipv6Match = received.match(/\[([a-f\d:]+)]/i);
1728
+ if (ipv6Match) {
1729
+ return ipv6Match[1];
1730
+ }
1731
+ return null;
1732
+ }
1733
+
1734
+ // src/get-attributes.js
1735
+ var import_node_util4 = require("node:util");
1736
+ var debug4 = (0, import_node_util4.debuglog)("spamscanner:attributes");
1737
+ function checkSRS(address) {
1738
+ if (!address) {
1739
+ return "";
1740
+ }
1741
+ const srs0Match = address.match(/^srs0=[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
1742
+ if (srs0Match) {
1743
+ return `${srs0Match[3]}@${srs0Match[2]}`;
1744
+ }
1745
+ const srs1Match = address.match(/^srs1=[^=]+=[^=]+==[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
1746
+ if (srs1Match) {
1747
+ return `${srs1Match[3]}@${srs1Match[2]}`;
1748
+ }
1749
+ return address;
1750
+ }
1751
+ function parseHostFromDomainOrAddress(addressOrDomain) {
1752
+ if (!addressOrDomain) {
1753
+ return "";
1754
+ }
1755
+ const atIndex = addressOrDomain.indexOf("@");
1756
+ if (atIndex !== -1) {
1757
+ return addressOrDomain.slice(atIndex + 1).toLowerCase();
1758
+ }
1759
+ return addressOrDomain.toLowerCase();
1760
+ }
1761
+ function parseRootDomain(hostname) {
1762
+ if (!hostname) {
1763
+ return "";
1764
+ }
1765
+ const parts = hostname.toLowerCase().split(".");
1766
+ if (parts.length <= 2) {
1767
+ return hostname.toLowerCase();
1768
+ }
1769
+ const multiPartTlds = /* @__PURE__ */ new Set([
1770
+ "co.uk",
1771
+ "com.au",
1772
+ "co.nz",
1773
+ "co.jp",
1774
+ "com.br",
1775
+ "co.in",
1776
+ "org.uk",
1777
+ "net.au",
1778
+ "com.mx",
1779
+ "com.cn",
1780
+ "com.tw",
1781
+ "com.hk",
1782
+ "co.za",
1783
+ "com.sg"
1784
+ ]);
1785
+ const lastTwo = parts.slice(-2).join(".");
1786
+ if (multiPartTlds.has(lastTwo)) {
1787
+ return parts.slice(-3).join(".");
1788
+ }
1789
+ return parts.slice(-2).join(".");
1790
+ }
1791
+ function parseAddresses(headerValue) {
1792
+ if (!headerValue) {
1793
+ return [];
1794
+ }
1795
+ if (Array.isArray(headerValue)) {
1796
+ return headerValue.flatMap((item) => {
1797
+ if (typeof item === "string") {
1798
+ return item;
1799
+ }
1800
+ if (item.address) {
1801
+ return item.address;
1802
+ }
1803
+ if (item.value && Array.isArray(item.value)) {
1804
+ return item.value.map((v) => v.address).filter(Boolean);
1805
+ }
1806
+ return null;
1807
+ }).filter(Boolean);
1808
+ }
1809
+ if (headerValue.value && Array.isArray(headerValue.value)) {
1810
+ return headerValue.value.map((v) => v.address).filter(Boolean);
1811
+ }
1812
+ if (typeof headerValue === "string") {
1813
+ const emailPattern = /[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi;
1814
+ return headerValue.match(emailPattern) || [];
1815
+ }
1816
+ return [];
1817
+ }
1818
+ function getHeaders(headers, name) {
1819
+ if (!headers) {
1820
+ return null;
1821
+ }
1822
+ if (headers.get) {
1823
+ const value = headers.get(name);
1824
+ if (value) {
1825
+ if (typeof value === "string") {
1826
+ return value;
1827
+ }
1828
+ if (value.text) {
1829
+ return value.text;
1830
+ }
1831
+ if (value.value && Array.isArray(value.value)) {
1832
+ return value.value.map((v) => v.address || v.text || v).join(", ");
1833
+ }
1834
+ }
1835
+ return null;
1836
+ }
1837
+ if (headers.headerLines) {
1838
+ const header = headers.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
1839
+ if (header) {
1840
+ return header.line?.split(":").slice(1).join(":").trim();
1841
+ }
1842
+ }
1843
+ if (typeof headers === "object") {
1844
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
1845
+ if (key) {
1846
+ const value = headers[key];
1847
+ if (typeof value === "string") {
1848
+ return value;
1849
+ }
1850
+ if (Array.isArray(value)) {
1851
+ return value[0];
1852
+ }
1853
+ }
1854
+ }
1855
+ return null;
1856
+ }
1857
+ async function getAttributes(parsed, session = {}, options = {}) {
1858
+ const { isAligned = false, authResults = null } = options;
1859
+ const headers = parsed.headers || parsed;
1860
+ const replyToHeader = getHeaders(headers, "reply-to");
1861
+ const replyToAddresses = parseAddresses(parsed.replyTo || (replyToHeader ? { value: [{ address: replyToHeader }] } : null));
1862
+ const array = [
1863
+ session.resolvedClientHostname,
1864
+ session.resolvedRootClientHostname,
1865
+ session.remoteAddress
1866
+ ];
1867
+ const from = [
1868
+ session.originalFromAddress,
1869
+ session.originalFromAddressDomain,
1870
+ session.originalFromAddressRootDomain
1871
+ ];
1872
+ const replyTo = [];
1873
+ for (const addr of replyToAddresses) {
1874
+ const checked = checkSRS(addr);
1875
+ replyTo.push(
1876
+ checked.toLowerCase(),
1877
+ parseHostFromDomainOrAddress(checked),
1878
+ parseRootDomain(parseHostFromDomainOrAddress(checked))
1879
+ );
1880
+ }
1881
+ const mailFrom = [];
1882
+ const mailFromAddress = session.envelope?.mailFrom?.address;
1883
+ if (mailFromAddress) {
1884
+ const checked = checkSRS(mailFromAddress);
1885
+ mailFrom.push(
1886
+ checked.toLowerCase(),
1887
+ parseHostFromDomainOrAddress(checked),
1888
+ parseRootDomain(parseHostFromDomainOrAddress(checked))
1889
+ );
1890
+ }
1891
+ if (isAligned) {
1892
+ const signingDomains = session.signingDomains || /* @__PURE__ */ new Set();
1893
+ const spfResult = session.spfFromHeader?.status?.result;
1894
+ const fromHasSpfPass = spfResult === "pass";
1895
+ const fromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(session.originalFromAddressDomain) || signingDomains.has(session.originalFromAddressRootDomain));
1896
+ if (fromHasSpfPass || fromHasDkimAlignment) {
1897
+ array.push(...from);
1898
+ }
1899
+ let hasAlignedReplyTo = false;
1900
+ for (const addr of replyToAddresses) {
1901
+ const checked = checkSRS(addr);
1902
+ const domain = parseHostFromDomainOrAddress(checked);
1903
+ const rootDomain = parseRootDomain(domain);
1904
+ if (signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain))) {
1905
+ hasAlignedReplyTo = true;
1906
+ break;
1907
+ }
1908
+ if (authResults?.spf) {
1909
+ const spfForReplyTo = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
1910
+ if (spfForReplyTo?.result === "pass") {
1911
+ hasAlignedReplyTo = true;
1912
+ break;
1913
+ }
1914
+ }
1915
+ }
1916
+ if (hasAlignedReplyTo) {
1917
+ array.push(...replyTo);
1918
+ }
1919
+ if (mailFromAddress) {
1920
+ const checked = checkSRS(mailFromAddress);
1921
+ const domain = parseHostFromDomainOrAddress(checked);
1922
+ const rootDomain = parseRootDomain(domain);
1923
+ const mailFromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain));
1924
+ let mailFromHasSpfPass = false;
1925
+ if (authResults?.spf) {
1926
+ const spfForMailFrom = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
1927
+ mailFromHasSpfPass = spfForMailFrom?.result === "pass";
1928
+ }
1929
+ if (mailFromHasDkimAlignment || mailFromHasSpfPass) {
1930
+ array.push(...mailFrom);
1931
+ }
1932
+ }
1933
+ } else {
1934
+ array.push(...from, ...replyTo, ...mailFrom);
1935
+ }
1936
+ const normalized = array.filter((string_) => typeof string_ === "string" && string_.length > 0).map((string_) => {
1937
+ try {
1938
+ return string_.toLowerCase().trim();
1939
+ } catch {
1940
+ return string_.toLowerCase().trim();
1941
+ }
1942
+ });
1943
+ const unique = [...new Set(normalized)];
1944
+ debug4("Extracted %d unique attributes (isAligned=%s): %o", unique.length, isAligned, unique);
1945
+ return unique;
1946
+ }
1947
+ function buildSessionFromParsed(parsed, existingSession = {}) {
1948
+ const session = { ...existingSession };
1949
+ const headers = parsed.headers || parsed;
1950
+ const fromHeader = getHeaders(headers, "from");
1951
+ const fromAddresses = parseAddresses(parsed.from || fromHeader);
1952
+ const fromAddress = fromAddresses[0];
1953
+ if (fromAddress && !session.originalFromAddress) {
1954
+ session.originalFromAddress = checkSRS(fromAddress).toLowerCase();
1955
+ session.originalFromAddressDomain = parseHostFromDomainOrAddress(session.originalFromAddress);
1956
+ session.originalFromAddressRootDomain = parseRootDomain(session.originalFromAddressDomain);
1957
+ }
1958
+ if (!session.resolvedClientHostname) {
1959
+ const receivedHeader = getHeaders(headers, "received");
1960
+ if (receivedHeader) {
1961
+ const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
1962
+ const fromMatch = received?.match(/from\s+([^\s(]+)/i);
1963
+ if (fromMatch) {
1964
+ session.resolvedClientHostname = fromMatch[1].toLowerCase();
1965
+ session.resolvedRootClientHostname = parseRootDomain(session.resolvedClientHostname);
1966
+ }
1967
+ }
1968
+ }
1969
+ if (!session.remoteAddress) {
1970
+ const receivedHeader = getHeaders(headers, "received");
1971
+ if (receivedHeader) {
1972
+ const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
1973
+ const ipv4Match = received?.match(/\[((?:\d+\.){3}\d+)]/);
1974
+ if (ipv4Match) {
1975
+ session.remoteAddress = ipv4Match[1];
1976
+ } else {
1977
+ const ipv6Match = received?.match(/\[([a-f\d:]+)]/i);
1978
+ if (ipv6Match) {
1979
+ session.remoteAddress = ipv6Match[1];
1980
+ }
1981
+ }
1982
+ }
1983
+ }
1984
+ if (!session.envelope) {
1985
+ session.envelope = {
1986
+ mailFrom: { address: session.originalFromAddress || "" },
1987
+ rcptTo: []
1988
+ };
1989
+ const toAddresses = parseAddresses(parsed.to || getHeaders(headers, "to"));
1990
+ const ccAddresses = parseAddresses(parsed.cc || getHeaders(headers, "cc"));
1991
+ for (const addr of [...toAddresses, ...ccAddresses]) {
1992
+ if (addr) {
1993
+ session.envelope.rcptTo.push({ address: addr });
1994
+ }
1995
+ }
1996
+ }
1997
+ return session;
1998
+ }
1999
+ async function extractAttributes(parsed, options = {}) {
2000
+ const { isAligned = false, senderIp, senderHostname, authResults } = options;
2001
+ const session = buildSessionFromParsed(parsed, {
2002
+ remoteAddress: senderIp,
2003
+ resolvedClientHostname: senderHostname,
2004
+ resolvedRootClientHostname: senderHostname ? parseRootDomain(senderHostname) : void 0
2005
+ });
2006
+ if (authResults?.dkim) {
2007
+ session.signingDomains = /* @__PURE__ */ new Set();
2008
+ for (const dkimResult of authResults.dkim) {
2009
+ if (dkimResult.result === "pass" && dkimResult.domain) {
2010
+ session.signingDomains.add(dkimResult.domain);
2011
+ session.signingDomains.add(parseRootDomain(dkimResult.domain));
2012
+ }
2013
+ }
2014
+ session.hadAlignedAndPassingDKIM = session.signingDomains.has(session.originalFromAddressDomain) || session.signingDomains.has(session.originalFromAddressRootDomain);
2015
+ }
2016
+ if (authResults?.spf) {
2017
+ const spfForFrom = authResults.spf.find((r) => r.domain === session.originalFromAddressDomain || r.domain === session.originalFromAddressRootDomain);
2018
+ if (spfForFrom) {
2019
+ session.spfFromHeader = {
2020
+ status: { result: spfForFrom.result }
2021
+ };
2022
+ }
2023
+ }
2024
+ const attributes = await getAttributes(parsed, session, { isAligned, authResults });
2025
+ return { attributes, session };
2026
+ }
2027
+
2028
+ // src/index.js
2029
+ var import_meta = {};
2030
+ var __filename = import_meta.url ? (0, import_node_url.fileURLToPath)(import_meta.url) : "";
2031
+ var __dirname = __filename ? import_node_path.default.dirname(__filename) : import_node_process.default.cwd();
2032
+ var findPackageRoot = (startDir) => {
2033
+ let dir = startDir;
2034
+ while (dir !== import_node_path.default.dirname(dir)) {
2035
+ if (import_node_fs3.default.existsSync(import_node_path.default.join(dir, "package.json"))) {
2036
+ return dir;
2037
+ }
2038
+ dir = import_node_path.default.dirname(dir);
2039
+ }
2040
+ return startDir;
2041
+ };
2042
+ var packageRoot = findPackageRoot(__dirname);
2043
+ var executablesData = JSON.parse(import_node_fs3.default.readFileSync(import_node_path.default.join(packageRoot, "executables.json"), "utf8"));
2044
+ var EXECUTABLES = new Set(executablesData);
2045
+ var getReplacements = async () => {
2046
+ const { default: replacements2 } = await Promise.resolve().then(() => (init_replacements(), replacements_exports));
2047
+ return replacements2;
2048
+ };
2049
+ var getClassifier = async () => {
2050
+ const { default: classifier2 } = await Promise.resolve().then(() => (init_get_classifier(), get_classifier_exports));
2051
+ return classifier2;
2052
+ };
2053
+ var debug7 = (0, import_node_util7.debuglog)("spamscanner");
2054
+ var GENERIC_TOKENIZER = /[^a-zá-úÁ-Úà-úÀ-Úñü\dа-яёæøåàáảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđäöëïîûœçążśźęćńł-]+/i;
2055
+ var converter = new import_ascii_fullwidth_halfwidth_convert.default();
2056
+ var chineseTokenizer = { tokenize: (text) => text.split(/\s+/) };
2057
+ var stopwordsMap = /* @__PURE__ */ new Map([
2058
+ ["ar", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.ar || []])],
2059
+ ["bg", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.bg || []])],
2060
+ ["bn", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.bn || []])],
2061
+ ["ca", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.ca || []])],
2062
+ ["cs", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.cs || []])],
2063
+ ["da", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.da || []])],
2064
+ ["de", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.de || []])],
2065
+ ["el", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.el || []])],
2066
+ ["en", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.en || []])],
2067
+ ["es", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.es || []])],
2068
+ ["fa", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.fa || []])],
2069
+ ["fi", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.fi || []])],
2070
+ ["fr", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.fr || []])],
2071
+ ["ga", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.ga || []])],
2072
+ ["gl", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.gl || []])],
2073
+ ["gu", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.gu || []])],
2074
+ ["he", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.he || []])],
2075
+ ["hi", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.hi || []])],
2076
+ ["hr", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.hr || []])],
2077
+ ["hu", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.hu || []])],
2078
+ ["hy", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.hy || []])],
2079
+ ["it", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.it || []])],
2080
+ ["ja", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.ja || []])],
2081
+ ["ko", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.ko || []])],
2082
+ ["la", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.la || []])],
2083
+ ["lt", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.lt || []])],
2084
+ ["lv", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.lv || []])],
2085
+ ["mr", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.mr || []])],
2086
+ ["nl", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.nl || []])],
2087
+ ["no", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.nob || []])],
2088
+ ["pl", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.pl || []])],
2089
+ ["pt", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.pt || []])],
2090
+ ["ro", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.ro || []])],
2091
+ ["ru", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.ru || []])],
2092
+ ["sk", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.sk || []])],
2093
+ ["sl", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.sl || []])],
2094
+ ["sv", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.sv || []])],
2095
+ ["th", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.th || []])],
2096
+ ["tr", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.tr || []])],
2097
+ ["uk", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.uk || []])],
2098
+ ["vi", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.vi || []])],
2099
+ ["zh", /* @__PURE__ */ new Set([...import_natural.default.stopwords || [], ...import_stopword.default.zh || []])]
2100
+ ]);
2101
+ var URL_ENDING_RESERVED_CHARS = /[).,;!?]+$/;
2102
+ var DATE_PATTERNS = [
2103
+ /\b(?:\d{1,2}[/-]){2}\d{2,4}\b/g,
2104
+ // MM/DD/YYYY or DD/MM/YYYY
2105
+ /\b\d{4}(?:[/-]\d{1,2}){2}\b/g,
2106
+ // YYYY/MM/DD
2107
+ /\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d{2,4}\b/gi,
2108
+ // DD MMM YYYY
2109
+ /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d{1,2},?\s+\d{2,4}\b/gi
2110
+ // MMM DD, YYYY
2111
+ ];
2112
+ var FILE_PATH_PATTERNS = [
2113
+ /[a-z]:\\\\[^\\s<>:"|?*]+/gi,
2114
+ // Windows paths
2115
+ /\/[^\\s<>:"|?*]+/g,
2116
+ // Unix paths
2117
+ /~\/[^\\s<>:"|?*]+/g
2118
+ // Home directory paths
2119
+ ];
2120
+ var CREDIT_CARD_PATTERN = (0, import_credit_card_regex.default)({ exact: false });
2121
+ var PHONE_PATTERN = (0, import_phone_regex.default)({ exact: false });
2122
+ var EMAIL_PATTERN = (0, import_email_regex_safe.default)({ exact: false });
2123
+ var IP_PATTERN = (0, import_ip_regex.default)({ exact: false });
2124
+ var URL_PATTERN = (0, import_url_regex_safe.default)({ exact: false });
2125
+ var BITCOIN_PATTERN = (0, import_bitcoin_regex.default)({ exact: false });
2126
+ var MAC_PATTERN = (0, import_mac_regex.default)({ exact: false });
2127
+ var HEX_COLOR_PATTERN = (0, import_hexa_color_regex.default)({ exact: false });
2128
+ var FLOATING_POINT_PATTERN = import_floating_point_regex.default;
2129
+ var SpamScanner = class {
2130
+ constructor(options = {}) {
2131
+ this.config = {
2132
+ // Enhanced configuration options
2133
+ enableMacroDetection: true,
2134
+ enablePerformanceMetrics: false,
2135
+ timeout: 3e4,
2136
+ supportedLanguages: ["en"],
2137
+ enableMixedLanguageDetection: false,
2138
+ enableAdvancedPatternRecognition: true,
2139
+ // Authentication options (mailauth)
2140
+ enableAuthentication: false,
2141
+ authOptions: {
2142
+ ip: null,
2143
+ // Remote IP address (required for auth)
2144
+ helo: null,
2145
+ // HELO/EHLO hostname
2146
+ mta: "spamscanner",
2147
+ // MTA hostname
2148
+ sender: null,
2149
+ // Envelope sender (MAIL FROM)
2150
+ timeout: 1e4
2151
+ // DNS lookup timeout
2152
+ },
2153
+ authScoreWeights: {
2154
+ dkimPass: -2,
2155
+ dkimFail: 3,
2156
+ spfPass: -1,
2157
+ spfFail: 2,
2158
+ spfSoftfail: 1,
2159
+ dmarcPass: -2,
2160
+ dmarcFail: 4,
2161
+ arcPass: -1,
2162
+ arcFail: 1
2163
+ },
2164
+ // Reputation API options (Forward Email)
2165
+ enableReputation: false,
2166
+ reputationOptions: {
2167
+ apiUrl: "https://api.forwardemail.net/v1/reputation",
2168
+ timeout: 1e4,
2169
+ onlyAligned: true
2170
+ },
2171
+ // Arbitrary spam detection options
2172
+ enableArbitraryDetection: true,
2173
+ arbitraryThreshold: 5,
2174
+ // Existing options
2175
+ debug: false,
2176
+ logger: console,
2177
+ clamscan: {
2178
+ removeInfected: false,
2179
+ quarantineInfected: false,
2180
+ scanLog: null,
2181
+ debugMode: false,
2182
+ fileList: null,
2183
+ scanRecursively: true,
2184
+ clamscanPath: "/usr/bin/clamscan",
2185
+ clamdscanPath: "/usr/bin/clamdscan",
2186
+ preference: "clamdscan"
2187
+ },
2188
+ classifier: null,
2189
+ replacements: null,
2190
+ ...options
2191
+ };
2192
+ this.classifier = null;
2193
+ this.clamscan = null;
2194
+ this.isInitialized = false;
2195
+ this.replacements = /* @__PURE__ */ new Map();
2196
+ this.metrics = {
2197
+ totalScans: 0,
2198
+ averageTime: 0,
2199
+ lastScanTime: 0
2200
+ };
2201
+ (0, import_auto_bind.default)(this);
2202
+ }
2203
+ async initializeClassifier() {
2204
+ if (this.classifier) {
2205
+ return;
2206
+ }
2207
+ try {
2208
+ if (this.config.classifier) {
2209
+ this.classifier = new import_naivebayes2.default(this.config.classifier);
2210
+ } else {
2211
+ const classifierData = await getClassifier();
2212
+ this.classifier = new import_naivebayes2.default(classifierData);
2213
+ }
2214
+ this.classifier.tokenizer = function(tokens) {
2215
+ if (typeof tokens === "string") {
2216
+ return tokens.split(/\s+/);
2217
+ }
2218
+ return Array.isArray(tokens) ? tokens : [];
2219
+ };
2220
+ } catch (error) {
2221
+ debug7("Failed to initialize classifier:", error);
2222
+ this.classifier = new import_naivebayes2.default();
2223
+ }
2224
+ }
2225
+ // Initialize replacements
2226
+ async initializeReplacements() {
2227
+ if (this.replacements && this.replacements.size > 0) {
2228
+ return;
2229
+ }
2230
+ try {
2231
+ const replacements2 = this.config.replacements || await getReplacements();
2232
+ if (replacements2 instanceof Map) {
2233
+ this.replacements = replacements2;
2234
+ } else if (typeof replacements2 === "object" && replacements2 !== null) {
2235
+ this.replacements = new Map(Object.entries(replacements2));
2236
+ } else {
2237
+ throw new Error("Invalid replacements format");
2238
+ }
2239
+ } catch (error) {
2240
+ debug7("Failed to initialize replacements:", error);
2241
+ this.replacements = /* @__PURE__ */ new Map();
2242
+ const basicReplacements = {
2243
+ u: "you",
2244
+ ur: "your",
2245
+ r: "are",
2246
+ n: "and",
2247
+ "w/": "with",
2248
+ b4: "before",
2249
+ 2: "to",
2250
+ 4: "for"
2251
+ };
2252
+ for (const [word, replacement] of Object.entries(basicReplacements)) {
2253
+ this.replacements.set(word, replacement);
2254
+ }
2255
+ }
2256
+ }
2257
+ // Initialize regex helpers
2258
+ initializeRegex() {
2259
+ this.regexCache = /* @__PURE__ */ new Map();
2260
+ this.urlCache = /* @__PURE__ */ new Map();
2261
+ }
2262
+ // Enhanced virus scanning with timeout protection
2263
+ async getVirusResults(mail) {
2264
+ if (!this.clamscan) {
2265
+ try {
2266
+ this.clamscan = await new import_clamscan.default().init(this.config.clamscan);
2267
+ } catch (error) {
2268
+ debug7("ClamScan initialization failed:", error);
2269
+ return [];
2270
+ }
2271
+ }
2272
+ const results = [];
2273
+ const attachments = mail.attachments || [];
2274
+ for (const attachment of attachments) {
2275
+ try {
2276
+ if (attachment.content && (0, import_is_buffer.default)(attachment.content)) {
2277
+ const scanResult = await Promise.race([
2278
+ this.clamscan.scanBuffer(attachment.content),
2279
+ new Promise((_resolve, reject) => {
2280
+ setTimeout(() => reject(new Error("Virus scan timeout")), this.config.timeout);
2281
+ })
2282
+ ]);
2283
+ if (scanResult.isInfected) {
2284
+ results.push({
2285
+ filename: attachment.filename || "unknown",
2286
+ virus: scanResult.viruses || ["Unknown virus"],
2287
+ type: "virus"
2288
+ });
2289
+ }
2290
+ }
2291
+ } catch (error) {
2292
+ debug7("Virus scan error:", error);
2293
+ }
2294
+ }
2295
+ return results;
2296
+ }
2297
+ // Macro detection (DONE)
2298
+ async getMacroResults(mail) {
2299
+ const results = [];
2300
+ const attachments = mail.attachments || [];
2301
+ const textContent = mail.text || "";
2302
+ const htmlContent = mail.html || "";
2303
+ const vbaPatterns = [
2304
+ /sub\s+\w+\s*\(/gi,
2305
+ /function\s+\w+\s*\(/gi,
2306
+ /dim\s+\w+\s+as\s+\w+/gi,
2307
+ /application\.run/gi,
2308
+ /shell\s*\(/gi
2309
+ ];
2310
+ const powershellPatterns = [
2311
+ /powershell/gi,
2312
+ /invoke-expression/gi,
2313
+ /iex\s*\(/gi,
2314
+ /start-process/gi,
2315
+ /new-object\s+system\./gi
2316
+ ];
2317
+ const jsPatterns = [
2318
+ /eval\s*\(/gi,
2319
+ /document\.write/gi,
2320
+ /activexobject/gi,
2321
+ /wscript\./gi,
2322
+ /new\s+activexobject/gi
2323
+ ];
2324
+ const batchPatterns = [/@echo\s+off/gi, /cmd\s*\/c/gi, /start\s+\/b/gi, /for\s+\/[lrf]/gi];
2325
+ let allContent = textContent + " " + htmlContent;
2326
+ if (mail.headerLines && Array.isArray(mail.headerLines)) {
2327
+ for (const headerLine of mail.headerLines) {
2328
+ if (headerLine.line) {
2329
+ allContent += " " + headerLine.line;
2330
+ }
2331
+ }
2332
+ }
2333
+ for (const pattern of vbaPatterns) {
2334
+ if (pattern.test(allContent)) {
2335
+ results.push({
2336
+ type: "macro",
2337
+ subtype: "vba",
2338
+ description: "VBA macro detected"
2339
+ });
2340
+ break;
2341
+ }
2342
+ }
2343
+ for (const pattern of powershellPatterns) {
2344
+ if (pattern.test(allContent)) {
2345
+ results.push({
2346
+ type: "macro",
2347
+ subtype: "powershell",
2348
+ description: "PowerShell script detected"
2349
+ });
2350
+ break;
2351
+ }
2352
+ }
2353
+ for (const pattern of jsPatterns) {
2354
+ if (pattern.test(allContent)) {
2355
+ results.push({
2356
+ type: "macro",
2357
+ subtype: "javascript",
2358
+ description: "JavaScript macro detected"
2359
+ });
2360
+ break;
2361
+ }
2362
+ }
2363
+ for (const pattern of batchPatterns) {
2364
+ if (pattern.test(allContent)) {
2365
+ results.push({
2366
+ type: "macro",
2367
+ subtype: "batch",
2368
+ description: "Batch script detected"
2369
+ });
2370
+ break;
2371
+ }
2372
+ }
2373
+ for (const attachment of attachments) {
2374
+ if (attachment.filename) {
2375
+ const extension = (0, import_file_extension.default)(attachment.filename).toLowerCase();
2376
+ const macroExtensions = ["vbs", "vba", "ps1", "bat", "cmd", "scr", "pif"];
2377
+ const officeMacroExtensions = ["docm", "xlsm", "pptm", "xlam", "dotm", "xltm", "potm"];
2378
+ const legacyOfficeExtensions = ["doc", "xls", "ppt", "dot", "xlt", "pot", "xla", "ppa"];
2379
+ if (macroExtensions.includes(extension)) {
2380
+ results.push({
2381
+ type: "macro",
2382
+ subtype: "script",
2383
+ filename: attachment.filename,
2384
+ description: `Macro script attachment detected: ${extension}`
2385
+ });
2386
+ } else if (officeMacroExtensions.includes(extension)) {
2387
+ results.push({
2388
+ type: "macro",
2389
+ subtype: "office_document",
2390
+ filename: attachment.filename,
2391
+ description: `Office document with macro capability: ${extension}`
2392
+ });
2393
+ } else if (legacyOfficeExtensions.includes(extension)) {
2394
+ results.push({
2395
+ type: "macro",
2396
+ subtype: "legacy_office",
2397
+ filename: attachment.filename,
2398
+ description: `Legacy Office document (macro-capable): ${extension}`,
2399
+ risk: "high"
2400
+ });
2401
+ }
2402
+ if (extension === "pdf" && attachment.content && (0, import_is_buffer.default)(attachment.content)) {
2403
+ try {
2404
+ const pdfContent = attachment.content.toString("latin1");
2405
+ if (pdfContent.includes("/JavaScript") || pdfContent.includes("/JS")) {
2406
+ results.push({
2407
+ type: "macro",
2408
+ subtype: "pdf_javascript",
2409
+ filename: attachment.filename,
2410
+ description: "PDF with embedded JavaScript detected",
2411
+ risk: "medium"
2412
+ });
2413
+ }
2414
+ } catch (error) {
2415
+ debug7("PDF JavaScript detection error:", error);
2416
+ }
2417
+ }
2418
+ }
2419
+ }
2420
+ return results;
2421
+ }
2422
+ // File path detection (DONE)
2423
+ async getFilePathResults(mail) {
2424
+ const results = [];
2425
+ const textContent = mail.text || "";
2426
+ const htmlContent = mail.html || "";
2427
+ const allContent = textContent + " " + htmlContent;
2428
+ for (const pattern of FILE_PATH_PATTERNS) {
2429
+ const matches = allContent.match(pattern);
2430
+ if (matches) {
2431
+ for (const match of matches) {
2432
+ if (this.isValidFilePath(match)) {
2433
+ results.push({
2434
+ type: "file_path",
2435
+ path: match,
2436
+ description: "Suspicious file path detected"
2437
+ });
2438
+ }
2439
+ }
2440
+ }
2441
+ }
2442
+ return results;
2443
+ }
2444
+ // Check if a path is a valid file path (not HTML tag or false positive)
2445
+ isValidFilePath(path3) {
2446
+ const htmlTags = [
2447
+ "a",
2448
+ "abbr",
2449
+ "address",
2450
+ "area",
2451
+ "article",
2452
+ "aside",
2453
+ "audio",
2454
+ "b",
2455
+ "base",
2456
+ "bdi",
2457
+ "bdo",
2458
+ "blockquote",
2459
+ "body",
2460
+ "br",
2461
+ "button",
2462
+ "canvas",
2463
+ "caption",
2464
+ "cite",
2465
+ "code",
2466
+ "col",
2467
+ "colgroup",
2468
+ "data",
2469
+ "datalist",
2470
+ "dd",
2471
+ "del",
2472
+ "details",
2473
+ "dfn",
2474
+ "dialog",
2475
+ "div",
2476
+ "dl",
2477
+ "dt",
2478
+ "em",
2479
+ "embed",
2480
+ "fieldset",
2481
+ "figcaption",
2482
+ "figure",
2483
+ "footer",
2484
+ "form",
2485
+ "h1",
2486
+ "h2",
2487
+ "h3",
2488
+ "h4",
2489
+ "h5",
2490
+ "h6",
2491
+ "head",
2492
+ "header",
2493
+ "hr",
2494
+ "html",
2495
+ "i",
2496
+ "iframe",
2497
+ "img",
2498
+ "input",
2499
+ "ins",
2500
+ "kbd",
2501
+ "label",
2502
+ "legend",
2503
+ "li",
2504
+ "link",
2505
+ "main",
2506
+ "map",
2507
+ "mark",
2508
+ "meta",
2509
+ "meter",
2510
+ "nav",
2511
+ "noscript",
2512
+ "object",
2513
+ "ol",
2514
+ "optgroup",
2515
+ "option",
2516
+ "output",
2517
+ "p",
2518
+ "param",
2519
+ "picture",
2520
+ "pre",
2521
+ "progress",
2522
+ "q",
2523
+ "rp",
2524
+ "rt",
2525
+ "ruby",
2526
+ "s",
2527
+ "samp",
2528
+ "script",
2529
+ "section",
2530
+ "select",
2531
+ "small",
2532
+ "source",
2533
+ "span",
2534
+ "strong",
2535
+ "style",
2536
+ "sub",
2537
+ "summary",
2538
+ "sup",
2539
+ "svg",
2540
+ "table",
2541
+ "tbody",
2542
+ "td",
2543
+ "template",
2544
+ "textarea",
2545
+ "tfoot",
2546
+ "th",
2547
+ "thead",
2548
+ "time",
2549
+ "title",
2550
+ "tr",
2551
+ "track",
2552
+ "u",
2553
+ "ul",
2554
+ "var",
2555
+ "video",
2556
+ "wbr"
2557
+ ];
2558
+ const tagMatch = path3.match(/^\/([a-z\d]+)$/i);
2559
+ if (tagMatch && htmlTags.includes(tagMatch[1].toLowerCase())) {
2560
+ return false;
2561
+ }
2562
+ if (path3.length < 4) {
2563
+ return false;
2564
+ }
2565
+ if (/^\/\/[a-z\d.-]+$/i.test(path3)) {
2566
+ return false;
2567
+ }
2568
+ if (!path3.includes(".") && !path3.includes("/")) {
2569
+ return false;
2570
+ }
2571
+ return true;
2572
+ }
2573
+ // Optimize URL parsing with timeout protection (DONE)
2574
+ async optimizeUrlParsing(url) {
2575
+ try {
2576
+ return await Promise.race([
2577
+ (0, import_normalize_url.default)(url, {
2578
+ stripHash: true,
2579
+ stripWWW: false,
2580
+ removeQueryParameters: false
2581
+ }),
2582
+ new Promise((_resolve, reject) => {
2583
+ setTimeout(() => reject(new Error("URL parsing timeout")), 5e3);
2584
+ })
2585
+ ]);
2586
+ } catch {
2587
+ return url;
2588
+ }
2589
+ }
2590
+ // Enhanced Cloudflare blocked domain checking with timeout
2591
+ async isCloudflareBlocked(hostname) {
2592
+ try {
2593
+ const response = await Promise.race([
2594
+ import_superagent.default.get(`https://1.1.1.3/dns-query?name=${hostname}&type=A`).set("Accept", "application/dns-json").timeout(5e3),
2595
+ new Promise((_resolve, reject) => {
2596
+ setTimeout(() => reject(new Error("DNS timeout")), 5e3);
2597
+ })
2598
+ ]);
2599
+ return response.body?.Status === 3;
2600
+ } catch {
2601
+ return false;
2602
+ }
2603
+ }
2604
+ // Extract URLs from all possible sources
2605
+ extractAllUrls(mail, originalSource) {
2606
+ let allText = "";
2607
+ allText += (mail.text || "") + " " + (mail.html || "");
2608
+ if (mail.headerLines && Array.isArray(mail.headerLines)) {
2609
+ for (const headerLine of mail.headerLines) {
2610
+ if (headerLine.line) {
2611
+ allText += " " + headerLine.line;
2612
+ }
2613
+ }
2614
+ }
2615
+ if (typeof originalSource === "string") {
2616
+ allText += " " + originalSource;
2617
+ }
2618
+ return this.getUrls(allText);
2619
+ }
2620
+ // Enhanced URL extraction with improved parsing using tldts
2621
+ getUrls(string_) {
2622
+ if (!(0, import_is_string_and_not_blank.default)(string_)) {
2623
+ return [];
2624
+ }
2625
+ const urls = [];
2626
+ const matches = string_.match(URL_PATTERN);
2627
+ if (matches) {
2628
+ for (let url of matches) {
2629
+ url = url.replace(URL_ENDING_RESERVED_CHARS, "");
2630
+ try {
2631
+ const normalizedUrl = (0, import_normalize_url.default)(url, {
2632
+ stripHash: false,
2633
+ stripWWW: false
2634
+ });
2635
+ urls.push(normalizedUrl);
2636
+ } catch {
2637
+ urls.push(url);
2638
+ }
2639
+ }
2640
+ }
2641
+ return [...new Set(urls)];
2642
+ }
2643
+ // Parse URL using tldts for accurate domain extraction
2644
+ parseUrlWithTldts(url) {
2645
+ try {
2646
+ const parsed = (0, import_tldts.parse)(url, { allowPrivateDomains: true });
2647
+ return {
2648
+ domain: parsed.domain,
2649
+ domainWithoutSuffix: parsed.domainWithoutSuffix,
2650
+ hostname: parsed.hostname,
2651
+ publicSuffix: parsed.publicSuffix,
2652
+ subdomain: parsed.subdomain,
2653
+ isIp: parsed.isIp,
2654
+ isIcann: parsed.isIcann,
2655
+ isPrivate: parsed.isPrivate
2656
+ };
2657
+ } catch (error) {
2658
+ debug7("tldts parsing error:", error);
2659
+ return null;
2660
+ }
2661
+ }
2662
+ // Enhanced tokenization with language detection
2663
+ async getTokens(string_, locale = "en", isHtml = false) {
2664
+ if (!(0, import_is_string_and_not_blank.default)(string_)) {
2665
+ return [];
2666
+ }
2667
+ let text = string_;
2668
+ if (isHtml) {
2669
+ text = (0, import_striptags.default)(text);
2670
+ }
2671
+ if (!locale || this.config.enableMixedLanguageDetection) {
2672
+ try {
2673
+ const detected = (0, import_lande.default)(text);
2674
+ if (detected && detected.length > 0) {
2675
+ locale = detected[0][0];
2676
+ }
2677
+ } catch {
2678
+ locale ||= "en";
2679
+ }
2680
+ }
2681
+ locale = this.parseLocale(locale);
2682
+ text = converter.toHalfWidth(text);
2683
+ try {
2684
+ text = (0, import_nlp_expand_contractions.default)(text);
2685
+ } catch {
2686
+ }
2687
+ let tokens = [];
2688
+ if (locale === "ja") {
2689
+ try {
2690
+ tokens = chineseTokenizer.tokenize(text);
2691
+ } catch {
2692
+ tokens = text.split(GENERIC_TOKENIZER);
2693
+ }
2694
+ } else if (locale === "zh") {
2695
+ try {
2696
+ tokens = chineseTokenizer.tokenize(text);
2697
+ } catch {
2698
+ tokens = text.split(GENERIC_TOKENIZER);
2699
+ }
2700
+ } else {
2701
+ tokens = text.split(GENERIC_TOKENIZER);
2702
+ }
2703
+ let processedTokens = tokens.map((token) => token.toLowerCase().trim()).filter((token) => token.length > 0 && token.length <= 50);
2704
+ const stopwordSet = stopwordsMap.get(locale) || stopwordsMap.get("en");
2705
+ if (stopwordSet) {
2706
+ processedTokens = processedTokens.filter((token) => !stopwordSet.has(token));
2707
+ }
2708
+ try {
2709
+ if (["en", "es", "fr", "de", "it", "pt", "ru"].includes(locale)) {
2710
+ processedTokens = processedTokens.map((token) => {
2711
+ try {
2712
+ return import_node_snowball.default.stemword(token, locale);
2713
+ } catch {
2714
+ return token;
2715
+ }
2716
+ });
2717
+ }
2718
+ } catch {
2719
+ }
2720
+ if (this.config.hashTokens) {
2721
+ processedTokens = processedTokens.map((token) => (0, import_node_crypto2.createHash)("sha256").update(token).digest("hex").slice(0, 16));
2722
+ }
2723
+ return processedTokens;
2724
+ }
2725
+ // Enhanced text preprocessing with pattern recognition
2726
+ async preprocessText(string_) {
2727
+ if (!(0, import_is_string_and_not_blank.default)(string_)) {
2728
+ return "";
2729
+ }
2730
+ let text = string_;
2731
+ if (this.replacements) {
2732
+ for (const [original, replacement] of this.replacements) {
2733
+ text = text.replaceAll(new RegExp((0, import_escape_string_regexp.default)(original), "gi"), replacement);
2734
+ }
2735
+ }
2736
+ if (this.config.enableAdvancedPatternRecognition) {
2737
+ text = text.replaceAll(DATE_PATTERNS[0], " DATE_PATTERN ");
2738
+ text = text.replace(CREDIT_CARD_PATTERN, " CREDIT_CARD ");
2739
+ text = text.replace(PHONE_PATTERN, " PHONE_NUMBER ");
2740
+ text = text.replace(EMAIL_PATTERN, " EMAIL_ADDRESS ");
2741
+ text = text.replace(IP_PATTERN, " IP_ADDRESS ");
2742
+ text = text.replace(URL_PATTERN, " URL_LINK ");
2743
+ text = text.replace(BITCOIN_PATTERN, " BITCOIN_ADDRESS ");
2744
+ text = text.replace(MAC_PATTERN, " MAC_ADDRESS ");
2745
+ text = text.replace(HEX_COLOR_PATTERN, " HEX_COLOR ");
2746
+ text = text.replace(FLOATING_POINT_PATTERN, " FLOATING_POINT ");
2747
+ }
2748
+ return text;
2749
+ }
2750
+ // Main scan method - enhanced with performance metrics, auth, and reputation
2751
+ async scan(source, scanOptions = {}) {
2752
+ const startTime = Date.now();
2753
+ try {
2754
+ await this.initializeClassifier();
2755
+ await this.initializeReplacements();
2756
+ const { tokens, mail } = await this.getTokensAndMailFromSource(source);
2757
+ const authOptions = { ...this.config.authOptions, ...scanOptions.authOptions };
2758
+ const reputationOptions = { ...this.config.reputationOptions, ...scanOptions.reputationOptions };
2759
+ const detectionPromises = [
2760
+ this.getClassification(tokens),
2761
+ this.getPhishingResults(mail),
2762
+ this.getExecutableResults(mail),
2763
+ this.config.enableMacroDetection ? this.getMacroResults(mail) : [],
2764
+ this.config.enableArbitraryDetection ? this.getArbitraryResults(mail, { remoteAddress: authOptions.ip, resolvedClientHostname: authOptions.hostname }) : [],
2765
+ this.getVirusResults(mail),
2766
+ this.getPatternResults(mail),
2767
+ this.getIDNHomographResults(mail),
2768
+ this.getToxicityResults(mail),
2769
+ this.getNSFWResults(mail)
2770
+ ];
2771
+ const enableAuth = scanOptions.enableAuthentication ?? this.config.enableAuthentication;
2772
+ if (enableAuth && authOptions.ip) {
2773
+ detectionPromises.push(this.getAuthenticationResults(source, mail, authOptions));
2774
+ } else {
2775
+ detectionPromises.push(Promise.resolve(null));
2776
+ }
2777
+ const enableReputation = scanOptions.enableReputation ?? this.config.enableReputation;
2778
+ if (enableReputation) {
2779
+ detectionPromises.push(this.getReputationResults(mail, authOptions, reputationOptions));
2780
+ } else {
2781
+ detectionPromises.push(Promise.resolve(null));
2782
+ }
2783
+ const [
2784
+ classification,
2785
+ phishing,
2786
+ executables,
2787
+ macros,
2788
+ arbitrary,
2789
+ viruses,
2790
+ patterns,
2791
+ idnHomographAttack,
2792
+ toxicity,
2793
+ nsfw,
2794
+ authResult,
2795
+ reputationResult
2796
+ ] = await Promise.all(detectionPromises);
2797
+ let isSpam = classification.category === "spam" || phishing.length > 0 || executables.length > 0 || macros.length > 0 || arbitrary.length > 0 || viruses.length > 0 || patterns.length > 0 || idnHomographAttack && idnHomographAttack.detected || toxicity.length > 0 || nsfw.length > 0;
2798
+ if (reputationResult && reputationResult.isDenylisted) {
2799
+ isSpam = true;
2800
+ }
2801
+ let message = "Ham";
2802
+ if (isSpam) {
2803
+ const reasons = [];
2804
+ if (classification.category === "spam") {
2805
+ reasons.push("spam classification");
2806
+ }
2807
+ if (phishing.length > 0) {
2808
+ reasons.push("phishing detected");
2809
+ }
2810
+ if (executables.length > 0) {
2811
+ reasons.push("executable content");
2812
+ }
2813
+ if (macros.length > 0) {
2814
+ reasons.push("macro detected");
2815
+ }
2816
+ if (arbitrary.length > 0) {
2817
+ reasons.push("arbitrary patterns");
2818
+ }
2819
+ if (viruses.length > 0) {
2820
+ reasons.push("virus detected");
2821
+ }
2822
+ if (patterns.length > 0) {
2823
+ reasons.push("suspicious patterns");
2824
+ }
2825
+ if (idnHomographAttack && idnHomographAttack.detected) {
2826
+ reasons.push("IDN homograph attack");
2827
+ }
2828
+ if (toxicity.length > 0) {
2829
+ reasons.push("toxic content");
2830
+ }
2831
+ if (nsfw.length > 0) {
2832
+ reasons.push("NSFW content");
2833
+ }
2834
+ if (reputationResult?.isDenylisted) {
2835
+ reasons.push("denylisted sender");
2836
+ }
2837
+ message = `Spam (${(0, import_array_join_conjunction.default)(reasons)})`;
2838
+ } else if (reputationResult?.isTruthSource) {
2839
+ message = "Ham (truth source)";
2840
+ } else if (reputationResult?.isAllowlisted) {
2841
+ message = "Ham (allowlisted)";
2842
+ }
2843
+ const endTime = Date.now();
2844
+ const processingTime = endTime - startTime;
2845
+ this.metrics.totalScans++;
2846
+ this.metrics.lastScanTime = processingTime;
2847
+ this.metrics.averageTime = (this.metrics.averageTime * (this.metrics.totalScans - 1) + processingTime) / this.metrics.totalScans;
2848
+ const result = {
2849
+ isSpam,
2850
+ message,
2851
+ results: {
2852
+ classification,
2853
+ phishing,
2854
+ executables,
2855
+ macros,
2856
+ arbitrary,
2857
+ viruses,
2858
+ patterns,
2859
+ idnHomographAttack,
2860
+ toxicity,
2861
+ nsfw,
2862
+ authentication: authResult,
2863
+ reputation: reputationResult
2864
+ },
2865
+ links: this.extractAllUrls(mail, source),
2866
+ tokens,
2867
+ mail
2868
+ };
2869
+ if (this.config.enablePerformanceMetrics) {
2870
+ result.metrics = {
2871
+ totalTime: processingTime,
2872
+ classificationTime: 0,
2873
+ // Would need to measure individually
2874
+ phishingTime: 0,
2875
+ executableTime: 0,
2876
+ macroTime: 0,
2877
+ virusTime: 0,
2878
+ patternTime: 0,
2879
+ idnTime: 0,
2880
+ memoryUsage: import_node_process.default.memoryUsage()
2881
+ };
2882
+ }
2883
+ return result;
2884
+ } catch (error) {
2885
+ debug7("Scan error:", error);
2886
+ throw error;
2887
+ }
2888
+ }
2889
+ // Get authentication results using mailauth
2890
+ async getAuthenticationResults(source, mail, options = {}) {
2891
+ try {
2892
+ const messageBuffer = typeof source === "string" ? import_node_buffer2.Buffer.from(source) : source;
2893
+ const sender = options.sender || mail.from?.value?.[0]?.address || mail.from?.text;
2894
+ const authResult = await authenticate(messageBuffer, {
2895
+ ip: options.ip,
2896
+ helo: options.helo,
2897
+ mta: options.mta || "spamscanner",
2898
+ sender,
2899
+ timeout: options.timeout || 1e4
2900
+ });
2901
+ const scoreResult = calculateAuthScore(authResult, this.config.authScoreWeights);
2902
+ return {
2903
+ ...authResult,
2904
+ score: scoreResult,
2905
+ authResultsHeader: formatAuthResultsHeader(authResult, options.mta || "spamscanner")
2906
+ };
2907
+ } catch (error) {
2908
+ debug7("Authentication error:", error);
2909
+ return null;
2910
+ }
2911
+ }
2912
+ // Get reputation results from Forward Email API
2913
+ // Uses get-attributes module to extract comprehensive attributes for checking
2914
+ async getReputationResults(mail, authOptions = {}, reputationOptions = {}) {
2915
+ try {
2916
+ const { attributes, session } = await extractAttributes(mail, {
2917
+ isAligned: reputationOptions.onlyAligned ?? true,
2918
+ senderIp: authOptions.ip,
2919
+ senderHostname: authOptions.hostname,
2920
+ authResults: authOptions.authResults
2921
+ });
2922
+ const valuesToCheck = [...attributes];
2923
+ if (authOptions.sender) {
2924
+ const senderLower = authOptions.sender.toLowerCase();
2925
+ if (!valuesToCheck.includes(senderLower)) {
2926
+ valuesToCheck.push(senderLower);
2927
+ }
2928
+ const envelopeDomain = senderLower.split("@")[1];
2929
+ if (envelopeDomain && !valuesToCheck.includes(envelopeDomain)) {
2930
+ valuesToCheck.push(envelopeDomain);
2931
+ }
2932
+ }
2933
+ const replyTo = mail.replyTo?.value || [];
2934
+ for (const addr of replyTo) {
2935
+ if (addr.address) {
2936
+ const addrLower = addr.address.toLowerCase();
2937
+ if (!valuesToCheck.includes(addrLower)) {
2938
+ valuesToCheck.push(addrLower);
2939
+ }
2940
+ const domain = addrLower.split("@")[1];
2941
+ if (domain && !valuesToCheck.includes(domain)) {
2942
+ valuesToCheck.push(domain);
2943
+ }
2944
+ }
2945
+ }
2946
+ if (valuesToCheck.length === 0) {
2947
+ return null;
2948
+ }
2949
+ debug7("Checking reputation for %d attributes: %o", valuesToCheck.length, valuesToCheck);
2950
+ const resultsMap = await checkReputationBatch(valuesToCheck, reputationOptions);
2951
+ const aggregated = aggregateReputationResults([...resultsMap.values()]);
2952
+ return {
2953
+ ...aggregated,
2954
+ checkedValues: valuesToCheck,
2955
+ details: Object.fromEntries(resultsMap),
2956
+ session
2957
+ // Include session info for debugging
2958
+ };
2959
+ } catch (error) {
2960
+ debug7("Reputation check error:", error);
2961
+ return null;
2962
+ }
2963
+ }
2964
+ // Get pattern recognition results
2965
+ async getPatternResults(mail) {
2966
+ const results = [];
2967
+ const textContent = mail.text || "";
2968
+ const htmlContent = mail.html || "";
2969
+ const allContent = textContent + " " + htmlContent;
2970
+ for (const pattern of DATE_PATTERNS) {
2971
+ const matches = allContent.match(pattern);
2972
+ if (matches && matches.length > 5) {
2973
+ results.push({
2974
+ type: "pattern",
2975
+ subtype: "date_spam",
2976
+ count: matches.length,
2977
+ description: "Excessive date patterns detected"
2978
+ });
2979
+ }
2980
+ }
2981
+ const filePathResults = await this.getFilePathResults(mail);
2982
+ results.push(...filePathResults);
2983
+ return results;
2984
+ }
2985
+ // Enhanced mail parsing with better error handling
2986
+ async getTokensAndMailFromSource(source) {
2987
+ let mail;
2988
+ if (typeof source === "string" && import_node_fs3.default.existsSync(source)) {
2989
+ source = import_node_fs3.default.readFileSync(source);
2990
+ }
2991
+ if ((0, import_is_buffer.default)(source)) {
2992
+ source = source.toString();
2993
+ }
2994
+ if (!source || typeof source !== "string") {
2995
+ source = "";
2996
+ }
2997
+ try {
2998
+ mail = await (0, import_mailparser.simpleParser)(source);
2999
+ } catch (error) {
3000
+ debug7("Mail parsing error:", error);
3001
+ mail = {
3002
+ text: source,
3003
+ html: "",
3004
+ subject: "",
3005
+ from: {},
3006
+ to: [],
3007
+ attachments: []
3008
+ };
3009
+ }
3010
+ const textContent = await this.preprocessText(mail.text || "");
3011
+ const htmlContent = await this.preprocessText((0, import_striptags.default)(mail.html || ""));
3012
+ const subjectContent = await this.preprocessText(mail.subject || "");
3013
+ const allContent = [textContent, htmlContent, subjectContent].join(" ");
3014
+ const tokens = await this.getTokens(allContent, "en");
3015
+ return { tokens, mail };
3016
+ }
3017
+ // Enhanced classification with better error handling
3018
+ async getClassification(tokens) {
3019
+ if (!this.classifier) {
3020
+ await this.initializeClassifier();
3021
+ }
3022
+ try {
3023
+ const text = Array.isArray(tokens) ? tokens.join(" ") : String(tokens);
3024
+ const result = this.classifier.categorize(text);
3025
+ return {
3026
+ category: result,
3027
+ probability: 0.5
3028
+ // Default probability
3029
+ };
3030
+ } catch (error) {
3031
+ debug7("Classification error:", error);
3032
+ return {
3033
+ category: "ham",
3034
+ probability: 0.5
3035
+ };
3036
+ }
3037
+ }
3038
+ // Enhanced phishing detection
3039
+ async getPhishingResults(mail) {
3040
+ const results = [];
3041
+ const links = this.getUrls(mail.text || "");
3042
+ for (const url of links) {
3043
+ try {
3044
+ const normalizedUrl = await this.optimizeUrlParsing(url);
3045
+ const parsed = new URL(normalizedUrl);
3046
+ const isBlocked = await this.isCloudflareBlocked(parsed.hostname);
3047
+ if (isBlocked) {
3048
+ results.push({
3049
+ type: "phishing",
3050
+ url: normalizedUrl,
3051
+ description: "Blocked by security filters"
3052
+ });
3053
+ }
3054
+ const idnDetector = await this.getIDNDetector();
3055
+ if (idnDetector && parsed.hostname) {
3056
+ const context = {
3057
+ emailContent: mail.text || mail.html || "",
3058
+ displayText: url === normalizedUrl ? null : url,
3059
+ senderReputation: 0.5
3060
+ // Default neutral reputation
3061
+ };
3062
+ const idnAnalysis = idnDetector.detectHomographAttack(parsed.hostname, context);
3063
+ if (idnAnalysis.riskScore > 0.6) {
3064
+ results.push({
3065
+ type: "phishing",
3066
+ url: normalizedUrl,
3067
+ description: `IDN homograph attack detected (risk: ${(idnAnalysis.riskScore * 100).toFixed(1)}%)`,
3068
+ details: {
3069
+ riskFactors: idnAnalysis.riskFactors,
3070
+ recommendations: idnAnalysis.recommendations,
3071
+ confidence: idnAnalysis.confidence
3072
+ }
3073
+ });
3074
+ } else if (idnAnalysis.riskScore > 0.3) {
3075
+ results.push({
3076
+ type: "suspicious",
3077
+ url: normalizedUrl,
3078
+ description: `Suspicious IDN domain (risk: ${(idnAnalysis.riskScore * 100).toFixed(1)}%)`,
3079
+ details: {
3080
+ riskFactors: idnAnalysis.riskFactors,
3081
+ recommendations: idnAnalysis.recommendations
3082
+ }
3083
+ });
3084
+ }
3085
+ }
3086
+ } catch (error) {
3087
+ debug7("Phishing check error:", error);
3088
+ }
3089
+ }
3090
+ return results;
3091
+ }
3092
+ // Enhanced executable detection
3093
+ async getExecutableResults(mail) {
3094
+ const results = [];
3095
+ const attachments = mail.attachments || [];
3096
+ for (const attachment of attachments) {
3097
+ if (attachment.filename) {
3098
+ const extension = (0, import_file_extension.default)(attachment.filename).toLowerCase();
3099
+ if (EXECUTABLES.has(extension)) {
3100
+ results.push({
3101
+ type: "executable",
3102
+ filename: attachment.filename,
3103
+ extension,
3104
+ description: "Executable file attachment"
3105
+ });
3106
+ }
3107
+ const archiveExtensions = ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "arj", "cab", "lzh", "ace", "iso"];
3108
+ if (archiveExtensions.includes(extension)) {
3109
+ results.push({
3110
+ type: "archive",
3111
+ filename: attachment.filename,
3112
+ extension,
3113
+ description: "Archive attachment (contents not scanned)",
3114
+ risk: "medium",
3115
+ warning: "Archive contents should be extracted and scanned separately"
3116
+ });
3117
+ }
3118
+ }
3119
+ if (attachment.content && (0, import_is_buffer.default)(attachment.content)) {
3120
+ try {
3121
+ const fileType = await (0, import_file_type.fileTypeFromBuffer)(attachment.content);
3122
+ if (fileType && EXECUTABLES.has(fileType.ext)) {
3123
+ results.push({
3124
+ type: "executable",
3125
+ filename: attachment.filename || "unknown",
3126
+ detectedType: fileType.ext,
3127
+ description: "Executable content detected"
3128
+ });
3129
+ }
3130
+ } catch (error) {
3131
+ debug7("File type detection error:", error);
3132
+ }
3133
+ }
3134
+ }
3135
+ return results;
3136
+ }
3137
+ // Arbitrary results (GTUBE, spam patterns, etc.)
3138
+ // Updated to use session info for Microsoft Exchange spam detection
3139
+ async getArbitraryResults(mail, sessionInfo = {}) {
3140
+ const results = [];
3141
+ let content = (mail.text || "") + (mail.html || "");
3142
+ if (mail.headerLines && Array.isArray(mail.headerLines)) {
3143
+ for (const headerLine of mail.headerLines) {
3144
+ if (headerLine.line) {
3145
+ content += " " + headerLine.line;
3146
+ }
3147
+ }
3148
+ }
3149
+ if (content.includes("XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X")) {
3150
+ results.push({
3151
+ type: "arbitrary",
3152
+ subtype: "gtube",
3153
+ description: "GTUBE spam test pattern detected",
3154
+ score: 100
3155
+ });
3156
+ }
3157
+ try {
3158
+ const session = buildSessionInfo(mail, sessionInfo);
3159
+ const arbitraryResult = isArbitrary(mail, {
3160
+ threshold: this.config.arbitraryThreshold || 5,
3161
+ checkSubject: true,
3162
+ checkBody: true,
3163
+ checkSender: true,
3164
+ checkHeaders: true,
3165
+ checkLinks: true,
3166
+ checkMicrosoftHeaders: true,
3167
+ // Enable Microsoft Exchange spam detection
3168
+ checkVendorSpam: true,
3169
+ // Enable vendor-specific spam detection
3170
+ checkSpoofing: true,
3171
+ // Enable spoofing attack detection
3172
+ session
3173
+ // Pass session info for advanced checks
3174
+ });
3175
+ if (arbitraryResult.isArbitrary) {
3176
+ results.push({
3177
+ type: "arbitrary",
3178
+ subtype: arbitraryResult.category ? arbitraryResult.category.toLowerCase() : "pattern",
3179
+ description: `Arbitrary spam patterns detected (score: ${arbitraryResult.score})`,
3180
+ score: arbitraryResult.score,
3181
+ reasons: arbitraryResult.reasons,
3182
+ category: arbitraryResult.category
3183
+ });
3184
+ }
3185
+ } catch (error) {
3186
+ debug7("Arbitrary detection error:", error);
3187
+ }
3188
+ return results;
3189
+ }
3190
+ // Parse and normalize locale
3191
+ parseLocale(locale) {
3192
+ if (!locale || typeof locale !== "string") {
3193
+ return "en";
3194
+ }
3195
+ const normalized = locale.toLowerCase().split("-")[0];
3196
+ const localeMap = {
3197
+ nb: "no",
3198
+ // Norwegian Bokmål
3199
+ nn: "no",
3200
+ // Norwegian Nynorsk
3201
+ "zh-cn": "zh",
3202
+ "zh-tw": "zh"
3203
+ };
3204
+ return localeMap[normalized] || normalized;
3205
+ }
3206
+ // Get IDN homograph attack results
3207
+ async getIDNHomographResults(mail) {
3208
+ const result = {
3209
+ detected: false,
3210
+ domains: [],
3211
+ riskScore: 0,
3212
+ details: []
3213
+ };
3214
+ try {
3215
+ const idnDetector = await this.getIDNDetector();
3216
+ if (!idnDetector) {
3217
+ return result;
3218
+ }
3219
+ const textContent = mail.text || "";
3220
+ const htmlContent = mail.html || "";
3221
+ const allContent = textContent + " " + htmlContent;
3222
+ const urls = this.getUrls(allContent);
3223
+ for (const url of urls) {
3224
+ try {
3225
+ const normalizedUrl = await this.optimizeUrlParsing(url);
3226
+ const parsed = new URL(normalizedUrl);
3227
+ const domain = parsed.hostname;
3228
+ if (!domain) {
3229
+ continue;
3230
+ }
3231
+ const context = {
3232
+ emailContent: allContent,
3233
+ displayText: url === normalizedUrl ? null : url,
3234
+ senderReputation: 0.5,
3235
+ // Default neutral reputation
3236
+ emailHeaders: mail.headers || {}
3237
+ };
3238
+ const analysis = idnDetector.detectHomographAttack(domain, context);
3239
+ if (analysis.riskScore > 0.3) {
3240
+ result.detected = true;
3241
+ result.domains.push({
3242
+ domain,
3243
+ originalUrl: url,
3244
+ normalizedUrl,
3245
+ riskScore: analysis.riskScore,
3246
+ riskFactors: analysis.riskFactors,
3247
+ recommendations: analysis.recommendations,
3248
+ confidence: analysis.confidence
3249
+ });
3250
+ result.riskScore = Math.max(result.riskScore, analysis.riskScore);
3251
+ }
3252
+ } catch (error) {
3253
+ debug7("IDN analysis error for URL:", url, error);
3254
+ }
3255
+ }
3256
+ if (result.detected) {
3257
+ result.details.push(
3258
+ `Found ${result.domains.length} suspicious domain(s)`,
3259
+ `Highest risk score: ${(result.riskScore * 100).toFixed(1)}%`
3260
+ );
3261
+ const allRiskFactors = /* @__PURE__ */ new Set();
3262
+ for (const domain of result.domains) {
3263
+ for (const factor of domain.riskFactors) {
3264
+ allRiskFactors.add(factor);
3265
+ }
3266
+ }
3267
+ result.details.push(...allRiskFactors);
3268
+ }
3269
+ } catch (error) {
3270
+ debug7("IDN homograph detection error:", error);
3271
+ }
3272
+ return result;
3273
+ }
3274
+ // Get IDN detector instance
3275
+ async getIDNDetector() {
3276
+ if (!this.idnDetector) {
3277
+ try {
3278
+ const { default: EnhancedIDNDetector2 } = await Promise.resolve().then(() => (init_enhanced_idn_detector(), enhanced_idn_detector_exports));
3279
+ this.idnDetector = new EnhancedIDNDetector2({
3280
+ strictMode: this.config.strictIDNDetection || false,
3281
+ enableWhitelist: true,
3282
+ enableBrandProtection: true,
3283
+ enableContextAnalysis: true
3284
+ });
3285
+ } catch (error) {
3286
+ debug7("Failed to load IDN detector:", error);
3287
+ return null;
3288
+ }
3289
+ }
3290
+ return this.idnDetector;
3291
+ }
3292
+ // Hybrid language detection using both lande and franc
3293
+ async detectLanguageHybrid(text) {
3294
+ if (!text || typeof text !== "string" || text.length < 3) {
3295
+ return "en";
3296
+ }
3297
+ const cleanText = text.trim();
3298
+ if (!cleanText || /^[\d\s\W]+$/.test(cleanText)) {
3299
+ return "en";
3300
+ }
3301
+ try {
3302
+ if (text.length < 50) {
3303
+ const landeResult = (0, import_lande.default)(text);
3304
+ if (landeResult && landeResult.length > 0) {
3305
+ const detected2 = landeResult[0][0];
3306
+ const normalized = this.normalizeLanguageCode(detected2);
3307
+ if (this.isValidShortTextDetection(text, normalized)) {
3308
+ if (this.config.supportedLanguages && this.config.supportedLanguages.length > 0) {
3309
+ if (this.config.supportedLanguages.includes(normalized)) {
3310
+ return normalized;
3311
+ }
3312
+ return this.config.supportedLanguages[0];
3313
+ }
3314
+ return normalized;
3315
+ }
3316
+ return "en";
3317
+ }
3318
+ return "en";
3319
+ }
3320
+ const { franc } = await import("franc");
3321
+ const francResult = franc(text);
3322
+ if (francResult === "und") {
3323
+ const landeResult = (0, import_lande.default)(text);
3324
+ if (landeResult && landeResult.length > 0) {
3325
+ return this.normalizeLanguageCode(landeResult[0][0]);
3326
+ }
3327
+ return "en";
3328
+ }
3329
+ const detected = this.normalizeLanguageCode(francResult);
3330
+ if (this.config.supportedLanguages && this.config.supportedLanguages.length > 0) {
3331
+ if (this.config.supportedLanguages.includes(detected)) {
3332
+ return detected;
3333
+ }
3334
+ return this.config.supportedLanguages[0];
3335
+ }
3336
+ return detected;
3337
+ } catch (error) {
3338
+ debug7("Language detection error:", error);
3339
+ try {
3340
+ const landeResult = (0, import_lande.default)(text);
3341
+ if (landeResult && landeResult.length > 0) {
3342
+ return this.normalizeLanguageCode(landeResult[0][0]);
3343
+ }
3344
+ return "en";
3345
+ } catch {
3346
+ return "en";
3347
+ }
3348
+ }
3349
+ }
3350
+ // Validate short text language detection
3351
+ isValidShortTextDetection(text, detectedLang) {
3352
+ const hasNonLatin = /[^\u0000-\u024F\u1E00-\u1EFF]/.test(text);
3353
+ if (hasNonLatin) {
3354
+ return true;
3355
+ }
3356
+ if (text.length < 7 && detectedLang !== "en") {
3357
+ return false;
3358
+ }
3359
+ return true;
3360
+ }
3361
+ // Get toxicity detection results
3362
+ async getToxicityResults(mail) {
3363
+ const results = [];
3364
+ try {
3365
+ if (!this.toxicityModel) {
3366
+ const _tf = await import("@tensorflow/tfjs-node");
3367
+ const toxicity = await import("@tensorflow-models/toxicity");
3368
+ const threshold = this.config.toxicityThreshold || 0.7;
3369
+ this.toxicityModel = await toxicity.load(threshold);
3370
+ }
3371
+ const textContent = mail.text || "";
3372
+ const htmlContent = (0, import_striptags.default)(mail.html || "");
3373
+ const subjectContent = mail.subject || "";
3374
+ const allContent = [subjectContent, textContent, htmlContent].filter((text) => text && text.trim().length > 0).join(" ").slice(0, 5e3);
3375
+ if (!allContent || allContent.trim().length < 10) {
3376
+ return results;
3377
+ }
3378
+ const predictions = await Promise.race([
3379
+ this.toxicityModel.classify([allContent]),
3380
+ new Promise((_resolve, reject) => {
3381
+ setTimeout(() => reject(new Error("Toxicity detection timeout")), this.config.timeout);
3382
+ })
3383
+ ]);
3384
+ for (const prediction of predictions) {
3385
+ const { label } = prediction;
3386
+ const matches = prediction.results[0].match;
3387
+ if (matches) {
3388
+ const { probabilities } = prediction.results[0];
3389
+ const toxicProbability = probabilities[1];
3390
+ results.push({
3391
+ type: "toxicity",
3392
+ category: label,
3393
+ probability: toxicProbability,
3394
+ description: `Toxic content detected: ${label} (${(toxicProbability * 100).toFixed(1)}%)`
3395
+ });
3396
+ }
3397
+ }
3398
+ } catch (error) {
3399
+ debug7("Toxicity detection error:", error);
3400
+ }
3401
+ return results;
3402
+ }
3403
+ // Get NSFW image detection results
3404
+ async getNSFWResults(mail) {
3405
+ const results = [];
3406
+ try {
3407
+ if (!this.nsfwModel) {
3408
+ const _tf = await import("@tensorflow/tfjs-node");
3409
+ const nsfw = await import("nsfwjs");
3410
+ this.nsfwModel = await nsfw.load();
3411
+ }
3412
+ const attachments = mail.attachments || [];
3413
+ for (const attachment of attachments) {
3414
+ try {
3415
+ if (!attachment.content || !(0, import_is_buffer.default)(attachment.content)) {
3416
+ continue;
3417
+ }
3418
+ const fileType = await (0, import_file_type.fileTypeFromBuffer)(attachment.content);
3419
+ if (!fileType || !fileType.mime.startsWith("image/")) {
3420
+ continue;
3421
+ }
3422
+ const sharp = (await import("sharp")).default;
3423
+ const processedImage = await sharp(attachment.content).resize(224, 224).raw().toBuffer();
3424
+ const _tf = await import("@tensorflow/tfjs-node");
3425
+ const imageTensor = _tf.tensor3d(
3426
+ new Uint8Array(processedImage),
3427
+ [224, 224, 3]
3428
+ );
3429
+ const predictions = await Promise.race([
3430
+ this.nsfwModel.classify(imageTensor),
3431
+ new Promise((_resolve, reject) => {
3432
+ setTimeout(() => reject(new Error("NSFW detection timeout")), this.config.timeout);
3433
+ })
3434
+ ]);
3435
+ imageTensor.dispose();
3436
+ const nsfwThreshold = this.config.nsfwThreshold || 0.6;
3437
+ for (const prediction of predictions) {
3438
+ if ((prediction.className === "Porn" || prediction.className === "Hentai" || prediction.className === "Sexy") && prediction.probability > nsfwThreshold) {
3439
+ results.push({
3440
+ type: "nsfw",
3441
+ filename: attachment.filename || "unknown",
3442
+ category: prediction.className,
3443
+ probability: prediction.probability,
3444
+ description: `NSFW image detected: ${prediction.className} (${(prediction.probability * 100).toFixed(1)}%)`
3445
+ });
3446
+ }
3447
+ }
3448
+ } catch (error) {
3449
+ debug7("NSFW detection error for attachment:", attachment.filename, error);
3450
+ }
3451
+ }
3452
+ } catch (error) {
3453
+ debug7("NSFW detection error:", error);
3454
+ }
3455
+ return results;
3456
+ }
3457
+ // Normalize language codes from 3-letter to 2-letter format
3458
+ normalizeLanguageCode(code) {
3459
+ if (!code || typeof code !== "string") {
3460
+ return "en";
3461
+ }
3462
+ if (code.length === 2) {
3463
+ return code.toLowerCase();
3464
+ }
3465
+ const codeMap = {
3466
+ // Common language mappings
3467
+ eng: "en",
3468
+ // English
3469
+ fra: "fr",
3470
+ // French
3471
+ fre: "fr",
3472
+ // French (alternative)
3473
+ spa: "es",
3474
+ // Spanish
3475
+ deu: "de",
3476
+ // German
3477
+ ger: "de",
3478
+ // German (alternative)
3479
+ ita: "it",
3480
+ // Italian
3481
+ por: "pt",
3482
+ // Portuguese
3483
+ rus: "ru",
3484
+ // Russian
3485
+ jpn: "ja",
3486
+ // Japanese
3487
+ kor: "ko",
3488
+ // Korean
3489
+ cmn: "zh",
3490
+ // Chinese (Mandarin)
3491
+ zho: "zh",
3492
+ // Chinese
3493
+ chi: "zh",
3494
+ // Chinese (alternative)
3495
+ ara: "ar",
3496
+ // Arabic
3497
+ hin: "hi",
3498
+ // Hindi
3499
+ ben: "bn",
3500
+ // Bengali
3501
+ urd: "ur",
3502
+ // Urdu
3503
+ tur: "tr",
3504
+ // Turkish
3505
+ pol: "pl",
3506
+ // Polish
3507
+ nld: "nl",
3508
+ // Dutch
3509
+ dut: "nl",
3510
+ // Dutch (alternative)
3511
+ swe: "sv",
3512
+ // Swedish
3513
+ nor: "no",
3514
+ // Norwegian
3515
+ dan: "da",
3516
+ // Danish
3517
+ fin: "fi",
3518
+ // Finnish
3519
+ hun: "hu",
3520
+ // Hungarian
3521
+ ces: "cs",
3522
+ // Czech
3523
+ cze: "cs",
3524
+ // Czech (alternative)
3525
+ slk: "sk",
3526
+ // Slovak
3527
+ slo: "sk",
3528
+ // Slovak (alternative)
3529
+ slv: "sl",
3530
+ // Slovenian
3531
+ hrv: "hr",
3532
+ // Croatian
3533
+ srp: "sr",
3534
+ // Serbian
3535
+ bul: "bg",
3536
+ // Bulgarian
3537
+ ron: "ro",
3538
+ // Romanian
3539
+ rum: "ro",
3540
+ // Romanian (alternative)
3541
+ ell: "el",
3542
+ // Greek
3543
+ gre: "el",
3544
+ // Greek (alternative)
3545
+ heb: "he",
3546
+ // Hebrew
3547
+ tha: "th",
3548
+ // Thai
3549
+ vie: "vi",
3550
+ // Vietnamese
3551
+ ind: "id",
3552
+ // Indonesian
3553
+ msa: "ms",
3554
+ // Malay
3555
+ may: "ms",
3556
+ // Malay (alternative)
3557
+ tgl: "tl",
3558
+ // Tagalog
3559
+ ukr: "uk",
3560
+ // Ukrainian
3561
+ bel: "be",
3562
+ // Belarusian
3563
+ lit: "lt",
3564
+ // Lithuanian
3565
+ lav: "lv",
3566
+ // Latvian
3567
+ est: "et",
3568
+ // Estonian
3569
+ cat: "ca",
3570
+ // Catalan
3571
+ eus: "eu",
3572
+ // Basque
3573
+ baq: "eu",
3574
+ // Basque (alternative)
3575
+ glg: "gl",
3576
+ // Galician
3577
+ gle: "ga",
3578
+ // Irish
3579
+ gla: "gd",
3580
+ // Scottish Gaelic
3581
+ cym: "cy",
3582
+ // Welsh
3583
+ wel: "cy",
3584
+ // Welsh (alternative)
3585
+ isl: "is",
3586
+ // Icelandic
3587
+ ice: "is",
3588
+ // Icelandic (alternative)
3589
+ mlt: "mt",
3590
+ // Maltese
3591
+ afr: "af",
3592
+ // Afrikaans
3593
+ swa: "sw",
3594
+ // Swahili
3595
+ amh: "am",
3596
+ // Amharic
3597
+ hau: "ha",
3598
+ // Hausa
3599
+ yor: "yo",
3600
+ // Yoruba
3601
+ ibo: "ig",
3602
+ // Igbo
3603
+ som: "so",
3604
+ // Somali
3605
+ orm: "om",
3606
+ // Oromo
3607
+ tig: "ti",
3608
+ // Tigrinya
3609
+ mlg: "mg",
3610
+ // Malagasy
3611
+ nya: "ny",
3612
+ // Chichewa
3613
+ sna: "sn",
3614
+ // Shona
3615
+ xho: "xh",
3616
+ // Xhosa
3617
+ zul: "zu",
3618
+ // Zulu
3619
+ nso: "nso",
3620
+ // Northern Sotho
3621
+ sot: "st",
3622
+ // Southern Sotho
3623
+ tsn: "tn",
3624
+ // Tswana
3625
+ ven: "ve",
3626
+ // Venda
3627
+ tso: "ts",
3628
+ // Tsonga
3629
+ ssw: "ss",
3630
+ // Swati
3631
+ nde: "nr",
3632
+ // Southern Ndebele
3633
+ nbl: "nd"
3634
+ // Northern Ndebele
3635
+ };
3636
+ const normalized = code.toLowerCase();
3637
+ return codeMap[normalized] || "en";
3638
+ }
3639
+ };
3640
+ var index_default = SpamScanner;
3641
+
3642
+ // src/cli.js
3643
+ var import_meta2 = {};
3644
+ var __filename2 = (0, import_node_url2.fileURLToPath)(import_meta2.url);
3645
+ var __dirname2 = import_node_path2.default.dirname(__filename2);
3646
+ var SUPPORTED_LANGUAGES = {
3647
+ en: "English",
3648
+ fr: "French",
3649
+ es: "Spanish",
3650
+ de: "German",
3651
+ it: "Italian",
3652
+ pt: "Portuguese",
3653
+ ru: "Russian",
3654
+ ja: "Japanese",
3655
+ ko: "Korean",
3656
+ zh: "Chinese",
3657
+ ar: "Arabic",
3658
+ hi: "Hindi",
3659
+ bn: "Bengali",
3660
+ ur: "Urdu",
3661
+ tr: "Turkish",
3662
+ pl: "Polish",
3663
+ nl: "Dutch",
3664
+ sv: "Swedish",
3665
+ no: "Norwegian",
3666
+ da: "Danish",
3667
+ fi: "Finnish",
3668
+ hu: "Hungarian",
3669
+ cs: "Czech",
3670
+ sk: "Slovak",
3671
+ sl: "Slovenian",
3672
+ hr: "Croatian",
3673
+ sr: "Serbian",
3674
+ bg: "Bulgarian",
3675
+ ro: "Romanian",
3676
+ el: "Greek",
3677
+ he: "Hebrew",
3678
+ th: "Thai",
3679
+ vi: "Vietnamese",
3680
+ id: "Indonesian",
3681
+ ms: "Malay",
3682
+ tl: "Tagalog",
3683
+ uk: "Ukrainian",
3684
+ be: "Belarusian",
3685
+ lt: "Lithuanian",
3686
+ lv: "Latvian",
3687
+ et: "Estonian",
3688
+ ca: "Catalan",
3689
+ eu: "Basque",
3690
+ gl: "Galician",
3691
+ ga: "Irish",
3692
+ gd: "Scottish Gaelic",
3693
+ cy: "Welsh",
3694
+ is: "Icelandic",
3695
+ mt: "Maltese",
3696
+ af: "Afrikaans",
3697
+ sw: "Swahili",
3698
+ am: "Amharic",
3699
+ ha: "Hausa",
3700
+ yo: "Yoruba",
3701
+ ig: "Igbo",
3702
+ so: "Somali",
3703
+ om: "Oromo",
3704
+ ti: "Tigrinya",
3705
+ mg: "Malagasy",
3706
+ ny: "Chichewa",
3707
+ sn: "Shona",
3708
+ xh: "Xhosa",
3709
+ zu: "Zulu",
3710
+ st: "Southern Sotho",
3711
+ tn: "Tswana"
3712
+ };
3713
+ var DEFAULT_SCORES = {
3714
+ classifier: 5,
3715
+ // Base score when classifier says spam
3716
+ phishing: 5,
3717
+ // Per phishing issue detected
3718
+ executable: 10,
3719
+ // Per dangerous executable detected
3720
+ macro: 5,
3721
+ // Per macro detected
3722
+ virus: 100,
3723
+ // Per virus detected
3724
+ nsfw: 3,
3725
+ // Per NSFW content detected
3726
+ toxicity: 3
3727
+ // Per toxic content detected
3728
+ };
3729
+ var UPDATE_CACHE_DIR = import_node_path2.default.join((0, import_node_os.homedir)(), ".spamscanner");
3730
+ var UPDATE_CACHE_FILE = import_node_path2.default.join(UPDATE_CACHE_DIR, "update-check.json");
3731
+ var UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1e3;
3732
+ function getVersion() {
3733
+ const possiblePaths = [
3734
+ import_node_path2.default.join(__dirname2, "..", "package.json"),
3735
+ import_node_path2.default.join(__dirname2, "..", "..", "package.json"),
3736
+ import_node_path2.default.join(__dirname2, "..", "..", "..", "package.json")
3737
+ ];
3738
+ for (const pkgPath of possiblePaths) {
3739
+ try {
3740
+ const content = (0, import_node_fs4.readFileSync)(pkgPath, "utf8");
3741
+ const pkg = JSON.parse(content);
3742
+ if (pkg.name === "spamscanner" && pkg.version) {
3743
+ return pkg.version;
3744
+ }
3745
+ } catch {
3746
+ }
3747
+ }
3748
+ return "unknown";
3749
+ }
3750
+ var VERSION = getVersion();
3751
+ function compareVersions(v1, v2) {
3752
+ const parts1 = v1.replace(/^v/, "").split(".").map(Number);
3753
+ const parts2 = v2.replace(/^v/, "").split(".").map(Number);
3754
+ for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
3755
+ const p1 = parts1[i] || 0;
3756
+ const p2 = parts2[i] || 0;
3757
+ if (p1 < p2) {
3758
+ return -1;
3759
+ }
3760
+ if (p1 > p2) {
3761
+ return 1;
3762
+ }
3763
+ }
3764
+ return 0;
3765
+ }
3766
+ function getBinaryName() {
3767
+ const { platform } = import_node_process2.default;
3768
+ const { arch } = import_node_process2.default;
3769
+ if (platform === "win32") {
3770
+ return "spamscanner-win-x64.exe";
3771
+ }
3772
+ if (platform === "darwin") {
3773
+ return arch === "arm64" ? "spamscanner-darwin-arm64" : "spamscanner-darwin-x64";
3774
+ }
3775
+ return "spamscanner-linux-x64";
3776
+ }
3777
+ async function checkForUpdates(force = false) {
3778
+ try {
3779
+ if (!force && (0, import_node_fs4.existsSync)(UPDATE_CACHE_FILE)) {
3780
+ const cache2 = JSON.parse((0, import_node_fs4.readFileSync)(UPDATE_CACHE_FILE, "utf8"));
3781
+ const age = Date.now() - cache2.timestamp;
3782
+ if (age < UPDATE_CHECK_INTERVAL) {
3783
+ if (cache2.latestVersion && compareVersions(cache2.latestVersion, VERSION) > 0) {
3784
+ return {
3785
+ currentVersion: VERSION,
3786
+ latestVersion: cache2.latestVersion,
3787
+ releaseUrl: cache2.releaseUrl,
3788
+ downloadUrl: cache2.downloadUrl,
3789
+ cached: true
3790
+ };
3791
+ }
3792
+ return null;
3793
+ }
3794
+ }
3795
+ const response = await fetch("https://api.github.com/repos/spamscanner/spamscanner/releases/latest", {
3796
+ headers: {
3797
+ Accept: "application/vnd.github.v3+json",
3798
+ "User-Agent": `spamscanner-cli/${VERSION}`
3799
+ }
3800
+ });
3801
+ if (!response.ok) {
3802
+ return null;
3803
+ }
3804
+ const release = await response.json();
3805
+ const latestVersion = release.tag_name.replace(/^v/, "");
3806
+ const binaryName = getBinaryName();
3807
+ const asset = release.assets.find((a) => a.name === binaryName);
3808
+ const downloadUrl = asset?.browser_download_url;
3809
+ const cacheData = {
3810
+ timestamp: Date.now(),
3811
+ latestVersion,
3812
+ releaseUrl: release.html_url,
3813
+ downloadUrl
3814
+ };
3815
+ try {
3816
+ if (!(0, import_node_fs4.existsSync)(UPDATE_CACHE_DIR)) {
3817
+ (0, import_node_fs4.mkdirSync)(UPDATE_CACHE_DIR, { recursive: true });
3818
+ }
3819
+ (0, import_node_fs4.writeFileSync)(UPDATE_CACHE_FILE, JSON.stringify(cacheData, null, 2));
3820
+ } catch {
3821
+ }
3822
+ if (compareVersions(latestVersion, VERSION) > 0) {
3823
+ return {
3824
+ currentVersion: VERSION,
3825
+ latestVersion,
3826
+ releaseUrl: release.html_url,
3827
+ downloadUrl,
3828
+ cached: false
3829
+ };
3830
+ }
3831
+ return null;
3832
+ } catch {
3833
+ return null;
3834
+ }
3835
+ }
3836
+ async function printUpdateNotification(force = false) {
3837
+ const update = await checkForUpdates(force);
3838
+ if (update) {
3839
+ const { platform } = import_node_process2.default;
3840
+ console.error("");
3841
+ console.error("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E");
3842
+ console.error(`\u2502 Update available: ${update.currentVersion} \u2192 ${update.latestVersion.padEnd(37)}\u2502`);
3843
+ console.error("\u2502 \u2502");
3844
+ if (update.downloadUrl) {
3845
+ console.error("\u2502 To update, run one of: \u2502");
3846
+ if (platform === "darwin") {
3847
+ console.error("\u2502 curl -fsSL https://spamscanner.net/install.sh | sh \u2502");
3848
+ } else if (platform === "win32") {
3849
+ console.error("\u2502 irm https://spamscanner.net/install.ps1 | iex \u2502");
3850
+ } else {
3851
+ console.error("\u2502 curl -fsSL https://spamscanner.net/install.sh | sh \u2502");
3852
+ }
3853
+ console.error("\u2502 \u2502");
3854
+ console.error("\u2502 Or download manually from: \u2502");
3855
+ } else {
3856
+ console.error("\u2502 Download from: \u2502");
3857
+ }
3858
+ console.error("\u2502 https://github.com/spamscanner/spamscanner/releases \u2502");
3859
+ console.error("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F");
3860
+ console.error("");
3861
+ }
3862
+ }
3863
+ function formatLanguageList() {
3864
+ const entries = Object.entries(SUPPORTED_LANGUAGES);
3865
+ const lines = [];
3866
+ for (let i = 0; i < entries.length; i += 4) {
3867
+ const chunk = entries.slice(i, i + 4);
3868
+ const formatted = chunk.map(([code, name]) => `${code} (${name})`).join(", ");
3869
+ lines.push(` ${formatted}`);
3870
+ }
3871
+ return lines.join("\n");
3872
+ }
3873
+ var HELP_TEXT = `
3874
+ SpamScanner CLI v${VERSION}
3875
+
3876
+ Usage:
3877
+ spamscanner <command> [options]
3878
+
3879
+ Commands:
3880
+ scan <file> Scan an email file for spam
3881
+ scan - Scan email from stdin
3882
+ server Start TCP server mode
3883
+ update Check for updates
3884
+ help Show this help message
3885
+ version Show version number
3886
+
3887
+ General Options:
3888
+ -h, --help Show help
3889
+ -v, --version Show version
3890
+ -j, --json Output results as JSON
3891
+ --verbose Show detailed output
3892
+ --debug Enable debug mode
3893
+ --timeout <ms> Scan timeout in milliseconds (default: 30000)
3894
+ --no-update-check Disable automatic update check
3895
+
3896
+ Spam Detection Options:
3897
+ --threshold <score> Spam score threshold (default: 5.0)
3898
+ --check-classifier Include Bayesian classifier in scoring (default: true)
3899
+ --check-phishing Include phishing detection in scoring (default: true)
3900
+ --check-executables Include executable detection in scoring (default: true)
3901
+ --check-macros Include macro detection in scoring (default: true)
3902
+ --check-virus Include virus detection in scoring (default: true)
3903
+ --check-nsfw Include NSFW detection in scoring (default: false)
3904
+ --check-toxicity Include toxicity detection in scoring (default: false)
3905
+ --no-classifier Disable Bayesian classifier scoring
3906
+ --no-phishing Disable phishing scoring
3907
+ --no-executables Disable executable scoring
3908
+ --no-macros Disable macro scoring
3909
+ --no-virus Disable virus scoring
3910
+
3911
+ Score Weights (customize scoring):
3912
+ --score-classifier <n> Classifier spam score weight (default: 5.0)
3913
+ --score-phishing <n> Phishing score per issue (default: 5.0)
3914
+ --score-executable <n> Executable score per file (default: 10.0)
3915
+ --score-macro <n> Macro score per detection (default: 5.0)
3916
+ --score-virus <n> Virus score per detection (default: 100.0)
3917
+ --score-nsfw <n> NSFW score per detection (default: 3.0)
3918
+ --score-toxicity <n> Toxicity score per detection (default: 3.0)
3919
+
3920
+ Scanner Configuration Options:
3921
+ --languages <list> Comma-separated list of supported language codes (default: all)
3922
+ Use empty string or 'all' for all languages
3923
+ --mixed-language Enable mixed language detection in emails
3924
+ --no-macro-detection Disable macro detection in attachments
3925
+ --no-pattern-recognition Disable advanced pattern recognition
3926
+ --strict-idn Enable strict IDN/homograph detection
3927
+ --nsfw-threshold <n> NSFW detection threshold 0.0-1.0 (default: 0.6)
3928
+ --toxicity-threshold <n> Toxicity detection threshold 0.0-1.0 (default: 0.7)
3929
+ --clamscan-path <path> Path to clamscan binary (default: /usr/bin/clamscan)
3930
+ --clamdscan-path <path> Path to clamdscan binary (default: /usr/bin/clamdscan)
3931
+
3932
+ Authentication Options (mailauth):
3933
+ --enable-auth Enable DKIM/SPF/ARC/DMARC/BIMI authentication
3934
+ --sender-ip <ip> Remote IP address of the sender (required for auth)
3935
+ --sender-hostname <host> Resolved hostname of the sender (from reverse DNS)
3936
+ --helo <hostname> HELO/EHLO hostname
3937
+ --sender <email> Envelope sender (MAIL FROM)
3938
+ --mta <hostname> MTA hostname for auth headers (default: spamscanner)
3939
+ --auth-timeout <ms> DNS lookup timeout for auth (default: 10000)
3940
+
3941
+ Reputation Options (Forward Email API):
3942
+ --enable-reputation Enable Forward Email reputation checking
3943
+ --reputation-url <url> Custom reputation API URL
3944
+ --reputation-timeout <ms> Reputation API timeout (default: 10000)
3945
+ --only-aligned Only check aligned/authenticated attributes for reputation (default: true)
3946
+ --no-only-aligned Check all attributes regardless of alignment
3947
+
3948
+ Header Options:
3949
+ --add-headers Add X-Spam-* headers to output (for mail server integration)
3950
+ --add-auth-headers Add Authentication-Results header to output
3951
+ --prepend-subject Prepend [SPAM] to subject if spam detected
3952
+ --subject-tag <tag> Custom subject tag (default: [SPAM])
3953
+
3954
+ Server Options:
3955
+ --port <port> TCP server port (default: 7830)
3956
+ --host <host> TCP server host (default: 127.0.0.1)
3957
+
3958
+ Supported Languages (use ISO 639-1 codes with --languages):
3959
+ ${formatLanguageList()}
3960
+
3961
+ Examples:
3962
+ # Scan a file
3963
+ spamscanner scan email.eml
3964
+
3965
+ # Scan from stdin (for Postfix integration)
3966
+ cat email.eml | spamscanner scan -
3967
+
3968
+ # Scan with JSON output
3969
+ spamscanner scan email.eml --json
3970
+
3971
+ # Scan with custom threshold
3972
+ spamscanner scan email.eml --threshold 3.0
3973
+
3974
+ # Scan with only classifier and phishing checks
3975
+ spamscanner scan email.eml --no-executables --no-macros --no-virus
3976
+
3977
+ # Scan and add spam headers (for mail server integration)
3978
+ spamscanner scan email.eml --add-headers --prepend-subject
3979
+
3980
+ # Scan with specific language support
3981
+ spamscanner scan email.eml --languages en,es,fr
3982
+
3983
+ # Scan with mixed language detection
3984
+ spamscanner scan email.eml --mixed-language
3985
+
3986
+ # Start TCP server
3987
+ spamscanner server --port 7830
3988
+
3989
+ # Scan with authentication (DKIM/SPF/DMARC)
3990
+ spamscanner scan email.eml --enable-auth --sender-ip 192.168.1.1 --sender user@example.com
3991
+
3992
+ # Scan with reputation checking
3993
+ spamscanner scan email.eml --enable-reputation
3994
+
3995
+ # Full mail server integration
3996
+ spamscanner scan email.eml --enable-auth --enable-reputation --sender-ip 192.168.1.1 --add-headers --add-auth-headers
3997
+
3998
+ # Check for updates
3999
+ spamscanner update
4000
+
4001
+ Exit Codes:
4002
+ 0 - Clean (not spam)
4003
+ 1 - Spam detected
4004
+ 2 - Error occurred
4005
+
4006
+ X-Spam Headers (when --add-headers is used):
4007
+ X-Spam-Status: Yes/No, score=X.X required=Y.Y tests=TEST1,TEST2,...
4008
+ X-Spam-Score: X.X
4009
+ X-Spam-Flag: YES/NO
4010
+ X-Spam-Tests: Comma-separated list of triggered tests
4011
+ `;
4012
+ function parseArgs(args) {
4013
+ const result = {
4014
+ command: null,
4015
+ file: null,
4016
+ json: false,
4017
+ verbose: false,
4018
+ debug: false,
4019
+ port: 7830,
4020
+ host: "127.0.0.1",
4021
+ timeout: 3e4,
4022
+ help: false,
4023
+ version: false,
4024
+ noUpdateCheck: false,
4025
+ // Spam detection options
4026
+ threshold: 5,
4027
+ checkClassifier: true,
4028
+ checkPhishing: true,
4029
+ checkExecutables: true,
4030
+ checkMacros: true,
4031
+ checkVirus: true,
4032
+ checkNsfw: false,
4033
+ checkToxicity: false,
4034
+ // Score weights
4035
+ scores: { ...DEFAULT_SCORES },
4036
+ // Header options
4037
+ addHeaders: false,
4038
+ prependSubject: false,
4039
+ subjectTag: "[SPAM]",
4040
+ // Scanner configuration options
4041
+ supportedLanguages: [],
4042
+ // Empty = all languages
4043
+ enableMixedLanguageDetection: false,
4044
+ enableMacroDetection: true,
4045
+ enableAdvancedPatternRecognition: true,
4046
+ strictIdnDetection: false,
4047
+ nsfwThreshold: 0.6,
4048
+ toxicityThreshold: 0.7,
4049
+ clamscanPath: "/usr/bin/clamscan",
4050
+ clamdscanPath: "/usr/bin/clamdscan",
4051
+ // Authentication options
4052
+ enableAuth: false,
4053
+ senderIp: null,
4054
+ senderHostname: null,
4055
+ helo: null,
4056
+ sender: null,
4057
+ mta: "spamscanner",
4058
+ authTimeout: 1e4,
4059
+ // Reputation options
4060
+ enableReputation: false,
4061
+ reputationUrl: "https://api.forwardemail.net/v1/reputation",
4062
+ reputationTimeout: 1e4,
4063
+ onlyAligned: true,
4064
+ // Additional header options
4065
+ addAuthHeaders: false
4066
+ };
4067
+ for (let index = 0; index < args.length; index++) {
4068
+ const arg = args[index];
4069
+ switch (arg) {
4070
+ case "scan":
4071
+ case "server":
4072
+ case "help":
4073
+ case "version":
4074
+ case "update": {
4075
+ result.command = arg;
4076
+ break;
4077
+ }
4078
+ case "-h":
4079
+ case "--help": {
4080
+ result.help = true;
4081
+ break;
4082
+ }
4083
+ case "-v":
4084
+ case "--version": {
4085
+ result.version = true;
4086
+ break;
4087
+ }
4088
+ case "-j":
4089
+ case "--json": {
4090
+ result.json = true;
4091
+ break;
4092
+ }
4093
+ case "--verbose": {
4094
+ result.verbose = true;
4095
+ break;
4096
+ }
4097
+ case "--debug": {
4098
+ result.debug = true;
4099
+ break;
4100
+ }
4101
+ case "--no-update-check": {
4102
+ result.noUpdateCheck = true;
4103
+ break;
4104
+ }
4105
+ case "--port": {
4106
+ result.port = Number.parseInt(args[++index], 10);
4107
+ break;
4108
+ }
4109
+ case "--host": {
4110
+ result.host = args[++index];
4111
+ break;
4112
+ }
4113
+ case "--timeout": {
4114
+ result.timeout = Number.parseInt(args[++index], 10);
4115
+ break;
4116
+ }
4117
+ // Spam detection options
4118
+ case "--threshold": {
4119
+ result.threshold = Number.parseFloat(args[++index]);
4120
+ break;
4121
+ }
4122
+ case "--check-classifier": {
4123
+ result.checkClassifier = true;
4124
+ break;
4125
+ }
4126
+ case "--check-phishing": {
4127
+ result.checkPhishing = true;
4128
+ break;
4129
+ }
4130
+ case "--check-executables": {
4131
+ result.checkExecutables = true;
4132
+ break;
4133
+ }
4134
+ case "--check-macros": {
4135
+ result.checkMacros = true;
4136
+ break;
4137
+ }
4138
+ case "--check-virus": {
4139
+ result.checkVirus = true;
4140
+ break;
4141
+ }
4142
+ case "--check-nsfw": {
4143
+ result.checkNsfw = true;
4144
+ break;
4145
+ }
4146
+ case "--check-toxicity": {
4147
+ result.checkToxicity = true;
4148
+ break;
4149
+ }
4150
+ case "--no-classifier": {
4151
+ result.checkClassifier = false;
4152
+ break;
4153
+ }
4154
+ case "--no-phishing": {
4155
+ result.checkPhishing = false;
4156
+ break;
4157
+ }
4158
+ case "--no-executables": {
4159
+ result.checkExecutables = false;
4160
+ break;
4161
+ }
4162
+ case "--no-macros": {
4163
+ result.checkMacros = false;
4164
+ break;
4165
+ }
4166
+ case "--no-virus": {
4167
+ result.checkVirus = false;
4168
+ break;
4169
+ }
4170
+ // Score weights
4171
+ case "--score-classifier": {
4172
+ result.scores.classifier = Number.parseFloat(args[++index]);
4173
+ break;
4174
+ }
4175
+ case "--score-phishing": {
4176
+ result.scores.phishing = Number.parseFloat(args[++index]);
4177
+ break;
4178
+ }
4179
+ case "--score-executable": {
4180
+ result.scores.executable = Number.parseFloat(args[++index]);
4181
+ break;
4182
+ }
4183
+ case "--score-macro": {
4184
+ result.scores.macro = Number.parseFloat(args[++index]);
4185
+ break;
4186
+ }
4187
+ case "--score-virus": {
4188
+ result.scores.virus = Number.parseFloat(args[++index]);
4189
+ break;
4190
+ }
4191
+ case "--score-nsfw": {
4192
+ result.scores.nsfw = Number.parseFloat(args[++index]);
4193
+ break;
4194
+ }
4195
+ case "--score-toxicity": {
4196
+ result.scores.toxicity = Number.parseFloat(args[++index]);
4197
+ break;
4198
+ }
4199
+ // Header options
4200
+ case "--add-headers": {
4201
+ result.addHeaders = true;
4202
+ break;
4203
+ }
4204
+ case "--prepend-subject": {
4205
+ result.prependSubject = true;
4206
+ break;
4207
+ }
4208
+ case "--subject-tag": {
4209
+ result.subjectTag = args[++index];
4210
+ break;
4211
+ }
4212
+ // Scanner configuration options
4213
+ case "--languages": {
4214
+ const langArg = args[++index];
4215
+ result.supportedLanguages = langArg && langArg !== "all" && langArg !== "" ? langArg.split(",").map((l) => l.trim().toLowerCase()) : [];
4216
+ break;
4217
+ }
4218
+ case "--mixed-language": {
4219
+ result.enableMixedLanguageDetection = true;
4220
+ break;
4221
+ }
4222
+ case "--no-macro-detection": {
4223
+ result.enableMacroDetection = false;
4224
+ break;
4225
+ }
4226
+ case "--no-pattern-recognition": {
4227
+ result.enableAdvancedPatternRecognition = false;
4228
+ break;
4229
+ }
4230
+ case "--strict-idn": {
4231
+ result.strictIdnDetection = true;
4232
+ break;
4233
+ }
4234
+ case "--nsfw-threshold": {
4235
+ result.nsfwThreshold = Number.parseFloat(args[++index]);
4236
+ break;
4237
+ }
4238
+ case "--toxicity-threshold": {
4239
+ result.toxicityThreshold = Number.parseFloat(args[++index]);
4240
+ break;
4241
+ }
4242
+ case "--clamscan-path": {
4243
+ result.clamscanPath = args[++index];
4244
+ break;
4245
+ }
4246
+ case "--clamdscan-path": {
4247
+ result.clamdscanPath = args[++index];
4248
+ break;
4249
+ }
4250
+ // Authentication options
4251
+ case "--enable-auth": {
4252
+ result.enableAuth = true;
4253
+ break;
4254
+ }
4255
+ case "--sender-ip": {
4256
+ result.senderIp = args[++index];
4257
+ break;
4258
+ }
4259
+ case "--sender-hostname": {
4260
+ result.senderHostname = args[++index];
4261
+ break;
4262
+ }
4263
+ case "--helo": {
4264
+ result.helo = args[++index];
4265
+ break;
4266
+ }
4267
+ case "--sender": {
4268
+ result.sender = args[++index];
4269
+ break;
4270
+ }
4271
+ case "--mta": {
4272
+ result.mta = args[++index];
4273
+ break;
4274
+ }
4275
+ case "--auth-timeout": {
4276
+ result.authTimeout = Number.parseInt(args[++index], 10);
4277
+ break;
4278
+ }
4279
+ // Reputation options
4280
+ case "--enable-reputation": {
4281
+ result.enableReputation = true;
4282
+ break;
4283
+ }
4284
+ case "--reputation-url": {
4285
+ result.reputationUrl = args[++index];
4286
+ break;
4287
+ }
4288
+ case "--reputation-timeout": {
4289
+ result.reputationTimeout = Number.parseInt(args[++index], 10);
4290
+ break;
4291
+ }
4292
+ case "--only-aligned": {
4293
+ result.onlyAligned = true;
4294
+ break;
4295
+ }
4296
+ case "--no-only-aligned": {
4297
+ result.onlyAligned = false;
4298
+ break;
4299
+ }
4300
+ // Additional header options
4301
+ case "--add-auth-headers": {
4302
+ result.addAuthHeaders = true;
4303
+ break;
4304
+ }
4305
+ default: {
4306
+ if (!result.file && result.command === "scan" && (arg === "-" || !arg.startsWith("-"))) {
4307
+ result.file = arg;
4308
+ }
4309
+ }
4310
+ }
4311
+ }
4312
+ return result;
4313
+ }
4314
+ async function readEmail(file) {
4315
+ if (file === "-") {
4316
+ const chunks2 = [];
4317
+ for await (const chunk of import_node_process2.default.stdin) {
4318
+ chunks2.push(chunk);
4319
+ }
4320
+ return import_node_buffer3.Buffer.concat(chunks2);
4321
+ }
4322
+ const chunks = [];
4323
+ const stream = (0, import_node_fs4.createReadStream)(file);
4324
+ for await (const chunk of stream) {
4325
+ chunks.push(chunk);
4326
+ }
4327
+ return import_node_buffer3.Buffer.concat(chunks);
4328
+ }
4329
+ function calculateScore(result, options) {
4330
+ const { scores } = options;
4331
+ const tests = [];
4332
+ let totalScore = 0;
4333
+ if (options.checkClassifier && result.results?.classification) {
4334
+ const { category, probability } = result.results.classification;
4335
+ if (category === "spam") {
4336
+ const scaledScore = scores.classifier * Math.max(0, (probability - 0.5) * 2);
4337
+ totalScore += scaledScore;
4338
+ tests.push(`BAYES_SPAM(${scaledScore.toFixed(1)})`);
4339
+ } else if (category === "ham" && probability > 0.8) {
4340
+ const hamBonus = -1 * (probability - 0.8) * 5;
4341
+ totalScore += hamBonus;
4342
+ tests.push(`BAYES_HAM(${hamBonus.toFixed(1)})`);
4343
+ }
4344
+ }
4345
+ if (options.checkPhishing && result.results?.phishing?.length > 0) {
4346
+ const phishingScore = result.results.phishing.length * scores.phishing;
4347
+ totalScore += phishingScore;
4348
+ tests.push(`PHISHING_DETECTED(${phishingScore.toFixed(1)})`);
4349
+ }
4350
+ if (options.checkExecutables && result.results?.executables?.length > 0) {
4351
+ const execScore = result.results.executables.length * scores.executable;
4352
+ totalScore += execScore;
4353
+ tests.push(`EXECUTABLE_ATTACHMENT(${execScore.toFixed(1)})`);
4354
+ }
4355
+ if (options.checkMacros && result.results?.macros?.length > 0) {
4356
+ const macroScore = result.results.macros.length * scores.macro;
4357
+ totalScore += macroScore;
4358
+ tests.push(`MACRO_DETECTED(${macroScore.toFixed(1)})`);
4359
+ }
4360
+ if (options.checkVirus && result.results?.viruses?.length > 0) {
4361
+ const virusScore = result.results.viruses.length * scores.virus;
4362
+ totalScore += virusScore;
4363
+ tests.push(`VIRUS_DETECTED(${virusScore.toFixed(1)})`);
4364
+ }
4365
+ if (options.checkNsfw && result.results?.nsfw?.length > 0) {
4366
+ const nsfwScore = result.results.nsfw.length * scores.nsfw;
4367
+ totalScore += nsfwScore;
4368
+ tests.push(`NSFW_CONTENT(${nsfwScore.toFixed(1)})`);
4369
+ }
4370
+ if (options.checkToxicity && result.results?.toxicity?.length > 0) {
4371
+ const toxicScore = result.results.toxicity.length * scores.toxicity;
4372
+ totalScore += toxicScore;
4373
+ tests.push(`TOXIC_CONTENT(${toxicScore.toFixed(1)})`);
4374
+ }
4375
+ if (result.results?.authentication?.score) {
4376
+ const authScore = result.results.authentication.score;
4377
+ totalScore += authScore.score;
4378
+ tests.push(...authScore.tests);
4379
+ }
4380
+ if (result.results?.reputation) {
4381
+ const rep = result.results.reputation;
4382
+ if (rep.isDenylisted) {
4383
+ totalScore += 10;
4384
+ tests.push("DENYLISTED(10.0)");
4385
+ }
4386
+ if (rep.isTruthSource) {
4387
+ totalScore -= 5;
4388
+ tests.push("TRUTH_SOURCE(-5.0)");
4389
+ } else if (rep.isAllowlisted) {
4390
+ totalScore -= 3;
4391
+ tests.push("ALLOWLISTED(-3.0)");
4392
+ }
4393
+ }
4394
+ let isSpam = totalScore >= options.threshold;
4395
+ if (result.results?.reputation) {
4396
+ const rep = result.results.reputation;
4397
+ if (rep.isDenylisted) {
4398
+ isSpam = true;
4399
+ } else if ((rep.isTruthSource || rep.isAllowlisted) && !result.results?.viruses?.length && !result.results?.executables?.length) {
4400
+ isSpam = false;
4401
+ }
4402
+ }
4403
+ return {
4404
+ score: totalScore,
4405
+ threshold: options.threshold,
4406
+ isSpam,
4407
+ tests
4408
+ };
4409
+ }
4410
+ function generateSpamHeaders(scoreDetails) {
4411
+ const { score, threshold, isSpam, tests } = scoreDetails;
4412
+ const status = isSpam ? "Yes" : "No";
4413
+ const flag = isSpam ? "YES" : "NO";
4414
+ return {
4415
+ "X-Spam-Status": `${status}, score=${score.toFixed(1)} required=${threshold.toFixed(1)} tests=${tests.join(",")} version=${VERSION}`,
4416
+ "X-Spam-Score": score.toFixed(1),
4417
+ "X-Spam-Flag": flag,
4418
+ "X-Spam-Tests": tests.join(", ")
4419
+ };
4420
+ }
4421
+ function modifyEmail(emailContent, options, scoreDetails, authResultsHeader = null) {
4422
+ const emailString = emailContent.toString("utf8");
4423
+ const headers = generateSpamHeaders(scoreDetails);
4424
+ if (options.addAuthHeaders && authResultsHeader) {
4425
+ headers["Authentication-Results"] = authResultsHeader;
4426
+ }
4427
+ const headerEndMatch = emailString.match(/\r?\n\r?\n/);
4428
+ if (!headerEndMatch) {
4429
+ return emailString + "\r\n" + Object.entries(headers).map(([key, value]) => `${key}: ${value}`).join("\r\n");
4430
+ }
4431
+ const headerEndIndex = headerEndMatch.index;
4432
+ const lineEnding = headerEndMatch[0].startsWith("\r\n") ? "\r\n" : "\n";
4433
+ const headerPart = emailString.slice(0, headerEndIndex);
4434
+ const bodyPart = emailString.slice(headerEndIndex);
4435
+ let newHeaders = headerPart;
4436
+ if (options.addHeaders) {
4437
+ const headerLines = Object.entries(headers).map(([key, value]) => `${key}: ${value}`).join(lineEnding);
4438
+ newHeaders = headerPart + lineEnding + headerLines;
4439
+ }
4440
+ if (options.prependSubject && scoreDetails.isSpam) {
4441
+ const subjectMatch = newHeaders.match(/^(subject:\s*)(.*)$/im);
4442
+ if (subjectMatch) {
4443
+ const [fullMatch, prefix, subject] = subjectMatch;
4444
+ if (!subject.startsWith(options.subjectTag)) {
4445
+ const newSubject = `${prefix}${options.subjectTag} ${subject}`;
4446
+ newHeaders = newHeaders.replace(fullMatch, newSubject);
4447
+ }
4448
+ }
4449
+ }
4450
+ return newHeaders + bodyPart;
4451
+ }
4452
+ function formatResult(result, scoreDetails, verbose) {
4453
+ const lines = [];
4454
+ const { score, threshold, isSpam, tests } = scoreDetails;
4455
+ if (isSpam) {
4456
+ lines.push(`SPAM DETECTED (score: ${score.toFixed(1)}, threshold: ${threshold.toFixed(1)})`);
4457
+ } else {
4458
+ lines.push(`Clean (score: ${score.toFixed(1)}, threshold: ${threshold.toFixed(1)})`);
4459
+ }
4460
+ if (tests.length > 0) {
4461
+ lines.push(`Tests: ${tests.join(", ")}`);
4462
+ }
4463
+ if (verbose) {
4464
+ lines.push("", "Details:");
4465
+ if (result.results?.classification) {
4466
+ const prob = (result.results.classification.probability * 100).toFixed(1);
4467
+ lines.push(` Classification: ${result.results.classification.category} (${prob}%)`);
4468
+ }
4469
+ if (result.results?.phishing?.length > 0) {
4470
+ lines.push(` Phishing: ${result.results.phishing.length} issue(s) detected`);
4471
+ for (const issue of result.results.phishing) {
4472
+ lines.push(` - ${issue.type}: ${issue.description || issue.message || "N/A"}`);
4473
+ }
4474
+ }
4475
+ if (result.results?.executables?.length > 0) {
4476
+ lines.push(` Executables: ${result.results.executables.length} dangerous file(s) detected`);
4477
+ for (const exec of result.results.executables) {
4478
+ lines.push(` - ${exec.filename || exec.extension || "Unknown"}`);
4479
+ }
4480
+ }
4481
+ if (result.results?.viruses?.length > 0) {
4482
+ lines.push(` Viruses: ${result.results.viruses.length} virus(es) detected`);
4483
+ for (const virus of result.results.viruses) {
4484
+ lines.push(` - ${virus.name || virus.message || "Unknown"}`);
4485
+ }
4486
+ }
4487
+ if (result.results?.macros?.length > 0) {
4488
+ lines.push(` Macros: ${result.results.macros.length} macro(s) detected`);
4489
+ }
4490
+ if (result.results?.toxicity?.length > 0) {
4491
+ lines.push(` Toxicity: ${result.results.toxicity.length} toxic content detected`);
4492
+ }
4493
+ if (result.results?.nsfw?.length > 0) {
4494
+ lines.push(` NSFW: ${result.results.nsfw.length} NSFW content detected`);
4495
+ }
4496
+ if (result.results?.authentication) {
4497
+ const auth = result.results.authentication;
4498
+ lines.push("", " Authentication:");
4499
+ if (auth.dkim?.status?.result) {
4500
+ lines.push(` DKIM: ${auth.dkim.status.result}`);
4501
+ }
4502
+ if (auth.spf?.status?.result) {
4503
+ lines.push(` SPF: ${auth.spf.status.result}`);
4504
+ }
4505
+ if (auth.dmarc?.status?.result) {
4506
+ lines.push(` DMARC: ${auth.dmarc.status.result}`);
4507
+ }
4508
+ if (auth.arc?.status?.result) {
4509
+ lines.push(` ARC: ${auth.arc.status.result}`);
4510
+ }
4511
+ }
4512
+ if (result.results?.reputation) {
4513
+ const rep = result.results.reputation;
4514
+ lines.push("", " Reputation:");
4515
+ if (rep.isTruthSource) {
4516
+ lines.push(" Status: Truth Source");
4517
+ } else if (rep.isAllowlisted) {
4518
+ lines.push(` Status: Allowlisted (${rep.allowlistValue || "N/A"})`);
4519
+ } else if (rep.isDenylisted) {
4520
+ lines.push(` Status: DENYLISTED (${rep.denylistValue || "N/A"})`);
4521
+ } else {
4522
+ lines.push(" Status: Unknown");
4523
+ }
4524
+ if (rep.checkedValues?.length > 0) {
4525
+ lines.push(` Checked: ${rep.checkedValues.join(", ")}`);
4526
+ }
4527
+ }
4528
+ }
4529
+ return lines.join("\n");
4530
+ }
4531
+ function buildScannerConfig(options) {
4532
+ return {
4533
+ debug: options.debug,
4534
+ timeout: options.timeout,
4535
+ supportedLanguages: options.supportedLanguages,
4536
+ enableMixedLanguageDetection: options.enableMixedLanguageDetection,
4537
+ enableMacroDetection: options.enableMacroDetection,
4538
+ enableAdvancedPatternRecognition: options.enableAdvancedPatternRecognition,
4539
+ strictIDNDetection: options.strictIdnDetection,
4540
+ nsfwThreshold: options.nsfwThreshold,
4541
+ toxicityThreshold: options.toxicityThreshold,
4542
+ clamscan: {
4543
+ clamscanPath: options.clamscanPath,
4544
+ clamdscanPath: options.clamdscanPath
4545
+ },
4546
+ // Authentication options
4547
+ enableAuthentication: options.enableAuth,
4548
+ authOptions: {
4549
+ ip: options.senderIp,
4550
+ hostname: options.senderHostname,
4551
+ helo: options.helo,
4552
+ mta: options.mta,
4553
+ sender: options.sender,
4554
+ timeout: options.authTimeout
4555
+ },
4556
+ // Reputation options
4557
+ enableReputation: options.enableReputation,
4558
+ reputationOptions: {
4559
+ apiUrl: options.reputationUrl,
4560
+ timeout: options.reputationTimeout,
4561
+ onlyAligned: options.onlyAligned
4562
+ }
4563
+ };
4564
+ }
4565
+ async function scanCommand(options) {
4566
+ const { file, json, verbose, addHeaders, prependSubject } = options;
4567
+ if (!file) {
4568
+ console.error('Error: No file specified. Use "spamscanner scan <file>" or "spamscanner scan -" for stdin.');
4569
+ import_node_process2.default.exit(2);
4570
+ }
4571
+ try {
4572
+ if (file !== "-") {
4573
+ try {
4574
+ (0, import_node_fs4.readFileSync)(file);
4575
+ } catch {
4576
+ console.error(`Error: File not found: ${file}`);
4577
+ import_node_process2.default.exit(2);
4578
+ }
4579
+ }
4580
+ const scannerConfig = buildScannerConfig(options);
4581
+ const scanner = new index_default(scannerConfig);
4582
+ const emailContent = await readEmail(file);
4583
+ const result = await scanner.scan(emailContent);
4584
+ const scoreDetails = calculateScore(result, options);
4585
+ const output = {
4586
+ isSpam: scoreDetails.isSpam,
4587
+ score: scoreDetails.score,
4588
+ threshold: scoreDetails.threshold,
4589
+ tests: scoreDetails.tests,
4590
+ message: result.message,
4591
+ results: result.results,
4592
+ links: result.links,
4593
+ tokens: result.tokens,
4594
+ mail: result.mail
4595
+ };
4596
+ if (addHeaders || prependSubject || options.addAuthHeaders) {
4597
+ output.headers = generateSpamHeaders(scoreDetails);
4598
+ const authResultsHeader = result.results?.authentication?.authResultsHeader || null;
4599
+ output.modifiedEmail = modifyEmail(emailContent, options, scoreDetails, authResultsHeader);
4600
+ }
4601
+ if (json) {
4602
+ console.log(JSON.stringify(output, null, 2));
4603
+ } else if (addHeaders || prependSubject || options.addAuthHeaders) {
4604
+ console.log(output.modifiedEmail);
4605
+ } else {
4606
+ console.log(formatResult(result, scoreDetails, verbose));
4607
+ }
4608
+ import_node_process2.default.exit(scoreDetails.isSpam ? 1 : 0);
4609
+ } catch (error) {
4610
+ console.error(`Error scanning email: ${error.message}`);
4611
+ if (options.debug) {
4612
+ console.error(error.stack);
4613
+ }
4614
+ import_node_process2.default.exit(2);
4615
+ }
4616
+ }
4617
+ async function serverCommand(options) {
4618
+ const { port, host, json, verbose, debug: debug8 } = options;
4619
+ const scannerConfig = buildScannerConfig(options);
4620
+ const scanner = new index_default(scannerConfig);
4621
+ const server = (0, import_node_net.createServer)((socket) => {
4622
+ const chunks = [];
4623
+ socket.on("data", (chunk) => {
4624
+ chunks.push(chunk);
4625
+ });
4626
+ socket.on("end", async () => {
4627
+ try {
4628
+ const emailContent = import_node_buffer3.Buffer.concat(chunks);
4629
+ const result = await scanner.scan(emailContent);
4630
+ const scoreDetails = calculateScore(result, options);
4631
+ const output = {
4632
+ isSpam: scoreDetails.isSpam,
4633
+ score: scoreDetails.score,
4634
+ threshold: scoreDetails.threshold,
4635
+ tests: scoreDetails.tests,
4636
+ message: result.message
4637
+ };
4638
+ if (options.addHeaders) {
4639
+ output.headers = generateSpamHeaders(scoreDetails);
4640
+ }
4641
+ if (json) {
4642
+ socket.write(JSON.stringify(output));
4643
+ } else {
4644
+ socket.write(formatResult(result, scoreDetails, verbose));
4645
+ }
4646
+ } catch (error) {
4647
+ const errorResponse = json ? JSON.stringify({ error: error.message }) : `Error: ${error.message}`;
4648
+ socket.write(errorResponse);
4649
+ if (debug8) {
4650
+ console.error(error.stack);
4651
+ }
4652
+ }
4653
+ socket.end();
4654
+ });
4655
+ socket.on("error", (error) => {
4656
+ console.error(`Socket error: ${error.message}`);
4657
+ });
4658
+ });
4659
+ server.listen(port, host, () => {
4660
+ console.log(`SpamScanner TCP server listening on ${host}:${port}`);
4661
+ console.log("Send email content to scan, close connection to receive results.");
4662
+ console.log("Press Ctrl+C to stop.");
4663
+ });
4664
+ server.on("error", (error) => {
4665
+ console.error(`Server error: ${error.message}`);
4666
+ import_node_process2.default.exit(2);
4667
+ });
4668
+ }
4669
+ async function main() {
4670
+ const args = import_node_process2.default.argv.slice(2);
4671
+ const options = parseArgs(args);
4672
+ if (options.help) {
4673
+ console.log(HELP_TEXT);
4674
+ import_node_process2.default.exit(0);
4675
+ }
4676
+ if (options.version) {
4677
+ console.log(`SpamScanner v${VERSION}`);
4678
+ import_node_process2.default.exit(0);
4679
+ }
4680
+ if (!options.noUpdateCheck && options.command !== "update") {
4681
+ void printUpdateNotification().catch(() => {
4682
+ });
4683
+ }
4684
+ switch (options.command) {
4685
+ case "scan": {
4686
+ await scanCommand(options);
4687
+ break;
4688
+ }
4689
+ case "server": {
4690
+ await serverCommand(options);
4691
+ break;
4692
+ }
4693
+ case "update": {
4694
+ console.log(`SpamScanner v${VERSION}`);
4695
+ console.log("Checking for updates...");
4696
+ const update = await checkForUpdates(true);
4697
+ if (update) {
4698
+ console.log(`New version available: ${update.latestVersion}`);
4699
+ console.log(`Download from: ${update.releaseUrl}`);
4700
+ if (update.downloadUrl) {
4701
+ console.log(`Direct download: ${update.downloadUrl}`);
4702
+ }
4703
+ } else {
4704
+ console.log("You are running the latest version.");
4705
+ }
4706
+ import_node_process2.default.exit(0);
4707
+ break;
4708
+ }
4709
+ case "help": {
4710
+ console.log(HELP_TEXT);
4711
+ import_node_process2.default.exit(0);
4712
+ break;
4713
+ }
4714
+ case "version": {
4715
+ console.log(`SpamScanner v${VERSION}`);
4716
+ import_node_process2.default.exit(0);
4717
+ break;
4718
+ }
4719
+ default: {
4720
+ console.error('Unknown command. Use "spamscanner help" for usage information.');
4721
+ import_node_process2.default.exit(2);
4722
+ }
4723
+ }
4724
+ }
4725
+ main().catch((error) => {
4726
+ console.error(`Fatal error: ${error.message}`);
4727
+ import_node_process2.default.exit(2);
4728
+ });
4729
+ //# sourceMappingURL=cli.cjs.map