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.
package/dist/esm/index.js CHANGED
@@ -34,14 +34,14 @@ var replacements_exports = {};
34
34
  __export(replacements_exports, {
35
35
  default: () => replacements_default
36
36
  });
37
- import { debuglog } from "node:util";
37
+ import { debuglog as debuglog5 } from "node:util";
38
38
  import { readFileSync } from "node:fs";
39
39
  import cryptoRandomString from "crypto-random-string";
40
- var debug, randomOptions, replacements, replacements_default;
40
+ var debug5, randomOptions, replacements, replacements_default;
41
41
  var init_replacements = __esm({
42
42
  "replacements.js"() {
43
43
  init_replacement_words();
44
- debug = debuglog("spamscanner");
44
+ debug5 = debuglog5("spamscanner");
45
45
  randomOptions = {
46
46
  length: 10,
47
47
  characters: "abcdefghijklmnopqrstuvwxyz"
@@ -50,7 +50,7 @@ var init_replacements = __esm({
50
50
  try {
51
51
  replacements = JSON.parse(readFileSync("./replacements.json", "utf8"));
52
52
  } catch (error) {
53
- debug(error);
53
+ debug5(error);
54
54
  for (const replacement of replacement_words_default) {
55
55
  replacements[replacement] = `${replacement}${cryptoRandomString(randomOptions)}`;
56
56
  }
@@ -64,18 +64,18 @@ var get_classifier_exports = {};
64
64
  __export(get_classifier_exports, {
65
65
  default: () => get_classifier_default
66
66
  });
67
- import { debuglog as debuglog2 } from "node:util";
67
+ import { debuglog as debuglog6 } from "node:util";
68
68
  import { readFileSync as readFileSync2 } from "node:fs";
69
69
  import NaiveBayes from "@ladjs/naivebayes";
70
- var debug2, classifier, get_classifier_default;
70
+ var debug6, classifier, get_classifier_default;
71
71
  var init_get_classifier = __esm({
72
72
  "get-classifier.js"() {
73
- debug2 = debuglog2("spamscanner");
73
+ debug6 = debuglog6("spamscanner");
74
74
  classifier = new NaiveBayes().toJsonObject();
75
75
  try {
76
76
  classifier = JSON.parse(readFileSync2("./classifier.json", "utf8"));
77
77
  } catch (error) {
78
- debug2(error);
78
+ debug6(error);
79
79
  }
80
80
  get_classifier_default = classifier;
81
81
  }
@@ -520,11 +520,12 @@ var init_enhanced_idn_detector = __esm({
520
520
  });
521
521
 
522
522
  // src/index.js
523
+ import { Buffer as Buffer3 } from "node:buffer";
523
524
  import fs from "node:fs";
524
525
  import path from "node:path";
525
526
  import process from "node:process";
526
527
  import { createHash as createHash2 } from "node:crypto";
527
- import { debuglog as debuglog3 } from "node:util";
528
+ import { debuglog as debuglog7 } from "node:util";
528
529
  import { fileURLToPath } from "node:url";
529
530
  import autoBind from "auto-bind";
530
531
  import AFHConvert from "ascii-fullwidth-halfwidth-convert";
@@ -555,9 +556,1460 @@ import sw from "stopword";
555
556
  import urlRegexSafe from "url-regex-safe";
556
557
  import { simpleParser } from "mailparser";
557
558
  import { fileTypeFromBuffer } from "file-type";
558
- var __filename = fileURLToPath(import.meta.url);
559
- var __dirname = path.dirname(__filename);
560
- var executablesData = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "executables.json"), "utf8"));
559
+
560
+ // src/auth.js
561
+ import { Buffer as Buffer2 } from "node:buffer";
562
+ import { debuglog } from "node:util";
563
+ import dns from "node:dns";
564
+ var debug = debuglog("spamscanner:auth");
565
+ var mailauth;
566
+ var getMailauth = async () => {
567
+ mailauth ||= await import("mailauth");
568
+ return mailauth;
569
+ };
570
+ var createResolver = (timeout = 1e4) => {
571
+ const resolver = new dns.promises.Resolver();
572
+ resolver.setServers(["8.8.8.8", "1.1.1.1"]);
573
+ return async (name, type) => {
574
+ try {
575
+ const controller = new AbortController();
576
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
577
+ let result;
578
+ switch (type) {
579
+ case "TXT": {
580
+ result = await resolver.resolveTxt(name);
581
+ result = result.map((r) => Array.isArray(r) ? r.join("") : r);
582
+ break;
583
+ }
584
+ case "MX": {
585
+ result = await resolver.resolveMx(name);
586
+ break;
587
+ }
588
+ case "A": {
589
+ result = await resolver.resolve4(name);
590
+ break;
591
+ }
592
+ case "AAAA": {
593
+ result = await resolver.resolve6(name);
594
+ break;
595
+ }
596
+ case "PTR": {
597
+ result = await resolver.resolvePtr(name);
598
+ break;
599
+ }
600
+ case "CNAME": {
601
+ result = await resolver.resolveCname(name);
602
+ break;
603
+ }
604
+ default: {
605
+ result = await resolver.resolve(name, type);
606
+ }
607
+ }
608
+ clearTimeout(timeoutId);
609
+ return result;
610
+ } catch (error) {
611
+ debug("DNS lookup failed for %s %s: %s", type, name, error.message);
612
+ throw error;
613
+ }
614
+ };
615
+ };
616
+ async function authenticate(message, options = {}) {
617
+ const {
618
+ ip,
619
+ helo,
620
+ mta,
621
+ sender,
622
+ resolver = createResolver(options.timeout || 1e4)
623
+ } = options;
624
+ const defaultResult = {
625
+ dkim: {
626
+ results: [],
627
+ status: { result: "none", comment: "No DKIM signature found" }
628
+ },
629
+ spf: {
630
+ status: { result: "none", comment: "SPF check not performed" },
631
+ domain: null
632
+ },
633
+ dmarc: {
634
+ status: { result: "none", comment: "DMARC check not performed" },
635
+ policy: null,
636
+ domain: null
637
+ },
638
+ arc: {
639
+ status: { result: "none", comment: "No ARC chain found" },
640
+ chain: []
641
+ },
642
+ bimi: {
643
+ status: { result: "none", comment: "No BIMI record found" },
644
+ location: null,
645
+ authority: null
646
+ },
647
+ receivedChain: [],
648
+ headers: {}
649
+ };
650
+ if (!ip) {
651
+ debug("No IP address provided, skipping authentication");
652
+ return defaultResult;
653
+ }
654
+ try {
655
+ const { authenticate: mailauthAuthenticate } = await getMailauth();
656
+ const messageBuffer = Buffer2.isBuffer(message) ? message : Buffer2.from(message);
657
+ const authResult = await mailauthAuthenticate(messageBuffer, {
658
+ ip,
659
+ helo: helo || "unknown",
660
+ mta: mta || "spamscanner",
661
+ sender,
662
+ resolver
663
+ });
664
+ debug("Authentication result: %o", authResult);
665
+ return {
666
+ dkim: normalizeResult(authResult.dkim, "dkim"),
667
+ spf: normalizeResult(authResult.spf, "spf"),
668
+ dmarc: normalizeResult(authResult.dmarc, "dmarc"),
669
+ arc: normalizeResult(authResult.arc, "arc"),
670
+ bimi: normalizeResult(authResult.bimi, "bimi"),
671
+ receivedChain: authResult.receivedChain || [],
672
+ headers: authResult.headers || {}
673
+ };
674
+ } catch (error) {
675
+ debug("Authentication failed: %s", error.message);
676
+ return defaultResult;
677
+ }
678
+ }
679
+ function normalizeResult(result, type) {
680
+ if (!result) {
681
+ return {
682
+ status: { result: "none", comment: `No ${type.toUpperCase()} result` }
683
+ };
684
+ }
685
+ switch (type) {
686
+ case "dkim": {
687
+ return {
688
+ results: result.results || [],
689
+ status: result.status || { result: "none", comment: "No DKIM signature found" }
690
+ };
691
+ }
692
+ case "spf": {
693
+ return {
694
+ status: result.status || { result: "none", comment: "SPF check not performed" },
695
+ domain: result.domain || null,
696
+ explanation: result.explanation || null
697
+ };
698
+ }
699
+ case "dmarc": {
700
+ return {
701
+ status: result.status || { result: "none", comment: "DMARC check not performed" },
702
+ policy: result.policy || null,
703
+ domain: result.domain || null,
704
+ p: result.p || null,
705
+ sp: result.sp || null,
706
+ pct: result.pct || null
707
+ };
708
+ }
709
+ case "arc": {
710
+ return {
711
+ status: result.status || { result: "none", comment: "No ARC chain found" },
712
+ chain: result.chain || [],
713
+ i: result.i || null
714
+ };
715
+ }
716
+ case "bimi": {
717
+ return {
718
+ status: result.status || { result: "none", comment: "No BIMI record found" },
719
+ location: result.location || null,
720
+ authority: result.authority || null,
721
+ selector: result.selector || null
722
+ };
723
+ }
724
+ default: {
725
+ return result;
726
+ }
727
+ }
728
+ }
729
+ function calculateAuthScore(authResult, weights = {}) {
730
+ const defaultWeights = {
731
+ dkimPass: -2,
732
+ // Reduce spam score if DKIM passes
733
+ dkimFail: 3,
734
+ // Increase spam score if DKIM fails
735
+ spfPass: -1,
736
+ spfFail: 2,
737
+ spfSoftfail: 1,
738
+ dmarcPass: -2,
739
+ dmarcFail: 4,
740
+ arcPass: -1,
741
+ arcFail: 1,
742
+ ...weights
743
+ };
744
+ let score = 0;
745
+ const tests = [];
746
+ const dkimResult = authResult.dkim?.status?.result;
747
+ if (dkimResult === "pass") {
748
+ score += defaultWeights.dkimPass;
749
+ tests.push(`DKIM_PASS(${defaultWeights.dkimPass})`);
750
+ } else if (dkimResult === "fail") {
751
+ score += defaultWeights.dkimFail;
752
+ tests.push(`DKIM_FAIL(${defaultWeights.dkimFail})`);
753
+ }
754
+ const spfResult = authResult.spf?.status?.result;
755
+ switch (spfResult) {
756
+ case "pass": {
757
+ score += defaultWeights.spfPass;
758
+ tests.push(`SPF_PASS(${defaultWeights.spfPass})`);
759
+ break;
760
+ }
761
+ case "fail": {
762
+ score += defaultWeights.spfFail;
763
+ tests.push(`SPF_FAIL(${defaultWeights.spfFail})`);
764
+ break;
765
+ }
766
+ case "softfail": {
767
+ score += defaultWeights.spfSoftfail;
768
+ tests.push(`SPF_SOFTFAIL(${defaultWeights.spfSoftfail})`);
769
+ break;
770
+ }
771
+ }
772
+ const dmarcResult = authResult.dmarc?.status?.result;
773
+ if (dmarcResult === "pass") {
774
+ score += defaultWeights.dmarcPass;
775
+ tests.push(`DMARC_PASS(${defaultWeights.dmarcPass})`);
776
+ } else if (dmarcResult === "fail") {
777
+ score += defaultWeights.dmarcFail;
778
+ tests.push(`DMARC_FAIL(${defaultWeights.dmarcFail})`);
779
+ }
780
+ const arcResult = authResult.arc?.status?.result;
781
+ if (arcResult === "pass") {
782
+ score += defaultWeights.arcPass;
783
+ tests.push(`ARC_PASS(${defaultWeights.arcPass})`);
784
+ } else if (arcResult === "fail") {
785
+ score += defaultWeights.arcFail;
786
+ tests.push(`ARC_FAIL(${defaultWeights.arcFail})`);
787
+ }
788
+ return {
789
+ score,
790
+ tests,
791
+ details: {
792
+ dkim: dkimResult || "none",
793
+ spf: spfResult || "none",
794
+ dmarc: dmarcResult || "none",
795
+ arc: arcResult || "none"
796
+ }
797
+ };
798
+ }
799
+ function formatAuthResultsHeader(authResult, hostname = "spamscanner") {
800
+ const parts = [hostname];
801
+ if (authResult.dkim?.status?.result) {
802
+ const dkimResult = authResult.dkim.status.result;
803
+ let dkimPart = `dkim=${dkimResult}`;
804
+ if (authResult.dkim.results?.[0]?.signingDomain) {
805
+ dkimPart += ` header.d=${authResult.dkim.results[0].signingDomain}`;
806
+ }
807
+ parts.push(dkimPart);
808
+ }
809
+ if (authResult.spf?.status?.result) {
810
+ let spfPart = `spf=${authResult.spf.status.result}`;
811
+ if (authResult.spf.domain) {
812
+ spfPart += ` smtp.mailfrom=${authResult.spf.domain}`;
813
+ }
814
+ parts.push(spfPart);
815
+ }
816
+ if (authResult.dmarc?.status?.result) {
817
+ let dmarcPart = `dmarc=${authResult.dmarc.status.result}`;
818
+ if (authResult.dmarc.domain) {
819
+ dmarcPart += ` header.from=${authResult.dmarc.domain}`;
820
+ }
821
+ parts.push(dmarcPart);
822
+ }
823
+ if (authResult.arc?.status?.result) {
824
+ parts.push(`arc=${authResult.arc.status.result}`);
825
+ }
826
+ return parts.join(";\n ");
827
+ }
828
+
829
+ // src/reputation.js
830
+ import { debuglog as debuglog2 } from "node:util";
831
+ var debug2 = debuglog2("spamscanner:reputation");
832
+ var DEFAULT_API_URL = "https://api.forwardemail.net/v1/reputation";
833
+ var cache = /* @__PURE__ */ new Map();
834
+ var CACHE_TTL = 5 * 60 * 1e3;
835
+ async function checkReputation(value, options = {}) {
836
+ const {
837
+ apiUrl = DEFAULT_API_URL,
838
+ timeout = 1e4
839
+ } = options;
840
+ if (!value || typeof value !== "string") {
841
+ return {
842
+ isTruthSource: false,
843
+ truthSourceValue: null,
844
+ isAllowlisted: false,
845
+ allowlistValue: null,
846
+ isDenylisted: false,
847
+ denylistValue: null
848
+ };
849
+ }
850
+ const cacheKey = `${apiUrl}:${value}`;
851
+ const cached = cache.get(cacheKey);
852
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
853
+ debug2("Cache hit for %s", value);
854
+ return cached.result;
855
+ }
856
+ try {
857
+ const url = new URL(apiUrl);
858
+ url.searchParams.set("q", value);
859
+ const controller = new AbortController();
860
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
861
+ const response = await fetch(url.toString(), {
862
+ method: "GET",
863
+ headers: {
864
+ Accept: "application/json",
865
+ "User-Agent": "SpamScanner/6.0"
866
+ },
867
+ signal: controller.signal
868
+ });
869
+ clearTimeout(timeoutId);
870
+ if (!response.ok) {
871
+ debug2("API returned status %d for %s", response.status, value);
872
+ return {
873
+ isTruthSource: false,
874
+ truthSourceValue: null,
875
+ isAllowlisted: false,
876
+ allowlistValue: null,
877
+ isDenylisted: false,
878
+ denylistValue: null
879
+ };
880
+ }
881
+ const result = await response.json();
882
+ const normalizedResult = {
883
+ isTruthSource: Boolean(result.isTruthSource),
884
+ truthSourceValue: result.truthSourceValue || null,
885
+ isAllowlisted: Boolean(result.isAllowlisted),
886
+ allowlistValue: result.allowlistValue || null,
887
+ isDenylisted: Boolean(result.isDenylisted),
888
+ denylistValue: result.denylistValue || null
889
+ };
890
+ cache.set(cacheKey, {
891
+ result: normalizedResult,
892
+ timestamp: Date.now()
893
+ });
894
+ debug2("Reputation check for %s: %o", value, normalizedResult);
895
+ return normalizedResult;
896
+ } catch (error) {
897
+ debug2("Reputation check failed for %s: %s", value, error.message);
898
+ return {
899
+ isTruthSource: false,
900
+ truthSourceValue: null,
901
+ isAllowlisted: false,
902
+ allowlistValue: null,
903
+ isDenylisted: false,
904
+ denylistValue: null
905
+ };
906
+ }
907
+ }
908
+ async function checkReputationBatch(values, options = {}) {
909
+ const uniqueValues = [...new Set(values.filter(Boolean))];
910
+ const results = await Promise.all(uniqueValues.map(async (value) => {
911
+ const result = await checkReputation(value, options);
912
+ return [value, result];
913
+ }));
914
+ return new Map(results);
915
+ }
916
+ function aggregateReputationResults(results) {
917
+ const aggregated = {
918
+ isTruthSource: false,
919
+ truthSourceValue: null,
920
+ isAllowlisted: false,
921
+ allowlistValue: null,
922
+ isDenylisted: false,
923
+ denylistValue: null
924
+ };
925
+ for (const result of results) {
926
+ if (result.isTruthSource) {
927
+ aggregated.isTruthSource = true;
928
+ aggregated.truthSourceValue ||= result.truthSourceValue;
929
+ }
930
+ if (result.isAllowlisted) {
931
+ aggregated.isAllowlisted = true;
932
+ aggregated.allowlistValue ||= result.allowlistValue;
933
+ }
934
+ if (result.isDenylisted) {
935
+ aggregated.isDenylisted = true;
936
+ aggregated.denylistValue ||= result.denylistValue;
937
+ }
938
+ }
939
+ return aggregated;
940
+ }
941
+
942
+ // src/is-arbitrary.js
943
+ import { debuglog as debuglog3 } from "node:util";
944
+ var debug3 = debuglog3("spamscanner:arbitrary");
945
+ 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;
946
+ 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;
947
+ var SPAM_PATTERNS = {
948
+ // Subject line patterns
949
+ subjectPatterns: [
950
+ // Urgency patterns
951
+ /\b(urgent|immediate|action required|act now|limited time|expires?|deadline)\b/i,
952
+ // Money patterns
953
+ /\b(free|winner|won|prize|lottery|million|billion|cash|money|investment|profit)\b/i,
954
+ // Phishing patterns
955
+ /\b(verify|confirm|update|suspend|locked|unusual activity|security alert)\b/i,
956
+ // Adult content
957
+ /\b(viagra|cialis|pharmacy|pills|medication|prescription)\b/i,
958
+ // Crypto spam
959
+ /\b(bitcoin|crypto|btc|eth|nft|blockchain|wallet)\b/i
960
+ ],
961
+ // Body patterns
962
+ bodyPatterns: [
963
+ // Nigerian prince / advance fee fraud
964
+ /\b(nigerian?|prince|inheritance|beneficiary|next of kin|deceased|unclaimed)\b/i,
965
+ // Lottery scams
966
+ /\b(congratulations.*won|you have been selected|claim your prize)\b/i,
967
+ // Phishing
968
+ /\b(click here to verify|confirm your identity|update your account|suspended.*account)\b/i,
969
+ // Urgency
970
+ /\b(act now|limited time offer|expires in \d+|only \d+ left)\b/i,
971
+ // Financial scams
972
+ /\b(wire transfer|western union|moneygram|bank transfer|routing number)\b/i,
973
+ // Adult/pharma spam
974
+ /\b(enlarge|enhancement|erectile|dysfunction|weight loss|diet pills)\b/i
975
+ ],
976
+ // Suspicious sender patterns
977
+ senderPatterns: [
978
+ // Random numbers in email
979
+ /^[a-z]+\d{4,}@/i,
980
+ // Very long local parts
981
+ /^.{30,}@/,
982
+ // Suspicious domains
983
+ /@.*(\.ru|\.cn|\.tk|\.ml|\.ga|\.cf|\.gq)$/i,
984
+ // Numeric domains
985
+ /@(?:\d+\.){3}\d+/
986
+ ]
987
+ };
988
+ var SUSPICIOUS_TLDS = /* @__PURE__ */ new Set([
989
+ "tk",
990
+ "ml",
991
+ "ga",
992
+ "cf",
993
+ "gq",
994
+ // Free TLDs often abused
995
+ "xyz",
996
+ "top",
997
+ "wang",
998
+ "win",
999
+ "bid",
1000
+ "loan",
1001
+ "click",
1002
+ "link",
1003
+ "work",
1004
+ "date",
1005
+ "racing",
1006
+ "download",
1007
+ "stream",
1008
+ "trade"
1009
+ ]);
1010
+ var SPAM_KEYWORDS = /* @__PURE__ */ new Map([
1011
+ ["free", 1],
1012
+ ["winner", 2],
1013
+ ["prize", 2],
1014
+ ["lottery", 3],
1015
+ ["urgent", 1],
1016
+ ["act now", 2],
1017
+ ["limited time", 1],
1018
+ ["click here", 1],
1019
+ ["unsubscribe", -1],
1020
+ // Legitimate emails often have this
1021
+ ["verify your account", 2],
1022
+ ["suspended", 2],
1023
+ ["inheritance", 3],
1024
+ ["million dollars", 3],
1025
+ ["wire transfer", 3],
1026
+ ["western union", 3],
1027
+ ["nigerian", 3],
1028
+ ["prince", 2],
1029
+ ["beneficiary", 2],
1030
+ ["congratulations", 1],
1031
+ ["selected", 1],
1032
+ ["viagra", 3],
1033
+ ["cialis", 3],
1034
+ ["pharmacy", 2],
1035
+ ["bitcoin", 1],
1036
+ ["crypto", 1],
1037
+ ["investment opportunity", 2],
1038
+ ["guaranteed", 1],
1039
+ ["risk free", 2],
1040
+ ["no obligation", 1],
1041
+ ["dear friend", 2],
1042
+ ["dear customer", 1],
1043
+ ["dear user", 1]
1044
+ ]);
1045
+ var PAYPAL_SPAM_TYPE_IDS = /* @__PURE__ */ new Set(["PPC001017", "RT000238", "RT000542", "RT002947"]);
1046
+ var MS_SPAM_CATEGORIES = {
1047
+ // High-confidence threats (highest priority)
1048
+ highConfidence: ["cat:malw", "cat:hphsh", "cat:hphish", "cat:hspm"],
1049
+ // Impersonation attempts
1050
+ impersonation: ["cat:bimp", "cat:dimp", "cat:gimp", "cat:uimp"],
1051
+ // Phishing and spoofing
1052
+ phishingAndSpoofing: ["cat:phsh", "cat:spoof"],
1053
+ // Spam classifications
1054
+ spam: ["cat:ospm", "cat:spm"]
1055
+ };
1056
+ var MS_SPAM_VERDICTS = ["sfv:spm", "sfv:skb", "sfv:sks"];
1057
+ function isArbitrary(parsed, options = {}) {
1058
+ const {
1059
+ threshold = 5,
1060
+ checkSubject = true,
1061
+ checkBody = true,
1062
+ checkSender = true,
1063
+ checkHeaders = true,
1064
+ checkLinks = true,
1065
+ checkMicrosoftHeaders = true,
1066
+ checkVendorSpam = true,
1067
+ checkSpoofing = true,
1068
+ session = {}
1069
+ } = options;
1070
+ const reasons = [];
1071
+ let score = 0;
1072
+ let category = null;
1073
+ const getHeader = (name) => {
1074
+ if (parsed.headers?.get) {
1075
+ return parsed.headers.get(name);
1076
+ }
1077
+ if (parsed.headerLines) {
1078
+ const header = parsed.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
1079
+ return header?.line?.split(":").slice(1).join(":").trim();
1080
+ }
1081
+ return null;
1082
+ };
1083
+ const subject = parsed.subject || getHeader("subject") || "";
1084
+ const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
1085
+ const sessionInfo = buildSessionInfo(parsed, session, getHeader);
1086
+ if (subject && BLOCKED_PHRASES_PATTERN.test(subject)) {
1087
+ reasons.push("BLOCKED_PHRASE_IN_SUBJECT");
1088
+ score += 10;
1089
+ category = "SPAM";
1090
+ }
1091
+ if (checkMicrosoftHeaders) {
1092
+ const msResult = checkMicrosoftExchangeHeaders(getHeader, sessionInfo);
1093
+ if (msResult.blocked) {
1094
+ reasons.push(...msResult.reasons);
1095
+ score += msResult.score;
1096
+ category = msResult.category || category;
1097
+ }
1098
+ }
1099
+ if (checkVendorSpam) {
1100
+ const vendorResult = checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from);
1101
+ if (vendorResult.blocked) {
1102
+ reasons.push(...vendorResult.reasons);
1103
+ score += vendorResult.score;
1104
+ category = vendorResult.category || category;
1105
+ }
1106
+ }
1107
+ if (checkSpoofing) {
1108
+ const spoofResult = checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject);
1109
+ if (spoofResult.blocked) {
1110
+ reasons.push(...spoofResult.reasons);
1111
+ score += spoofResult.score;
1112
+ category = spoofResult.category || category;
1113
+ }
1114
+ }
1115
+ if (checkSubject && subject) {
1116
+ const subjectResult = checkSubjectLine(subject);
1117
+ score += subjectResult.score;
1118
+ reasons.push(...subjectResult.reasons);
1119
+ }
1120
+ if (checkBody) {
1121
+ const bodyText = parsed.text || "";
1122
+ const bodyHtml = parsed.html || "";
1123
+ const bodyResult = checkBodyContent(bodyText, bodyHtml);
1124
+ score += bodyResult.score;
1125
+ reasons.push(...bodyResult.reasons);
1126
+ }
1127
+ if (checkSender) {
1128
+ const replyTo = parsed.replyTo?.value?.[0]?.address || parsed.replyTo?.text || "";
1129
+ const senderResult = checkSenderPatterns(from, replyTo);
1130
+ score += senderResult.score;
1131
+ reasons.push(...senderResult.reasons);
1132
+ }
1133
+ if (checkHeaders) {
1134
+ const headerResult = checkHeaderAnomalies(parsed, getHeader);
1135
+ score += headerResult.score;
1136
+ reasons.push(...headerResult.reasons);
1137
+ }
1138
+ if (checkLinks) {
1139
+ const bodyHtml = parsed.html || parsed.text || "";
1140
+ const linkResult = checkSuspiciousLinks(bodyHtml);
1141
+ score += linkResult.score;
1142
+ reasons.push(...linkResult.reasons);
1143
+ }
1144
+ const isArbitrarySpam = score >= threshold;
1145
+ debug3(
1146
+ "Arbitrary check result: score=%d, threshold=%d, isArbitrary=%s, category=%s, reasons=%o",
1147
+ score,
1148
+ threshold,
1149
+ isArbitrarySpam,
1150
+ category,
1151
+ reasons
1152
+ );
1153
+ return {
1154
+ isArbitrary: isArbitrarySpam,
1155
+ reasons,
1156
+ score,
1157
+ category
1158
+ };
1159
+ }
1160
+ function buildSessionInfo(parsed, session, getHeader) {
1161
+ const info = { ...session };
1162
+ const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
1163
+ if (from && !info.originalFromAddress) {
1164
+ info.originalFromAddress = from.toLowerCase();
1165
+ const atIndex = from.indexOf("@");
1166
+ if (atIndex > 0) {
1167
+ info.originalFromAddressDomain = from.slice(atIndex + 1).toLowerCase();
1168
+ info.originalFromAddressRootDomain = getRootDomain(info.originalFromAddressDomain);
1169
+ }
1170
+ }
1171
+ if (!info.resolvedClientHostname) {
1172
+ info.resolvedClientHostname = extractClientHostname(parsed);
1173
+ if (info.resolvedClientHostname) {
1174
+ info.resolvedRootClientHostname = getRootDomain(info.resolvedClientHostname);
1175
+ }
1176
+ }
1177
+ info.remoteAddress ||= extractRemoteIp(parsed);
1178
+ return info;
1179
+ }
1180
+ function checkMicrosoftExchangeHeaders(getHeader, sessionInfo) {
1181
+ const result = {
1182
+ blocked: false,
1183
+ reasons: [],
1184
+ score: 0,
1185
+ category: null
1186
+ };
1187
+ const isFromMicrosoft = sessionInfo.resolvedClientHostname && sessionInfo.resolvedClientHostname.endsWith(".outbound.protection.outlook.com");
1188
+ if (!isFromMicrosoft) {
1189
+ return result;
1190
+ }
1191
+ const msAuthHeader = getHeader("x-ms-exchange-authentication-results");
1192
+ const forefrontHeader = getHeader("x-forefront-antispam-report");
1193
+ if (forefrontHeader) {
1194
+ const lowerForefront = forefrontHeader.toLowerCase();
1195
+ const sclMatch = lowerForefront.match(/scl:(\d+)/);
1196
+ const scl = sclMatch ? Number.parseInt(sclMatch[1], 10) : null;
1197
+ const sfvNotSpam = lowerForefront.includes("sfv:nspm");
1198
+ const microsoftSaysNotSpam = sfvNotSpam || scl !== null && scl <= 2;
1199
+ if (!microsoftSaysNotSpam && msAuthHeader) {
1200
+ const lowerMsAuth = msAuthHeader.toLowerCase();
1201
+ const spfPass = lowerMsAuth.includes("spf=pass");
1202
+ const dkimPass = lowerMsAuth.includes("dkim=pass");
1203
+ const dmarcPass = lowerMsAuth.includes("dmarc=pass");
1204
+ if (!spfPass && !dkimPass && !dmarcPass) {
1205
+ const spfFailed = lowerMsAuth.includes("spf=fail");
1206
+ const dkimFailed = lowerMsAuth.includes("dkim=fail");
1207
+ const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
1208
+ if (spfFailed || dkimFailed || dmarcFailed) {
1209
+ result.blocked = true;
1210
+ result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
1211
+ result.score += 10;
1212
+ result.category = "AUTHENTICATION_FAILURE";
1213
+ return result;
1214
+ }
1215
+ }
1216
+ }
1217
+ for (const cat of MS_SPAM_CATEGORIES.highConfidence) {
1218
+ if (lowerForefront.includes(cat)) {
1219
+ result.blocked = true;
1220
+ result.reasons.push(`MS_HIGH_CONFIDENCE_THREAT: ${cat.toUpperCase()}`);
1221
+ result.score += 15;
1222
+ result.category = cat.includes("malw") ? "MALWARE" : cat.includes("phish") || cat.includes("phsh") ? "PHISHING" : "HIGH_CONFIDENCE_SPAM";
1223
+ return result;
1224
+ }
1225
+ }
1226
+ for (const cat of MS_SPAM_CATEGORIES.impersonation) {
1227
+ if (lowerForefront.includes(cat)) {
1228
+ result.blocked = true;
1229
+ result.reasons.push(`MS_IMPERSONATION: ${cat.toUpperCase()}`);
1230
+ result.score += 12;
1231
+ result.category = "IMPERSONATION";
1232
+ return result;
1233
+ }
1234
+ }
1235
+ for (const cat of MS_SPAM_CATEGORIES.phishingAndSpoofing) {
1236
+ if (lowerForefront.includes(cat)) {
1237
+ result.blocked = true;
1238
+ result.reasons.push(`MS_PHISHING_SPOOF: ${cat.toUpperCase()}`);
1239
+ result.score += 12;
1240
+ result.category = cat.includes("phsh") ? "PHISHING" : "SPOOFING";
1241
+ return result;
1242
+ }
1243
+ }
1244
+ for (const verdict of MS_SPAM_VERDICTS) {
1245
+ if (lowerForefront.includes(verdict)) {
1246
+ result.blocked = true;
1247
+ result.reasons.push(`MS_SPAM_VERDICT: ${verdict.toUpperCase()}`);
1248
+ result.score += 10;
1249
+ result.category = "SPAM";
1250
+ return result;
1251
+ }
1252
+ }
1253
+ for (const cat of MS_SPAM_CATEGORIES.spam) {
1254
+ if (lowerForefront.includes(cat)) {
1255
+ result.blocked = true;
1256
+ result.reasons.push(`MS_SPAM_CATEGORY: ${cat.toUpperCase()}`);
1257
+ result.score += 10;
1258
+ result.category = "SPAM";
1259
+ return result;
1260
+ }
1261
+ }
1262
+ if (scl !== null && scl >= 5) {
1263
+ result.blocked = true;
1264
+ result.reasons.push(`MS_HIGH_SCL: ${scl}`);
1265
+ result.score += 8;
1266
+ result.category = "SPAM";
1267
+ return result;
1268
+ }
1269
+ } else if (msAuthHeader) {
1270
+ const lowerMsAuth = msAuthHeader.toLowerCase();
1271
+ const spfPass = lowerMsAuth.includes("spf=pass");
1272
+ const dkimPass = lowerMsAuth.includes("dkim=pass");
1273
+ const dmarcPass = lowerMsAuth.includes("dmarc=pass");
1274
+ if (!spfPass && !dkimPass && !dmarcPass) {
1275
+ const spfFailed = lowerMsAuth.includes("spf=fail");
1276
+ const dkimFailed = lowerMsAuth.includes("dkim=fail");
1277
+ const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
1278
+ if (spfFailed || dkimFailed || dmarcFailed) {
1279
+ result.blocked = true;
1280
+ result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
1281
+ result.score += 10;
1282
+ result.category = "AUTHENTICATION_FAILURE";
1283
+ }
1284
+ }
1285
+ }
1286
+ return result;
1287
+ }
1288
+ function checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from) {
1289
+ const result = {
1290
+ blocked: false,
1291
+ reasons: [],
1292
+ score: 0,
1293
+ category: null
1294
+ };
1295
+ const fromLower = from.toLowerCase();
1296
+ if (sessionInfo.originalFromAddressRootDomain === "paypal.com" && getHeader("x-email-type-id")) {
1297
+ const typeId = getHeader("x-email-type-id");
1298
+ if (PAYPAL_SPAM_TYPE_IDS.has(typeId)) {
1299
+ result.blocked = true;
1300
+ result.reasons.push(`PAYPAL_INVOICE_SPAM: ${typeId}`);
1301
+ result.score += 15;
1302
+ result.category = "VENDOR_SPAM";
1303
+ return result;
1304
+ }
1305
+ }
1306
+ if (sessionInfo.originalFromAddress === "invoice@authorize.net" && sessionInfo.resolvedRootClientHostname === "visa.com") {
1307
+ result.blocked = true;
1308
+ result.reasons.push("AUTHORIZE_VISA_PHISHING");
1309
+ result.score += 15;
1310
+ result.category = "PHISHING";
1311
+ return result;
1312
+ }
1313
+ if (fromLower.includes("amazon.co.jp") && (!sessionInfo.resolvedRootClientHostname || !sessionInfo.resolvedRootClientHostname.startsWith("amazon."))) {
1314
+ result.blocked = true;
1315
+ result.reasons.push("AMAZON_JP_IMPERSONATION");
1316
+ result.score += 12;
1317
+ result.category = "IMPERSONATION";
1318
+ return result;
1319
+ }
1320
+ if (subject && subject.includes("pCloud") && sessionInfo.originalFromAddressRootDomain !== "pcloud.com" && fromLower.includes("pcloud")) {
1321
+ result.blocked = true;
1322
+ result.reasons.push("PCLOUD_IMPERSONATION");
1323
+ result.score += 12;
1324
+ result.category = "IMPERSONATION";
1325
+ return result;
1326
+ }
1327
+ 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: "))) {
1328
+ result.blocked = true;
1329
+ result.reasons.push("MS_BOUNCE_SPAM");
1330
+ result.score += 10;
1331
+ result.category = "BOUNCE_SPAM";
1332
+ return result;
1333
+ }
1334
+ if (sessionInfo.originalFromAddress === "postmaster@163.com" && subject && subject.includes("\u7CFB\u7EDF\u9000\u4FE1")) {
1335
+ result.blocked = true;
1336
+ result.reasons.push("163_BOUNCE_SPAM");
1337
+ result.score += 10;
1338
+ result.category = "BOUNCE_SPAM";
1339
+ return result;
1340
+ }
1341
+ if (sessionInfo.originalFromAddress === "dse_na4@docusign.net" && sessionInfo.spf?.domain && (sessionInfo.spf.domain.endsWith(".onmicrosoft.com") || sessionInfo.spf.domain === "onmicrosoft.com")) {
1342
+ result.blocked = true;
1343
+ result.reasons.push("DOCUSIGN_MS_SCAM");
1344
+ result.score += 12;
1345
+ result.category = "PHISHING";
1346
+ return result;
1347
+ }
1348
+ return result;
1349
+ }
1350
+ function checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject) {
1351
+ const result = {
1352
+ blocked: false,
1353
+ reasons: [],
1354
+ score: 0,
1355
+ category: null
1356
+ };
1357
+ if (sessionInfo.hadAlignedAndPassingDKIM || sessionInfo.isAllowlisted) {
1358
+ return result;
1359
+ }
1360
+ if (sessionInfo.hasSameHostnameAsFrom) {
1361
+ return result;
1362
+ }
1363
+ const rcptTo = sessionInfo.envelope?.rcptTo || [];
1364
+ const fromRootDomain = sessionInfo.originalFromAddressRootDomain;
1365
+ if (!fromRootDomain || rcptTo.length === 0) {
1366
+ return result;
1367
+ }
1368
+ const hasSameRcptToAsFrom = rcptTo.some((to) => {
1369
+ if (!to.address) {
1370
+ return false;
1371
+ }
1372
+ const toRootDomain = getRootDomain(parseHostFromAddress(to.address));
1373
+ return toRootDomain === fromRootDomain;
1374
+ });
1375
+ if (!hasSameRcptToAsFrom) {
1376
+ return result;
1377
+ }
1378
+ const spfResult = sessionInfo.spfFromHeader?.status?.result;
1379
+ if (spfResult === "pass") {
1380
+ return result;
1381
+ }
1382
+ sessionInfo.isPotentialPhishing = true;
1383
+ const xPhpScript = getHeader("x-php-script");
1384
+ const xMailer = getHeader("x-mailer");
1385
+ if (xPhpScript) {
1386
+ return result;
1387
+ }
1388
+ if (xMailer) {
1389
+ const mailerLower = xMailer.toLowerCase();
1390
+ if (mailerLower.includes("php") || mailerLower.includes("drupal")) {
1391
+ return result;
1392
+ }
1393
+ }
1394
+ if (subject && SYSADMIN_SUBJECT_PATTERN.test(subject)) {
1395
+ return result;
1396
+ }
1397
+ result.blocked = true;
1398
+ result.reasons.push("SPOOFING_ATTACK");
1399
+ result.score += 12;
1400
+ result.category = "SPOOFING";
1401
+ return result;
1402
+ }
1403
+ function isAutoReply(getHeader) {
1404
+ const autoSubmitted = getHeader("auto-submitted");
1405
+ if (autoSubmitted && autoSubmitted !== "no") {
1406
+ return true;
1407
+ }
1408
+ const autoResponseSuppress = getHeader("x-auto-response-suppress");
1409
+ if (autoResponseSuppress) {
1410
+ return true;
1411
+ }
1412
+ const precedence = getHeader("precedence");
1413
+ if (precedence && ["bulk", "junk", "list", "auto_reply"].includes(precedence.toLowerCase())) {
1414
+ return true;
1415
+ }
1416
+ if (getHeader("list-unsubscribe")) {
1417
+ return true;
1418
+ }
1419
+ return false;
1420
+ }
1421
+ function checkSubjectLine(subject) {
1422
+ const reasons = [];
1423
+ let score = 0;
1424
+ for (const pattern of SPAM_PATTERNS.subjectPatterns) {
1425
+ if (pattern.test(subject)) {
1426
+ const match = subject.match(pattern);
1427
+ reasons.push(`SUBJECT_SPAM_PATTERN: ${match[0]}`);
1428
+ score += 1;
1429
+ }
1430
+ }
1431
+ const upperCount = (subject.match(/[A-Z]/g) || []).length;
1432
+ const letterCount = (subject.match(/[a-zA-Z]/g) || []).length;
1433
+ if (letterCount > 10 && upperCount / letterCount > 0.7) {
1434
+ reasons.push("SUBJECT_ALL_CAPS");
1435
+ score += 2;
1436
+ }
1437
+ const punctCount = (subject.match(/[!?$]/g) || []).length;
1438
+ if (punctCount >= 3) {
1439
+ reasons.push("SUBJECT_EXCESSIVE_PUNCTUATION");
1440
+ score += 1;
1441
+ }
1442
+ if (/^(re|fw|fwd):/i.test(subject) && subject.length < 20) {
1443
+ reasons.push("SUBJECT_FAKE_REPLY");
1444
+ score += 1;
1445
+ }
1446
+ return { score, reasons };
1447
+ }
1448
+ function checkBodyContent(text, html) {
1449
+ const reasons = [];
1450
+ let score = 0;
1451
+ const content = text || html || "";
1452
+ const contentLower = content.toLowerCase();
1453
+ for (const pattern of SPAM_PATTERNS.bodyPatterns) {
1454
+ if (pattern.test(content)) {
1455
+ const match = content.match(pattern);
1456
+ reasons.push(`BODY_SPAM_PATTERN: ${match[0].slice(0, 50)}`);
1457
+ score += 1;
1458
+ }
1459
+ }
1460
+ for (const [keyword, weight] of SPAM_KEYWORDS) {
1461
+ if (contentLower.includes(keyword.toLowerCase())) {
1462
+ reasons.push(`SPAM_KEYWORD: ${keyword}`);
1463
+ score += weight;
1464
+ }
1465
+ }
1466
+ if (html) {
1467
+ if (/color:\s*#fff|color:\s*white|font-size:\s*[01]px/i.test(html)) {
1468
+ reasons.push("HIDDEN_TEXT");
1469
+ score += 3;
1470
+ }
1471
+ const imgCount = (html.match(/<img/gi) || []).length;
1472
+ const textLength = (text || "").length;
1473
+ if (imgCount > 5 && textLength < 100) {
1474
+ reasons.push("IMAGE_HEAVY_LOW_TEXT");
1475
+ score += 2;
1476
+ }
1477
+ }
1478
+ if (/data:image\/[^;]+;base64,/i.test(html || "")) {
1479
+ reasons.push("BASE64_IMAGES");
1480
+ score += 1;
1481
+ }
1482
+ const shortenerPatterns = /\b(bit\.ly|tinyurl|goo\.gl|t\.co|ow\.ly|is\.gd|buff\.ly|adf\.ly|j\.mp)\b/i;
1483
+ if (shortenerPatterns.test(content)) {
1484
+ reasons.push("URL_SHORTENER");
1485
+ score += 2;
1486
+ }
1487
+ return { score, reasons };
1488
+ }
1489
+ function checkSenderPatterns(from, replyTo) {
1490
+ const reasons = [];
1491
+ let score = 0;
1492
+ if (!from) {
1493
+ reasons.push("MISSING_FROM");
1494
+ score += 2;
1495
+ return { score, reasons };
1496
+ }
1497
+ for (const pattern of SPAM_PATTERNS.senderPatterns) {
1498
+ if (pattern.test(from)) {
1499
+ reasons.push("SUSPICIOUS_SENDER_PATTERN");
1500
+ score += 2;
1501
+ break;
1502
+ }
1503
+ }
1504
+ const tldMatch = from.match(/@[^.]+\.([a-z]+)$/i);
1505
+ if (tldMatch && SUSPICIOUS_TLDS.has(tldMatch[1].toLowerCase())) {
1506
+ reasons.push(`SUSPICIOUS_TLD: ${tldMatch[1]}`);
1507
+ score += 2;
1508
+ }
1509
+ if (replyTo && from) {
1510
+ const fromDomain = from.split("@")[1]?.toLowerCase();
1511
+ const replyDomain = replyTo.split("@")[1]?.toLowerCase();
1512
+ if (fromDomain && replyDomain && fromDomain !== replyDomain) {
1513
+ reasons.push("FROM_REPLY_TO_MISMATCH");
1514
+ score += 2;
1515
+ }
1516
+ }
1517
+ const spoofPatterns = /^(paypal|amazon|apple|microsoft|google|bank|security)/i;
1518
+ if (spoofPatterns.test(from) && !/@(paypal|amazon|apple|microsoft|google)\.com$/i.test(from)) {
1519
+ reasons.push("DISPLAY_NAME_SPOOFING");
1520
+ score += 3;
1521
+ }
1522
+ return { score, reasons };
1523
+ }
1524
+ function checkHeaderAnomalies(parsed, getHeader) {
1525
+ const reasons = [];
1526
+ let score = 0;
1527
+ if (!parsed.messageId && !getHeader("message-id")) {
1528
+ reasons.push("MISSING_MESSAGE_ID");
1529
+ score += 1;
1530
+ }
1531
+ if (parsed.date) {
1532
+ const messageDate = new Date(parsed.date);
1533
+ const now = /* @__PURE__ */ new Date();
1534
+ if (messageDate > now) {
1535
+ const hoursDiff = (messageDate - now) / (1e3 * 60 * 60);
1536
+ if (hoursDiff > 24) {
1537
+ reasons.push("FUTURE_DATE");
1538
+ score += 2;
1539
+ }
1540
+ }
1541
+ const daysDiff = (now - messageDate) / (1e3 * 60 * 60 * 24);
1542
+ if (daysDiff > 365) {
1543
+ reasons.push("VERY_OLD_DATE");
1544
+ score += 1;
1545
+ }
1546
+ } else {
1547
+ reasons.push("MISSING_DATE");
1548
+ score += 1;
1549
+ }
1550
+ const xMailer = getHeader("x-mailer") || "";
1551
+ if (xMailer) {
1552
+ const suspiciousMailers = /mass mail|bulk mail|email blast/i;
1553
+ if (suspiciousMailers.test(xMailer)) {
1554
+ reasons.push("SUSPICIOUS_MAILER");
1555
+ score += 1;
1556
+ }
1557
+ }
1558
+ const mimeVersion = getHeader("mime-version");
1559
+ if (!mimeVersion && (parsed.html || parsed.attachments?.length > 0)) {
1560
+ reasons.push("MISSING_MIME_VERSION");
1561
+ score += 1;
1562
+ }
1563
+ const toCount = parsed.to?.value?.length || 0;
1564
+ const ccCount = parsed.cc?.value?.length || 0;
1565
+ if (toCount + ccCount > 50) {
1566
+ reasons.push("EXCESSIVE_RECIPIENTS");
1567
+ score += 2;
1568
+ }
1569
+ return { score, reasons };
1570
+ }
1571
+ function checkSuspiciousLinks(content) {
1572
+ const reasons = [];
1573
+ let score = 0;
1574
+ const urlPattern = /https?:\/\/[^\s<>"']+/gi;
1575
+ const urls = content.match(urlPattern) || [];
1576
+ if (urls.length === 0) {
1577
+ return { score, reasons };
1578
+ }
1579
+ const suspiciousUrls = /* @__PURE__ */ new Set();
1580
+ for (const url of urls) {
1581
+ try {
1582
+ const parsed = new URL(url);
1583
+ const hostname = parsed.hostname.toLowerCase();
1584
+ if (/^(?:\d+\.){3}\d+$/.test(hostname)) {
1585
+ suspiciousUrls.add("IP_ADDRESS_URL");
1586
+ }
1587
+ const tld = hostname.split(".").pop();
1588
+ if (SUSPICIOUS_TLDS.has(tld)) {
1589
+ suspiciousUrls.add(`SUSPICIOUS_URL_TLD: ${tld}`);
1590
+ }
1591
+ if (parsed.port && !["80", "443", ""].includes(parsed.port)) {
1592
+ suspiciousUrls.add("URL_WITH_PORT");
1593
+ }
1594
+ if (url.length > 200) {
1595
+ suspiciousUrls.add("VERY_LONG_URL");
1596
+ }
1597
+ const subdomainCount = hostname.split(".").length - 2;
1598
+ if (subdomainCount > 3) {
1599
+ suspiciousUrls.add("EXCESSIVE_SUBDOMAINS");
1600
+ }
1601
+ if (/%[\da-f]{2}/i.test(url) && /%[\da-f]{2}.*%[\da-f]{2}/i.test(url)) {
1602
+ suspiciousUrls.add("URL_OBFUSCATION");
1603
+ }
1604
+ } catch {
1605
+ suspiciousUrls.add("INVALID_URL");
1606
+ }
1607
+ }
1608
+ for (const reason of suspiciousUrls) {
1609
+ reasons.push(reason);
1610
+ score += 1;
1611
+ }
1612
+ const linkPattern = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi;
1613
+ let match;
1614
+ while ((match = linkPattern.exec(content)) !== null) {
1615
+ const href = match[1];
1616
+ const text = match[2];
1617
+ if (/^https?:\/\//i.test(text)) {
1618
+ try {
1619
+ const textUrl = new URL(text);
1620
+ const hrefUrl = new URL(href);
1621
+ if (textUrl.hostname.toLowerCase() !== hrefUrl.hostname.toLowerCase()) {
1622
+ reasons.push("LINK_TEXT_URL_MISMATCH");
1623
+ score += 3;
1624
+ break;
1625
+ }
1626
+ } catch {
1627
+ }
1628
+ }
1629
+ }
1630
+ return { score, reasons };
1631
+ }
1632
+ function getRootDomain(hostname) {
1633
+ if (!hostname) {
1634
+ return "";
1635
+ }
1636
+ const parts = hostname.toLowerCase().split(".");
1637
+ if (parts.length <= 2) {
1638
+ return hostname.toLowerCase();
1639
+ }
1640
+ const multiPartTlds = ["co.uk", "com.au", "co.nz", "co.jp", "com.br", "co.in"];
1641
+ const lastTwo = parts.slice(-2).join(".");
1642
+ if (multiPartTlds.includes(lastTwo)) {
1643
+ return parts.slice(-3).join(".");
1644
+ }
1645
+ return parts.slice(-2).join(".");
1646
+ }
1647
+ function parseHostFromAddress(address) {
1648
+ if (!address) {
1649
+ return "";
1650
+ }
1651
+ const atIndex = address.indexOf("@");
1652
+ if (atIndex === -1) {
1653
+ return "";
1654
+ }
1655
+ return address.slice(atIndex + 1).toLowerCase();
1656
+ }
1657
+ function extractClientHostname(parsed) {
1658
+ let receivedHeaders = null;
1659
+ if (parsed.headers?.get) {
1660
+ receivedHeaders = parsed.headers.get("received");
1661
+ } else if (parsed.headerLines) {
1662
+ const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
1663
+ receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
1664
+ }
1665
+ if (!receivedHeaders) {
1666
+ return null;
1667
+ }
1668
+ const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
1669
+ if (!received) {
1670
+ return null;
1671
+ }
1672
+ const fromMatch = received.match(/from\s+([^\s(]+)/i);
1673
+ if (fromMatch) {
1674
+ return fromMatch[1].toLowerCase();
1675
+ }
1676
+ return null;
1677
+ }
1678
+ function extractRemoteIp(parsed) {
1679
+ let receivedHeaders = null;
1680
+ if (parsed.headers?.get) {
1681
+ receivedHeaders = parsed.headers.get("received");
1682
+ } else if (parsed.headerLines) {
1683
+ const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
1684
+ receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
1685
+ }
1686
+ if (!receivedHeaders) {
1687
+ return null;
1688
+ }
1689
+ const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
1690
+ if (!received) {
1691
+ return null;
1692
+ }
1693
+ const ipv4Match = received.match(/\[((?:\d+\.){3}\d+)]/);
1694
+ if (ipv4Match) {
1695
+ return ipv4Match[1];
1696
+ }
1697
+ const ipv6Match = received.match(/\[([a-f\d:]+)]/i);
1698
+ if (ipv6Match) {
1699
+ return ipv6Match[1];
1700
+ }
1701
+ return null;
1702
+ }
1703
+
1704
+ // src/get-attributes.js
1705
+ import { debuglog as debuglog4 } from "node:util";
1706
+ var debug4 = debuglog4("spamscanner:attributes");
1707
+ function checkSRS(address) {
1708
+ if (!address) {
1709
+ return "";
1710
+ }
1711
+ const srs0Match = address.match(/^srs0=[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
1712
+ if (srs0Match) {
1713
+ return `${srs0Match[3]}@${srs0Match[2]}`;
1714
+ }
1715
+ const srs1Match = address.match(/^srs1=[^=]+=[^=]+==[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
1716
+ if (srs1Match) {
1717
+ return `${srs1Match[3]}@${srs1Match[2]}`;
1718
+ }
1719
+ return address;
1720
+ }
1721
+ function parseHostFromDomainOrAddress(addressOrDomain) {
1722
+ if (!addressOrDomain) {
1723
+ return "";
1724
+ }
1725
+ const atIndex = addressOrDomain.indexOf("@");
1726
+ if (atIndex !== -1) {
1727
+ return addressOrDomain.slice(atIndex + 1).toLowerCase();
1728
+ }
1729
+ return addressOrDomain.toLowerCase();
1730
+ }
1731
+ function parseRootDomain(hostname) {
1732
+ if (!hostname) {
1733
+ return "";
1734
+ }
1735
+ const parts = hostname.toLowerCase().split(".");
1736
+ if (parts.length <= 2) {
1737
+ return hostname.toLowerCase();
1738
+ }
1739
+ const multiPartTlds = /* @__PURE__ */ new Set([
1740
+ "co.uk",
1741
+ "com.au",
1742
+ "co.nz",
1743
+ "co.jp",
1744
+ "com.br",
1745
+ "co.in",
1746
+ "org.uk",
1747
+ "net.au",
1748
+ "com.mx",
1749
+ "com.cn",
1750
+ "com.tw",
1751
+ "com.hk",
1752
+ "co.za",
1753
+ "com.sg"
1754
+ ]);
1755
+ const lastTwo = parts.slice(-2).join(".");
1756
+ if (multiPartTlds.has(lastTwo)) {
1757
+ return parts.slice(-3).join(".");
1758
+ }
1759
+ return parts.slice(-2).join(".");
1760
+ }
1761
+ function parseAddresses(headerValue) {
1762
+ if (!headerValue) {
1763
+ return [];
1764
+ }
1765
+ if (Array.isArray(headerValue)) {
1766
+ return headerValue.flatMap((item) => {
1767
+ if (typeof item === "string") {
1768
+ return item;
1769
+ }
1770
+ if (item.address) {
1771
+ return item.address;
1772
+ }
1773
+ if (item.value && Array.isArray(item.value)) {
1774
+ return item.value.map((v) => v.address).filter(Boolean);
1775
+ }
1776
+ return null;
1777
+ }).filter(Boolean);
1778
+ }
1779
+ if (headerValue.value && Array.isArray(headerValue.value)) {
1780
+ return headerValue.value.map((v) => v.address).filter(Boolean);
1781
+ }
1782
+ if (typeof headerValue === "string") {
1783
+ const emailPattern = /[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi;
1784
+ return headerValue.match(emailPattern) || [];
1785
+ }
1786
+ return [];
1787
+ }
1788
+ function getHeaders(headers, name) {
1789
+ if (!headers) {
1790
+ return null;
1791
+ }
1792
+ if (headers.get) {
1793
+ const value = headers.get(name);
1794
+ if (value) {
1795
+ if (typeof value === "string") {
1796
+ return value;
1797
+ }
1798
+ if (value.text) {
1799
+ return value.text;
1800
+ }
1801
+ if (value.value && Array.isArray(value.value)) {
1802
+ return value.value.map((v) => v.address || v.text || v).join(", ");
1803
+ }
1804
+ }
1805
+ return null;
1806
+ }
1807
+ if (headers.headerLines) {
1808
+ const header = headers.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
1809
+ if (header) {
1810
+ return header.line?.split(":").slice(1).join(":").trim();
1811
+ }
1812
+ }
1813
+ if (typeof headers === "object") {
1814
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
1815
+ if (key) {
1816
+ const value = headers[key];
1817
+ if (typeof value === "string") {
1818
+ return value;
1819
+ }
1820
+ if (Array.isArray(value)) {
1821
+ return value[0];
1822
+ }
1823
+ }
1824
+ }
1825
+ return null;
1826
+ }
1827
+ async function getAttributes(parsed, session = {}, options = {}) {
1828
+ const { isAligned = false, authResults = null } = options;
1829
+ const headers = parsed.headers || parsed;
1830
+ const replyToHeader = getHeaders(headers, "reply-to");
1831
+ const replyToAddresses = parseAddresses(parsed.replyTo || (replyToHeader ? { value: [{ address: replyToHeader }] } : null));
1832
+ const array = [
1833
+ session.resolvedClientHostname,
1834
+ session.resolvedRootClientHostname,
1835
+ session.remoteAddress
1836
+ ];
1837
+ const from = [
1838
+ session.originalFromAddress,
1839
+ session.originalFromAddressDomain,
1840
+ session.originalFromAddressRootDomain
1841
+ ];
1842
+ const replyTo = [];
1843
+ for (const addr of replyToAddresses) {
1844
+ const checked = checkSRS(addr);
1845
+ replyTo.push(
1846
+ checked.toLowerCase(),
1847
+ parseHostFromDomainOrAddress(checked),
1848
+ parseRootDomain(parseHostFromDomainOrAddress(checked))
1849
+ );
1850
+ }
1851
+ const mailFrom = [];
1852
+ const mailFromAddress = session.envelope?.mailFrom?.address;
1853
+ if (mailFromAddress) {
1854
+ const checked = checkSRS(mailFromAddress);
1855
+ mailFrom.push(
1856
+ checked.toLowerCase(),
1857
+ parseHostFromDomainOrAddress(checked),
1858
+ parseRootDomain(parseHostFromDomainOrAddress(checked))
1859
+ );
1860
+ }
1861
+ if (isAligned) {
1862
+ const signingDomains = session.signingDomains || /* @__PURE__ */ new Set();
1863
+ const spfResult = session.spfFromHeader?.status?.result;
1864
+ const fromHasSpfPass = spfResult === "pass";
1865
+ const fromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(session.originalFromAddressDomain) || signingDomains.has(session.originalFromAddressRootDomain));
1866
+ if (fromHasSpfPass || fromHasDkimAlignment) {
1867
+ array.push(...from);
1868
+ }
1869
+ let hasAlignedReplyTo = false;
1870
+ for (const addr of replyToAddresses) {
1871
+ const checked = checkSRS(addr);
1872
+ const domain = parseHostFromDomainOrAddress(checked);
1873
+ const rootDomain = parseRootDomain(domain);
1874
+ if (signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain))) {
1875
+ hasAlignedReplyTo = true;
1876
+ break;
1877
+ }
1878
+ if (authResults?.spf) {
1879
+ const spfForReplyTo = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
1880
+ if (spfForReplyTo?.result === "pass") {
1881
+ hasAlignedReplyTo = true;
1882
+ break;
1883
+ }
1884
+ }
1885
+ }
1886
+ if (hasAlignedReplyTo) {
1887
+ array.push(...replyTo);
1888
+ }
1889
+ if (mailFromAddress) {
1890
+ const checked = checkSRS(mailFromAddress);
1891
+ const domain = parseHostFromDomainOrAddress(checked);
1892
+ const rootDomain = parseRootDomain(domain);
1893
+ const mailFromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain));
1894
+ let mailFromHasSpfPass = false;
1895
+ if (authResults?.spf) {
1896
+ const spfForMailFrom = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
1897
+ mailFromHasSpfPass = spfForMailFrom?.result === "pass";
1898
+ }
1899
+ if (mailFromHasDkimAlignment || mailFromHasSpfPass) {
1900
+ array.push(...mailFrom);
1901
+ }
1902
+ }
1903
+ } else {
1904
+ array.push(...from, ...replyTo, ...mailFrom);
1905
+ }
1906
+ const normalized = array.filter((string_) => typeof string_ === "string" && string_.length > 0).map((string_) => {
1907
+ try {
1908
+ return string_.toLowerCase().trim();
1909
+ } catch {
1910
+ return string_.toLowerCase().trim();
1911
+ }
1912
+ });
1913
+ const unique = [...new Set(normalized)];
1914
+ debug4("Extracted %d unique attributes (isAligned=%s): %o", unique.length, isAligned, unique);
1915
+ return unique;
1916
+ }
1917
+ function buildSessionFromParsed(parsed, existingSession = {}) {
1918
+ const session = { ...existingSession };
1919
+ const headers = parsed.headers || parsed;
1920
+ const fromHeader = getHeaders(headers, "from");
1921
+ const fromAddresses = parseAddresses(parsed.from || fromHeader);
1922
+ const fromAddress = fromAddresses[0];
1923
+ if (fromAddress && !session.originalFromAddress) {
1924
+ session.originalFromAddress = checkSRS(fromAddress).toLowerCase();
1925
+ session.originalFromAddressDomain = parseHostFromDomainOrAddress(session.originalFromAddress);
1926
+ session.originalFromAddressRootDomain = parseRootDomain(session.originalFromAddressDomain);
1927
+ }
1928
+ if (!session.resolvedClientHostname) {
1929
+ const receivedHeader = getHeaders(headers, "received");
1930
+ if (receivedHeader) {
1931
+ const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
1932
+ const fromMatch = received?.match(/from\s+([^\s(]+)/i);
1933
+ if (fromMatch) {
1934
+ session.resolvedClientHostname = fromMatch[1].toLowerCase();
1935
+ session.resolvedRootClientHostname = parseRootDomain(session.resolvedClientHostname);
1936
+ }
1937
+ }
1938
+ }
1939
+ if (!session.remoteAddress) {
1940
+ const receivedHeader = getHeaders(headers, "received");
1941
+ if (receivedHeader) {
1942
+ const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
1943
+ const ipv4Match = received?.match(/\[((?:\d+\.){3}\d+)]/);
1944
+ if (ipv4Match) {
1945
+ session.remoteAddress = ipv4Match[1];
1946
+ } else {
1947
+ const ipv6Match = received?.match(/\[([a-f\d:]+)]/i);
1948
+ if (ipv6Match) {
1949
+ session.remoteAddress = ipv6Match[1];
1950
+ }
1951
+ }
1952
+ }
1953
+ }
1954
+ if (!session.envelope) {
1955
+ session.envelope = {
1956
+ mailFrom: { address: session.originalFromAddress || "" },
1957
+ rcptTo: []
1958
+ };
1959
+ const toAddresses = parseAddresses(parsed.to || getHeaders(headers, "to"));
1960
+ const ccAddresses = parseAddresses(parsed.cc || getHeaders(headers, "cc"));
1961
+ for (const addr of [...toAddresses, ...ccAddresses]) {
1962
+ if (addr) {
1963
+ session.envelope.rcptTo.push({ address: addr });
1964
+ }
1965
+ }
1966
+ }
1967
+ return session;
1968
+ }
1969
+ async function extractAttributes(parsed, options = {}) {
1970
+ const { isAligned = false, senderIp, senderHostname, authResults } = options;
1971
+ const session = buildSessionFromParsed(parsed, {
1972
+ remoteAddress: senderIp,
1973
+ resolvedClientHostname: senderHostname,
1974
+ resolvedRootClientHostname: senderHostname ? parseRootDomain(senderHostname) : void 0
1975
+ });
1976
+ if (authResults?.dkim) {
1977
+ session.signingDomains = /* @__PURE__ */ new Set();
1978
+ for (const dkimResult of authResults.dkim) {
1979
+ if (dkimResult.result === "pass" && dkimResult.domain) {
1980
+ session.signingDomains.add(dkimResult.domain);
1981
+ session.signingDomains.add(parseRootDomain(dkimResult.domain));
1982
+ }
1983
+ }
1984
+ session.hadAlignedAndPassingDKIM = session.signingDomains.has(session.originalFromAddressDomain) || session.signingDomains.has(session.originalFromAddressRootDomain);
1985
+ }
1986
+ if (authResults?.spf) {
1987
+ const spfForFrom = authResults.spf.find((r) => r.domain === session.originalFromAddressDomain || r.domain === session.originalFromAddressRootDomain);
1988
+ if (spfForFrom) {
1989
+ session.spfFromHeader = {
1990
+ status: { result: spfForFrom.result }
1991
+ };
1992
+ }
1993
+ }
1994
+ const attributes = await getAttributes(parsed, session, { isAligned, authResults });
1995
+ return { attributes, session };
1996
+ }
1997
+
1998
+ // src/index.js
1999
+ var __filename = import.meta.url ? fileURLToPath(import.meta.url) : "";
2000
+ var __dirname = __filename ? path.dirname(__filename) : process.cwd();
2001
+ var findPackageRoot = (startDir) => {
2002
+ let dir = startDir;
2003
+ while (dir !== path.dirname(dir)) {
2004
+ if (fs.existsSync(path.join(dir, "package.json"))) {
2005
+ return dir;
2006
+ }
2007
+ dir = path.dirname(dir);
2008
+ }
2009
+ return startDir;
2010
+ };
2011
+ var packageRoot = findPackageRoot(__dirname);
2012
+ var executablesData = JSON.parse(fs.readFileSync(path.join(packageRoot, "executables.json"), "utf8"));
561
2013
  var EXECUTABLES = new Set(executablesData);
562
2014
  var getReplacements = async () => {
563
2015
  const { default: replacements2 } = await Promise.resolve().then(() => (init_replacements(), replacements_exports));
@@ -567,7 +2019,7 @@ var getClassifier = async () => {
567
2019
  const { default: classifier2 } = await Promise.resolve().then(() => (init_get_classifier(), get_classifier_exports));
568
2020
  return classifier2;
569
2021
  };
570
- var debug3 = debuglog3("spamscanner");
2022
+ var debug7 = debuglog7("spamscanner");
571
2023
  var GENERIC_TOKENIZER = /[^a-zá-úÁ-Úà-úÀ-Úñü\dа-яёæøåàáảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđäöëïîûœçążśźęćńł-]+/i;
572
2024
  var converter = new AFHConvert();
573
2025
  var chineseTokenizer = { tokenize: (text) => text.split(/\s+/) };
@@ -653,6 +2105,41 @@ var SpamScanner = class {
653
2105
  supportedLanguages: ["en"],
654
2106
  enableMixedLanguageDetection: false,
655
2107
  enableAdvancedPatternRecognition: true,
2108
+ // Authentication options (mailauth)
2109
+ enableAuthentication: false,
2110
+ authOptions: {
2111
+ ip: null,
2112
+ // Remote IP address (required for auth)
2113
+ helo: null,
2114
+ // HELO/EHLO hostname
2115
+ mta: "spamscanner",
2116
+ // MTA hostname
2117
+ sender: null,
2118
+ // Envelope sender (MAIL FROM)
2119
+ timeout: 1e4
2120
+ // DNS lookup timeout
2121
+ },
2122
+ authScoreWeights: {
2123
+ dkimPass: -2,
2124
+ dkimFail: 3,
2125
+ spfPass: -1,
2126
+ spfFail: 2,
2127
+ spfSoftfail: 1,
2128
+ dmarcPass: -2,
2129
+ dmarcFail: 4,
2130
+ arcPass: -1,
2131
+ arcFail: 1
2132
+ },
2133
+ // Reputation API options (Forward Email)
2134
+ enableReputation: false,
2135
+ reputationOptions: {
2136
+ apiUrl: "https://api.forwardemail.net/v1/reputation",
2137
+ timeout: 1e4,
2138
+ onlyAligned: true
2139
+ },
2140
+ // Arbitrary spam detection options
2141
+ enableArbitraryDetection: true,
2142
+ arbitraryThreshold: 5,
656
2143
  // Existing options
657
2144
  debug: false,
658
2145
  logger: console,
@@ -700,7 +2187,7 @@ var SpamScanner = class {
700
2187
  return Array.isArray(tokens) ? tokens : [];
701
2188
  };
702
2189
  } catch (error) {
703
- debug3("Failed to initialize classifier:", error);
2190
+ debug7("Failed to initialize classifier:", error);
704
2191
  this.classifier = new NaiveBayes2();
705
2192
  }
706
2193
  }
@@ -719,7 +2206,7 @@ var SpamScanner = class {
719
2206
  throw new Error("Invalid replacements format");
720
2207
  }
721
2208
  } catch (error) {
722
- debug3("Failed to initialize replacements:", error);
2209
+ debug7("Failed to initialize replacements:", error);
723
2210
  this.replacements = /* @__PURE__ */ new Map();
724
2211
  const basicReplacements = {
725
2212
  u: "you",
@@ -747,7 +2234,7 @@ var SpamScanner = class {
747
2234
  try {
748
2235
  this.clamscan = await new ClamScan().init(this.config.clamscan);
749
2236
  } catch (error) {
750
- debug3("ClamScan initialization failed:", error);
2237
+ debug7("ClamScan initialization failed:", error);
751
2238
  return [];
752
2239
  }
753
2240
  }
@@ -771,7 +2258,7 @@ var SpamScanner = class {
771
2258
  }
772
2259
  }
773
2260
  } catch (error) {
774
- debug3("Virus scan error:", error);
2261
+ debug7("Virus scan error:", error);
775
2262
  }
776
2263
  }
777
2264
  return results;
@@ -894,7 +2381,7 @@ var SpamScanner = class {
894
2381
  });
