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