spamscanner 6.0.1 → 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,6 +556,1446 @@ 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";
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
558
1999
  var __filename = import.meta.url ? fileURLToPath(import.meta.url) : "";
559
2000
  var __dirname = __filename ? path.dirname(__filename) : process.cwd();
560
2001
  var findPackageRoot = (startDir) => {
@@ -578,7 +2019,7 @@ var getClassifier = async () => {
578
2019
  const { default: classifier2 } = await Promise.resolve().then(() => (init_get_classifier(), get_classifier_exports));
579
2020
  return classifier2;
580
2021
  };
581
- var debug3 = debuglog3("spamscanner");
2022
+ var debug7 = debuglog7("spamscanner");
582
2023
  var GENERIC_TOKENIZER = /[^a-zá-úÁ-Úà-úÀ-Úñü\dа-яёæøåàáảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđäöëïîûœçążśźęćńł-]+/i;
583
2024
  var converter = new AFHConvert();
584
2025
  var chineseTokenizer = { tokenize: (text) => text.split(/\s+/) };
@@ -664,6 +2105,41 @@ var SpamScanner = class {
664
2105
  supportedLanguages: ["en"],
665
2106
  enableMixedLanguageDetection: false,
666
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,
667
2143
  // Existing options
668
2144
  debug: false,
669
2145
  logger: console,
@@ -711,7 +2187,7 @@ var SpamScanner = class {
711
2187
  return Array.isArray(tokens) ? tokens : [];
712
2188
  };
713
2189
  } catch (error) {
714
- debug3("Failed to initialize classifier:", error);
2190
+ debug7("Failed to initialize classifier:", error);
715
2191
  this.classifier = new NaiveBayes2();
716
2192
  }
717
2193
  }
@@ -730,7 +2206,7 @@ var SpamScanner = class {
730
2206
  throw new Error("Invalid replacements format");
731
2207
  }
732
2208
  } catch (error) {
733
- debug3("Failed to initialize replacements:", error);
2209
+ debug7("Failed to initialize replacements:", error);
734
2210
  this.replacements = /* @__PURE__ */ new Map();
735
2211
  const basicReplacements = {
736
2212
  u: "you",
@@ -758,7 +2234,7 @@ var SpamScanner = class {
758
2234
  try {
759
2235
  this.clamscan = await new ClamScan().init(this.config.clamscan);
760
2236
  } catch (error) {
761
- debug3("ClamScan initialization failed:", error);
2237
+ debug7("ClamScan initialization failed:", error);
762
2238
  return [];
763
2239
  }
764
2240
  }
@@ -782,7 +2258,7 @@ var SpamScanner = class {
782
2258
  }
783
2259
  }
784
2260
  } catch (error) {
785
- debug3("Virus scan error:", error);
2261
+ debug7("Virus scan error:", error);
786
2262
  }
787
2263
  }
788
2264
  return results;
@@ -905,7 +2381,7 @@ var SpamScanner = class {
905
2381
  });
906
2382
  }
907
2383
  } catch (error) {
908
- debug3("PDF JavaScript detection error:", error);
2384
+ debug7("PDF JavaScript detection error:", error);
909
2385
  }
910
2386
  }
911
2387
  }
@@ -1148,7 +2624,7 @@ var SpamScanner = class {
1148
2624
  isPrivate: parsed.isPrivate
1149
2625
  };
1150
2626
  } catch (error) {
1151
- debug3("tldts parsing error:", error);
2627
+ debug7("tldts parsing error:", error);
1152
2628
  return null;
1153
2629
  }
1154
2630
  }
@@ -1240,13 +2716,39 @@ var SpamScanner = class {
1240
2716
  }
1241
2717
  return text;
1242
2718
  }