895
2382
  }
896
2383
  } catch (error) {
897
- debug3("PDF JavaScript detection error:", error);
2384
+ debug7("PDF JavaScript detection error:", error);
898
2385
  }
899
2386
  }
900
2387
  }
@@ -1137,7 +2624,7 @@ var SpamScanner = class {
1137
2624
  isPrivate: parsed.isPrivate
1138
2625
  };
1139
2626
  } catch (error) {
1140
- debug3("tldts parsing error:", error);
2627
+ debug7("tldts parsing error:", error);
1141
2628
  return null;
1142
2629
  }
1143
2630
  }
@@ -1229,13 +2716,39 @@ var SpamScanner = class {
1229
2716
  }
1230
2717
  return text;
1231
2718
  }
1232
- // Main scan method - enhanced with performance metrics and new features
1233
- async scan(source) {
2719
+ // Main scan method - enhanced with performance metrics, auth, and reputation
2720
+ async scan(source, scanOptions = {}) {
1234
2721
  const startTime = Date.now();
1235
2722
  try {
1236
2723
  await this.initializeClassifier();
1237
2724
  await this.initializeReplacements();
1238
2725
  const { tokens, mail } = await this.getTokensAndMailFromSource(source);
2726
+ const authOptions = { ...this.config.authOptions, ...scanOptions.authOptions };
2727
+ const reputationOptions = { ...this.config.reputationOptions, ...scanOptions.reputationOptions };
2728
+ const detectionPromises = [
2729
+ this.getClassification(tokens),
2730
+ this.getPhishingResults(mail),
2731
+ this.getExecutableResults(mail),
2732
+ this.config.enableMacroDetection ? this.getMacroResults(mail) : [],
2733
+ this.config.enableArbitraryDetection ? this.getArbitraryResults(mail, { remoteAddress: authOptions.ip, resolvedClientHostname: authOptions.hostname }) : [],
2734
+ this.getVirusResults(mail),
2735
+ this.getPatternResults(mail),
2736
+ this.getIDNHomographResults(mail),
2737
+ this.getToxicityResults(mail),
2738
+ this.getNSFWResults(mail)
2739
+ ];
2740
+ const enableAuth = scanOptions.enableAuthentication ?? this.config.enableAuthentication;
2741
+ if (enableAuth && authOptions.ip) {
2742
+ detectionPromises.push(this.getAuthenticationResults(source, mail, authOptions));
2743
+ } else {
2744
+ detectionPromises.push(Promise.resolve(null));
2745
+ }
2746
+ const enableReputation = scanOptions.enableReputation ?? this.config.enableReputation;
2747
+ if (enableReputation) {
2748
+ detectionPromises.push(this.getReputationResults(mail, authOptions, reputationOptions));
2749
+ } else {
2750
+ detectionPromises.push(Promise.resolve(null));
2751
+ }
1239
2752
  const [
1240
2753
  classification,
1241
2754
  phishing,
@@ -1246,20 +2759,14 @@ var SpamScanner = class {
1246
2759
  patterns,
1247
2760
  idnHomographAttack,
1248
2761
  toxicity,
1249
- nsfw
1250
- ] = await Promise.all([
1251
- this.getClassification(tokens),
1252
- this.getPhishingResults(mail),
1253
- this.getExecutableResults(mail),
1254
- this.config.enableMacroDetection ? this.getMacroResults(mail) : [],
1255
- this.getArbitraryResults(mail),
1256
- this.getVirusResults(mail),
1257
- this.getPatternResults(mail),
1258
- this.getIDNHomographResults(mail),
1259
- this.getToxicityResults(mail),
1260
- this.getNSFWResults(mail)
1261
- ]);
1262
- const 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;
2762
+ nsfw,
2763
+ authResult,
2764
+ reputationResult
2765
+ ] = await Promise.all(detectionPromises);
2766
+ 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;
2767
+ if (reputationResult && reputationResult.isDenylisted) {
2768
+ isSpam = true;
2769
+ }
1263
2770
  let message = "Ham";
1264
2771
  if (isSpam) {
1265
2772
  const reasons = [];
@@ -1293,7 +2800,14 @@ var SpamScanner = class {
1293
2800
  if (nsfw.length > 0) {
1294
2801
  reasons.push("NSFW content");
1295
2802
  }
2803
+ if (reputationResult?.isDenylisted) {
2804
+ reasons.push("denylisted sender");
2805
+ }
1296
2806
  message = `Spam (${arrayJoinConjunction(reasons)})`;
2807
+ } else if (reputationResult?.isTruthSource) {
2808
+ message = "Ham (truth source)";
2809
+ } else if (reputationResult?.isAllowlisted) {
2810
+ message = "Ham (allowlisted)";
1297
2811
  }
1298
2812
  const endTime = Date.now();
1299
2813
  const processingTime = endTime - startTime;
@@ -1313,7 +2827,9 @@ var SpamScanner = class {
1313
2827
  patterns,
1314
2828
  idnHomographAttack,
1315
2829
  toxicity,
1316
- nsfw
2830
+ nsfw,
2831
+ authentication: authResult,
2832
+ reputation: reputationResult
1317
2833
  },
1318
2834
  links: this.extractAllUrls(mail, source),
1319
2835
  tokens,
@@ -1335,10 +2851,85 @@ var SpamScanner = class {
1335
2851
  }
1336
2852
  return result;
1337
2853
  } catch (error) {
1338
- debug3("Scan error:", error);
2854
+ debug7("Scan error:", error);
1339
2855
  throw error;
1340
2856
  }
1341
2857
  }
2858
+ // Get authentication results using mailauth
2859
+ async getAuthenticationResults(source, mail, options = {}) {
2860
+ try {
2861
+ const messageBuffer = typeof source === "string" ? Buffer3.from(source) : source;
2862
+ const sender = options.sender || mail.from?.value?.[0]?.address || mail.from?.text;
2863
+ const authResult = await authenticate(messageBuffer, {
2864
+ ip: options.ip,
2865
+ helo: options.helo,
2866
+ mta: options.mta || "spamscanner",
2867
+ sender,
2868
+ timeout: options.timeout || 1e4
2869
+ });
2870
+ const scoreResult = calculateAuthScore(authResult, this.config.authScoreWeights);
2871
+ return {
2872
+ ...authResult,
2873
+ score: scoreResult,
2874
+ authResultsHeader: formatAuthResultsHeader(authResult, options.mta || "spamscanner")
2875
+ };
2876
+ } catch (error) {
2877
+ debug7("Authentication error:", error);
2878
+ return null;
2879
+ }
2880
+ }
2881
+ // Get reputation results from Forward Email API
2882
+ // Uses get-attributes module to extract comprehensive attributes for checking
2883
+ async getReputationResults(mail, authOptions = {}, reputationOptions = {}) {
2884
+ try {
2885
+ const { attributes, session } = await extractAttributes(mail, {
2886
+ isAligned: reputationOptions.onlyAligned ?? true,
2887
+ senderIp: authOptions.ip,
2888
+ senderHostname: authOptions.hostname,
2889
+ authResults: authOptions.authResults
2890
+ });
2891
+ const valuesToCheck = [...attributes];
2892
+ if (authOptions.sender) {
2893
+ const senderLower = authOptions.sender.toLowerCase();
2894
+ if (!valuesToCheck.includes(senderLower)) {
2895
+ valuesToCheck.push(senderLower);
2896
+ }
2897
+ const envelopeDomain = senderLower.split("@")[1];
2898
+ if (envelopeDomain && !valuesToCheck.includes(envelopeDomain)) {
2899
+ valuesToCheck.push(envelopeDomain);
2900
+ }
2901
+ }
2902
+ const replyTo = mail.replyTo?.value || [];
2903
+ for (const addr of replyTo) {
2904
+ if (addr.address) {
2905
+ const addrLower = addr.address.toLowerCase();
2906
+ if (!valuesToCheck.includes(addrLower)) {
2907
+ valuesToCheck.push(addrLower);
2908
+ }
2909
+ const domain = addrLower.split("@")[1];
2910
+ if (domain && !valuesToCheck.includes(domain)) {
2911
+ valuesToCheck.push(domain);
2912
+ }
2913
+ }
2914
+ }
2915
+ if (valuesToCheck.length === 0) {
2916
+ return null;
2917
+ }
2918
+ debug7("Checking reputation for %d attributes: %o", valuesToCheck.length, valuesToCheck);
2919
+ const resultsMap = await checkReputationBatch(valuesToCheck, reputationOptions);
2920
+ const aggregated = aggregateReputationResults([...resultsMap.values()]);
2921
+ return {
2922
+ ...aggregated,
2923
+ checkedValues: valuesToCheck,
2924
+ details: Object.fromEntries(resultsMap),
2925
+ session
2926
+ // Include session info for debugging
2927
+ };
2928
+ } catch (error) {
2929
+ debug7("Reputation check error:", error);
2930
+ return null;
2931
+ }
2932
+ }
1342
2933
  // Get pattern recognition results
1343
2934
  async getPatternResults(mail) {
1344
2935
  const results = [];
@@ -1375,7 +2966,7 @@ var SpamScanner = class {
1375
2966
  try {
1376
2967
  mail = await simpleParser(source);
1377
2968
  } catch (error) {
1378
- debug3("Mail parsing error:", error);
2969
+ debug7("Mail parsing error:", error);
1379
2970
  mail = {
1380
2971
  text: source,
1381
2972
  html: "",
@@ -1406,7 +2997,7 @@ var SpamScanner = class {
1406
2997
  // Default probability
1407
2998
  };
1408
2999
  } catch (error) {
1409
- debug3("Classification error:", error);
3000
+ debug7("Classification error:", error);
1410
3001
  return {
1411
3002
  category: "ham",
1412
3003
  probability: 0.5
@@ -1462,7 +3053,7 @@ var SpamScanner = class {
1462
3053
  }
1463
3054
  }
1464
3055
  } catch (error) {
1465
- debug3("Phishing check error:", error);
3056
+ debug7("Phishing check error:", error);
1466
3057
  }
1467
3058
  }
1468
3059
  return results;
@@ -1506,14 +3097,15 @@ var SpamScanner = class {
1506
3097
  });
1507
3098
  }
1508
3099
  } catch (error) {
1509
- debug3("File type detection error:", error);
3100
+ debug7("File type detection error:", error);
1510
3101
  }
1511
3102
  }
1512
3103
  }
1513
3104
  return results;
1514
3105
  }
1515
- // Arbitrary results (GTUBE, etc.)
1516
- async getArbitraryResults(mail) {
3106
+ // Arbitrary results (GTUBE, spam patterns, etc.)
3107
+ // Updated to use session info for Microsoft Exchange spam detection
3108
+ async getArbitraryResults(mail, sessionInfo = {}) {
1517
3109
  const results = [];
1518
3110
  let content = (mail.text || "") + (mail.html || "");
1519
3111
  if (mail.headerLines && Array.isArray(mail.headerLines)) {
@@ -1526,9 +3118,42 @@ var SpamScanner = class {
1526
3118
  if (content.includes("XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X")) {
1527
3119
  results.push({
1528
3120
  type: "arbitrary",
1529
- description: "GTUBE spam test pattern detected"
3121
+ subtype: "gtube",
3122
+ description: "GTUBE spam test pattern detected",
3123
+ score: 100
1530
3124
  });
1531
3125
  }
3126
+ try {
3127
+ const session = buildSessionInfo(mail, sessionInfo);
3128
+ const arbitraryResult = isArbitrary(mail, {
3129
+ threshold: this.config.arbitraryThreshold || 5,
3130
+ checkSubject: true,
3131
+ checkBody: true,
3132
+ checkSender: true,
3133
+ checkHeaders: true,
3134
+ checkLinks: true,
3135
+ checkMicrosoftHeaders: true,
3136
+ // Enable Microsoft Exchange spam detection
3137
+ checkVendorSpam: true,
3138
+ // Enable vendor-specific spam detection
3139
+ checkSpoofing: true,
3140
+ // Enable spoofing attack detection
3141
+ session
3142
+ // Pass session info for advanced checks
3143
+ });
3144
+ if (arbitraryResult.isArbitrary) {
3145
+ results.push({
3146
+ type: "arbitrary",
3147
+ subtype: arbitraryResult.category ? arbitraryResult.category.toLowerCase() : "pattern",
3148
+ description: `Arbitrary spam patterns detected (score: ${arbitraryResult.score})`,
3149
+ score: arbitraryResult.score,
3150
+ reasons: arbitraryResult.reasons,
3151
+ category: arbitraryResult.category
3152
+ });
3153
+ }
3154
+ } catch (error) {
3155
+ debug7("Arbitrary detection error:", error);
3156
+ }
1532
3157
  return results;
1533
3158
  }
1534
3159
  // Parse and normalize locale
@@ -1594,7 +3219,7 @@ var SpamScanner = class {
1594
3219
  result.riskScore = Math.max(result.riskScore, analysis.riskScore);
1595
3220
  }
1596
3221
  } catch (error) {
1597
- debug3("IDN analysis error for URL:", url, error);
3222
+ debug7("IDN analysis error for URL:", url, error);
1598
3223
  }
1599
3224
  }
1600
3225
  if (result.detected) {
@@ -1611,7 +3236,7 @@ var SpamScanner = class {
1611
3236
  result.details.push(...allRiskFactors);
1612
3237
  }
1613
3238
  } catch (error) {
1614
- debug3("IDN homograph detection error:", error);
3239
+ debug7("IDN homograph detection error:", error);
1615
3240
  }
1616
3241
  return result;
1617
3242
  }