1243
- // Main scan method - enhanced with performance metrics and new features
1244
- async scan(source) {
2719
+ // Main scan method - enhanced with performance metrics, auth, and reputation
2720
+ async scan(source, scanOptions = {}) {
1245
2721
  const startTime = Date.now();
1246
2722
  try {
1247
2723
  await this.initializeClassifier();
1248
2724
  await this.initializeReplacements();
1249
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
+ }
1250
2752
  const [
1251
2753
  classification,
1252
2754
  phishing,
@@ -1257,20 +2759,14 @@ var SpamScanner = class {
1257
2759
  patterns,
1258
2760
  idnHomographAttack,
1259
2761
  toxicity,
1260
- nsfw
1261
- ] = await Promise.all([
1262
- this.getClassification(tokens),
1263
- this.getPhishingResults(mail),
1264
- this.getExecutableResults(mail),
1265
- this.config.enableMacroDetection ? this.getMacroResults(mail) : [],
1266
- this.getArbitraryResults(mail),
1267
- this.getVirusResults(mail),
1268
- this.getPatternResults(mail),
1269
- this.getIDNHomographResults(mail),
1270
- this.getToxicityResults(mail),
1271
- this.getNSFWResults(mail)
1272
- ]);
1273
- 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
+ }
1274
2770
  let message = "Ham";
1275
2771
  if (isSpam) {
1276
2772
  const reasons = [];
@@ -1304,7 +2800,14 @@ var SpamScanner = class {
1304
2800
  if (nsfw.length > 0) {
1305
2801
  reasons.push("NSFW content");
1306
2802
  }
2803
+ if (reputationResult?.isDenylisted) {
2804
+ reasons.push("denylisted sender");
2805
+ }
1307
2806
  message = `Spam (${arrayJoinConjunction(reasons)})`;
2807
+ } else if (reputationResult?.isTruthSource) {
2808
+ message = "Ham (truth source)";
2809
+ } else if (reputationResult?.isAllowlisted) {
2810
+ message = "Ham (allowlisted)";
1308
2811
  }
1309
2812
  const endTime = Date.now();
1310
2813
  const processingTime = endTime - startTime;
@@ -1324,7 +2827,9 @@ var SpamScanner = class {
1324
2827
  patterns,
1325
2828
  idnHomographAttack,
1326
2829
  toxicity,
1327
- nsfw
2830
+ nsfw,
2831
+ authentication: authResult,
2832
+ reputation: reputationResult
1328
2833
  },
1329
2834
  links: this.extractAllUrls(mail, source),
1330
2835
  tokens,
@@ -1346,10 +2851,85 @@ var SpamScanner = class {
1346
2851
  }
1347
2852
  return result;
1348
2853
  } catch (error) {
1349
- debug3("Scan error:", error);
2854
+ debug7("Scan error:", error);
1350
2855
  throw error;
1351
2856
  }
1352
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
+ }
1353
2933
  // Get pattern recognition results
1354
2934
  async getPatternResults(mail) {
1355
2935
  const results = [];
@@ -1386,7 +2966,7 @@ var SpamScanner = class {
1386
2966
  try {
1387
2967
  mail = await simpleParser(source);
1388
2968
  } catch (error) {
1389
- debug3("Mail parsing error:", error);
2969
+ debug7("Mail parsing error:", error);
1390
2970
  mail = {
1391
2971
  text: source,
1392
2972
  html: "",
@@ -1417,7 +2997,7 @@ var SpamScanner = class {
1417
2997
  // Default probability
1418
2998
  };
1419
2999
  } catch (error) {
1420
- debug3("Classification error:", error);
3000
+ debug7("Classification error:", error);
1421
3001
  return {
1422
3002
  category: "ham",
1423
3003
  probability: 0.5
@@ -1473,7 +3053,7 @@ var SpamScanner = class {
1473
3053
  }
1474
3054
  }
1475
3055
  } catch (error) {
1476
- debug3("Phishing check error:", error);
3056
+ debug7("Phishing check error:", error);
1477
3057
  }
1478
3058
  }
1479
3059
  return results;
@@ -1517,14 +3097,15 @@ var SpamScanner = class {
1517
3097
  });
1518
3098
  }
1519
3099
  } catch (error) {
1520
- debug3("File type detection error:", error);
3100
+ debug7("File type detection error:", error);
1521
3101
  }
1522
3102
  }
1523
3103
  }
1524
3104
  return results;
1525
3105
  }
1526
- // Arbitrary results (GTUBE, etc.)
1527
- 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 = {}) {
1528
3109
  const results = [];
1529
3110
  let content = (mail.text || "") + (mail.html || "");
1530
3111
  if (mail.headerLines && Array.isArray(mail.headerLines)) {
@@ -1537,9 +3118,42 @@ var SpamScanner = class {
1537
3118
  if (content.includes("XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X")) {
1538
3119
  results.push({
1539
3120
  type: "arbitrary",
1540
- description: "GTUBE spam test pattern detected"
3121
+ subtype: "gtube",
3122
+ description: "GTUBE spam test pattern detected",
3123
+ score: 100
1541
3124
  });
1542
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
+ }
1543
3157
  return results;
1544
3158
  }
1545
3159
  // Parse and normalize locale
@@ -1605,7 +3219,7 @@ var SpamScanner = class {
1605
3219
  result.riskScore = Math.max(result.riskScore, analysis.riskScore);
1606
3220
  }
1607
3221
  } catch (error) {
1608
- debug3("IDN analysis error for URL:", url, error);
3222
+ debug7("IDN analysis error for URL:", url, error);
1609
3223
  }
1610
3224
  }
1611
3225
  if (result.detected) {
@@ -1622,7 +3236,7 @@ var SpamScanner = class {
1622
3236
  result.details.push(...allRiskFactors);
1623
3237
  }
1624
3238
  } catch (error) {
1625
- debug3("IDN homograph detection error:", error);
3239
+ debug7("IDN homograph detection error:", error);
1626
3240
  }
1627
3241
  return result;
1628
3242
  }
@@ -1638,7 +3252,7 @@ var SpamScanner = class {
1638
3252
  enableContextAnalysis: true
1639
3253
  });
1640
3254
  } catch (error) {
1641
- debug3("Failed to load IDN detector:", error);
3255
+ debug7("Failed to load IDN detector:", error);
1642
3256
  return null;
1643
3257
  }
1644
3258
  }
@@ -1690,7 +3304,7 @@ var SpamScanner = class {
1690
3304
  }
1691
3305
  return detected;
1692
3306
  } catch (error) {
1693
- debug3("Language detection error:", error);
3307
+ debug7("Language detection error:", error);
1694
3308
  try {
1695
3309
  const landeResult = lande(text);
1696
3310
  if (landeResult && landeResult.length > 0) {
@@ -1751,7 +3365,7 @@ var SpamScanner = class {
1751
3365
  }
1752
3366
  }
1753
3367
  } catch (error) {
1754
- debug3("Toxicity detection error:", error);
3368
+ debug7("Toxicity detection error:", error);
1755
3369
  }
1756
3370
  return results;
1757
3371
  }
@@ -1801,11 +3415,11 @@ var SpamScanner = class {
1801
3415
  }
1802
3416
  }
1803
3417
  } catch (error) {
1804
- debug3("NSFW detection error for attachment:", attachment.filename, error);
3418
+ debug7("NSFW detection error for attachment:", attachment.filename, error);
1805
3419
  }
1806
3420
  }
1807
3421
  } catch (error) {
1808
- debug3("NSFW detection error:", error);
3422
+ debug7("NSFW detection error:", error);
1809
3423
  }
1810
3424
  return results;
1811
3425
  }