@@ -1627,7 +3252,7 @@ var SpamScanner = class {
1627
3252
  enableContextAnalysis: true
1628
3253
  });
1629
3254
  } catch (error) {
1630
- debug3("Failed to load IDN detector:", error);
3255
+ debug7("Failed to load IDN detector:", error);
1631
3256
  return null;
1632
3257
  }
1633
3258
  }
@@ -1679,7 +3304,7 @@ var SpamScanner = class {
1679
3304
  }
1680
3305
  return detected;
1681
3306
  } catch (error) {
1682
- debug3("Language detection error:", error);
3307
+ debug7("Language detection error:", error);
1683
3308
  try {
1684
3309
  const landeResult = lande(text);
1685
3310
  if (landeResult && landeResult.length > 0) {
@@ -1740,7 +3365,7 @@ var SpamScanner = class {
1740
3365
  }
1741
3366
  }
1742
3367
  } catch (error) {
1743
- debug3("Toxicity detection error:", error);
3368
+ debug7("Toxicity detection error:", error);
1744
3369
  }
1745
3370
  return results;
1746
3371
  }
@@ -1790,11 +3415,11 @@ var SpamScanner = class {
1790
3415
  }
1791
3416
  }
1792
3417
  } catch (error) {
1793
- debug3("NSFW detection error for attachment:", attachment.filename, error);
3418
+ debug7("NSFW detection error for attachment:", attachment.filename, error);
1794
3419
  }
1795
3420
  }
1796
3421
  } catch (error) {
1797
- debug3("NSFW detection error:", error);
3422
+ debug7("NSFW detection error:", error);
1798
3423
  }
1799
3424
  return results;
1800
3425
  }