From ff013fe694e9f41541e74e77bc945cf7cb012012 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 16:18:10 +0700 Subject: [PATCH 01/62] refactor: handle DNS whitelists Introduce a single DNSListChecker struct with flags to avoid code duplication with already existing RBL checker. --- api/openapi.yaml | 13 ++ internal/config/config.go | 2 + pkg/analyzer/analyzer.go | 5 +- pkg/analyzer/rbl.go | 165 ++++++++++---------- pkg/analyzer/rbl_test.go | 8 +- pkg/analyzer/report.go | 16 +- pkg/analyzer/report_test.go | 10 +- web/src/lib/components/WhitelistCard.svelte | 62 ++++++++ web/src/lib/components/index.ts | 1 + web/src/routes/test/[test]/+page.svelte | 52 ++++-- 10 files changed, 226 insertions(+), 108 deletions(-) create mode 100644 web/src/lib/components/WhitelistCard.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 5c628fd..f724ae6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -350,6 +350,19 @@ components: listed: false - rbl: "bl.spamcop.net" listed: false + whitelists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their DNS whitelist check results (informational only) + example: + "192.0.2.1": + - rbl: "list.dnswl.org" + listed: false + - rbl: "swl.spamhaus.org" + listed: false content_analysis: $ref: '#/components/schemas/ContentAnalysis' header_analysis: diff --git a/internal/config/config.go b/internal/config/config.go index 4a335c9..468a2aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,7 @@ type AnalysisConfig struct { DNSTimeout time.Duration HTTPTimeout time.Duration RBLs []string + DNSWLs []string CheckAllIPs bool // Check all IPs found in headers, not just the first one } @@ -88,6 +89,7 @@ func DefaultConfig() *Config { DNSTimeout: 5 * time.Second, HTTPTimeout: 10 * time.Second, RBLs: []string{}, + DNSWLs: []string{}, CheckAllIPs: false, // By default, only check the first IP }, } diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index e7ae561..83eafe6 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -44,6 +44,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, + cfg.Analysis.DNSWLs, cfg.Analysis.CheckAllIPs, ) @@ -130,12 +131,12 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int // Calculate score using the existing function // Create a minimal RBLResults structure for scoring - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ip: checks}, IPsChecked: []string{ip}, ListedCount: listedCount, } - score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results) + score, grade := a.analyzer.generator.rblChecker.CalculateScore(results) return checks, listedCount, score, grade, nil } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index ff0e813..44c6e99 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -32,13 +32,15 @@ import ( "git.happydns.org/happyDeliver/internal/api" ) -// RBLChecker checks IP addresses against DNS-based blacklists -type RBLChecker struct { +// DNSListChecker checks IP addresses against DNS-based block/allow lists. +// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags. +type DNSListChecker struct { Timeout time.Duration - RBLs []string + Lists []string CheckAllIPs bool // Check all IPs found in headers, not just the first one + filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors resolver *net.Resolver - informationalSet map[string]bool + informationalSet map[string]bool // Lists whose hits don't count toward the score } // DefaultRBLs is a list of commonly used RBL providers @@ -68,10 +70,16 @@ var DefaultInformationalRBLs = []string{ "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring } +// DefaultDNSWLs is a list of commonly used DNSWL providers +var DefaultDNSWLs = []string{ + "list.dnswl.org", // DNSWL.org — the main DNS whitelist + "swl.spamhaus.org", // Spamhaus Safe Whitelist +} + // NewRBLChecker creates a new RBL checker with configurable timeout and RBL list -func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker { +func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker { if timeout == 0 { - timeout = 5 * time.Second // Default timeout + timeout = 5 * time.Second } if len(rbls) == 0 { rbls = DefaultRBLs @@ -80,30 +88,48 @@ func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLC for _, rbl := range DefaultInformationalRBLs { informationalSet[rbl] = true } - return &RBLChecker{ + return &DNSListChecker{ Timeout: timeout, - RBLs: rbls, + Lists: rbls, CheckAllIPs: checkAllIPs, + filterErrorCodes: true, resolver: &net.Resolver{PreferGo: true}, informationalSet: informationalSet, } } -// RBLResults represents the results of RBL checks -type RBLResults struct { - Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP - IPsChecked []string - ListedCount int // Total listings including informational RBLs - RelevantListedCount int // Listings on scoring (non-informational) RBLs only +// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list +func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker { + if timeout == 0 { + timeout = 5 * time.Second + } + if len(dnswls) == 0 { + dnswls = DefaultDNSWLs + } + return &DNSListChecker{ + Timeout: timeout, + Lists: dnswls, + CheckAllIPs: checkAllIPs, + filterErrorCodes: false, + resolver: &net.Resolver{PreferGo: true}, + informationalSet: make(map[string]bool), + } } -// CheckEmail checks all IPs found in the email headers against RBLs -func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { - results := &RBLResults{ +// DNSListResults represents the results of DNS list checks +type DNSListResults struct { + Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP + IPsChecked []string + ListedCount int // Total listings including informational entries + RelevantListedCount int // Listings on scoring (non-informational) lists only +} + +// CheckEmail checks all IPs found in the email headers against the configured lists +func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults { + results := &DNSListResults{ Checks: make(map[string][]api.BlacklistCheck), } - // Extract IPs from Received headers ips := r.extractIPs(email) if len(ips) == 0 { return results @@ -111,20 +137,18 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { results.IPsChecked = ips - // Check each IP against all RBLs for _, ip := range ips { - for _, rbl := range r.RBLs { - check := r.checkIP(ip, rbl) + for _, list := range r.Lists { + check := r.checkIP(ip, list) results.Checks[ip] = append(results.Checks[ip], check) if check.Listed { results.ListedCount++ - if !r.informationalSet[rbl] { + if !r.informationalSet[list] { results.RelevantListedCount++ } } } - // Only check the first IP unless CheckAllIPs is enabled if !r.CheckAllIPs { break } @@ -133,9 +157,8 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { return results } -// CheckIP checks a single IP address against all configured RBLs -func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { - // Validate that it's a valid IP address +// CheckIP checks a single IP address against all configured lists +func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { if !r.isPublicIP(ip) { return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip) } @@ -143,9 +166,8 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { var checks []api.BlacklistCheck listedCount := 0 - // Check the IP against all RBLs - for _, rbl := range r.RBLs { - check := r.checkIP(ip, rbl) + for _, list := range r.Lists { + check := r.checkIP(ip, list) checks = append(checks, check) if check.Listed { listedCount++ @@ -156,27 +178,19 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { } // extractIPs extracts IP addresses from Received headers -func (r *RBLChecker) extractIPs(email *EmailMessage) []string { +func (r *DNSListChecker) extractIPs(email *EmailMessage) []string { var ips []string seenIPs := make(map[string]bool) - // Get all Received headers receivedHeaders := email.Header["Received"] - - // Regex patterns for IP addresses - // Match IPv4: xxx.xxx.xxx.xxx ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) - // Look for IPs in Received headers for _, received := range receivedHeaders { - // Find all IPv4 addresses matches := ipv4Pattern.FindAllString(received, -1) for _, match := range matches { - // Skip private/reserved IPs if !r.isPublicIP(match) { continue } - // Avoid duplicates if !seenIPs[match] { ips = append(ips, match) seenIPs[match] = true @@ -184,13 +198,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } } - // If no IPs found in Received headers, try X-Originating-IP if len(ips) == 0 { originatingIP := email.Header.Get("X-Originating-IP") if originatingIP != "" { - // Extract IP from formats like "[192.0.2.1]" or "192.0.2.1" cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]") - // Remove any whitespace cleanIP = strings.TrimSpace(cleanIP) matches := ipv4Pattern.FindString(cleanIP) if matches != "" && r.isPublicIP(matches) { @@ -203,19 +214,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } // isPublicIP checks if an IP address is public (not private, loopback, or reserved) -func (r *RBLChecker) isPublicIP(ipStr string) bool { +func (r *DNSListChecker) isPublicIP(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } - // Check if it's a private network if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return false } - // Additional checks for reserved ranges - // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3) if ip.IsUnspecified() { return false } @@ -223,51 +231,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool { return true } -// checkIP checks a single IP against a single RBL -func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { +// checkIP checks a single IP against a single DNS list +func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck { check := api.BlacklistCheck{ - Rbl: rbl, + Rbl: list, } - // Reverse the IP for DNSBL query reversedIP := r.reverseIP(ip) if reversedIP == "" { check.Error = api.PtrTo("Failed to reverse IP address") return check } - // Construct DNSBL query: reversed-ip.rbl-domain - query := fmt.Sprintf("%s.%s", reversedIP, rbl) + query := fmt.Sprintf("%s.%s", reversedIP, list) - // Perform DNS lookup with timeout ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) defer cancel() addrs, err := r.resolver.LookupHost(ctx, query) if err != nil { - // Most likely not listed (NXDOMAIN) if dnsErr, ok := err.(*net.DNSError); ok { if dnsErr.IsNotFound { check.Listed = false return check } } - // Other DNS errors check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) return check } - // If we got a response, check the return code if len(addrs) > 0 { - check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2) + check.Response = api.PtrTo(addrs[0]) - // Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255 - // These indicate RBL operational issues, not actual listings - if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" { + // In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings. + if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") { check.Listed = false - check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0])) + check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0])) } else { - // Normal listing response check.Listed = true } } @@ -275,50 +275,47 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { return check } -// reverseIP reverses an IPv4 address for DNSBL queries +// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries // Example: 192.0.2.1 -> 1.2.0.192 -func (r *RBLChecker) reverseIP(ipStr string) string { +func (r *DNSListChecker) reverseIP(ipStr string) string { ip := net.ParseIP(ipStr) if ip == nil { return "" } - // Convert to IPv4 ipv4 := ip.To4() if ipv4 == nil { return "" // IPv6 not supported yet } - // Reverse the octets return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// CalculateRBLScore calculates the blacklist contribution to deliverability. -// Informational RBLs are not counted in the score. -func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) { +// CalculateScore calculates the list contribution to deliverability. +// Informational lists are not counted in the score. +func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) { if results == nil || len(results.IPsChecked) == 0 { - // No IPs to check, give benefit of doubt return 100, "" } - scoringRBLCount := len(r.RBLs) - len(r.informationalSet) - if scoringRBLCount <= 0 { + scoringListCount := len(r.Lists) - len(r.informationalSet) + if scoringListCount <= 0 { return 100, "A+" } - percentage := 100 - results.RelevantListedCount*100/scoringRBLCount + percentage := 100 - results.RelevantListedCount*100/scoringListCount return percentage, ScoreToGrade(percentage) } -// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL -func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { +// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry +func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string { var listedIPs []string - for ip, rblChecks := range results.Checks { - for _, check := range rblChecks { + for ip, checks := range results.Checks { + for _, check := range checks { if check.Listed { listedIPs = append(listedIPs, ip) - break // Only add the IP once + break } } } @@ -326,17 +323,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { return listedIPs } -// GetRBLsForIP returns all RBLs that list a specific IP -func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { - var rbls []string +// GetListsForIP returns all lists that match a specific IP +func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string { + var lists []string - if rblChecks, exists := results.Checks[ip]; exists { - for _, check := range rblChecks { + if checks, exists := results.Checks[ip]; exists { + for _, check := range checks { if check.Listed { - rbls = append(rbls, check.Rbl) + lists = append(lists, check.Rbl) } } } - return rbls + return lists } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index a1de270..86f3c95 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) { if checker.Timeout != tt.expectedTimeout { t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout) } - if len(checker.RBLs) != tt.expectedRBLs { - t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs) + if len(checker.Lists) != tt.expectedRBLs { + t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs) } if checker.resolver == nil { t.Error("Resolver should not be nil") @@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := checker.CalculateRBLScore(tt.results) + score, _ := checker.CalculateScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } @@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rbls := checker.GetRBLsForIP(results, tt.ip) + rbls := checker.GetListsForIP(results, tt.ip) if len(rbls) != len(tt.expectedRBLs) { t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs)) diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index dc420fb..bd12960 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -35,7 +35,8 @@ type ReportGenerator struct { spamAnalyzer *SpamAssassinAnalyzer rspamdAnalyzer *RspamdAnalyzer dnsAnalyzer *DNSAnalyzer - rblChecker *RBLChecker + rblChecker *DNSListChecker + dnswlChecker *DNSListChecker contentAnalyzer *ContentAnalyzer headerAnalyzer *HeaderAnalyzer } @@ -45,6 +46,7 @@ func NewReportGenerator( dnsTimeout time.Duration, httpTimeout time.Duration, rbls []string, + dnswls []string, checkAllIPs bool, ) *ReportGenerator { return &ReportGenerator{ @@ -53,6 +55,7 @@ func NewReportGenerator( rspamdAnalyzer: NewRspamdAnalyzer(), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), + dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs), contentAnalyzer: NewContentAnalyzer(httpTimeout), headerAnalyzer: NewHeaderAnalyzer(), } @@ -65,7 +68,8 @@ type AnalysisResults struct { Content *ContentResults DNS *api.DNSResults Headers *api.HeaderAnalysis - RBL *RBLResults + RBL *DNSListResults + DNSWL *DNSListResults SpamAssassin *api.SpamAssassinResult Rspamd *api.RspamdResult } @@ -81,6 +85,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) + results.DNSWL = r.dnswlChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email) results.Content = r.contentAnalyzer.AnalyzeContent(email) @@ -135,7 +140,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu blacklistScore := 0 var blacklistGrade string if results.RBL != nil { - blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL) + blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL) } saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) @@ -197,6 +202,11 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu report.Blacklists = &results.RBL.Checks } + // Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only) + if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 { + report.Whitelists = &results.DNSWL.Checks + } + // Add SpamAssassin result with individual deliverability score if results.SpamAssassin != nil { saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade) diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 5a325b1..82e923e 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -32,7 +32,7 @@ import ( ) func TestNewReportGenerator(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) if gen == nil { t.Fatal("Expected report generator, got nil") } @@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) { } func TestAnalyzeEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) email := createTestEmail() @@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) { } func TestGenerateReport(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) testID := uuid.New() email := createTestEmail() @@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) { } func TestGenerateReportWithSpamAssassin(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) testID := uuid.New() email := createTestEmailWithSpamAssassin() @@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) { } func TestGenerateRawEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) tests := []struct { name string diff --git a/web/src/lib/components/WhitelistCard.svelte b/web/src/lib/components/WhitelistCard.svelte new file mode 100644 index 0000000..ee0b0e2 --- /dev/null +++ b/web/src/lib/components/WhitelistCard.svelte @@ -0,0 +1,62 @@ + + +
+
+

+ + + Whitelist Checks + + Informational +

+
+
+

+ DNS whitelists identify trusted senders. Being listed here is a positive signal, but has + no impact on the overall score. +

+ +
+ {#each Object.entries(whitelists) as [ip, checks]} +
+
+ + {ip} +
+ + + {#each checks as check} + + + + + {/each} + +
+ + {check.error + ? "Error" + : check.listed + ? "Listed" + : "Not listed"} + + {check.rbl}
+
+ {/each} +
+
+
diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index d577399..8ed409c 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -24,3 +24,4 @@ export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; export { default as SummaryCard } from "./SummaryCard.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte"; +export { default as WhitelistCard } from "./WhitelistCard.svelte"; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index c5add96..0c8ea9d 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -3,7 +3,7 @@ import { onDestroy } from "svelte"; import { getReport, getTest, reanalyzeReport } from "$lib/api"; - import type { Report, Test } from "$lib/api/types.gen"; + import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen"; import { AuthenticationCard, BlacklistCard, @@ -17,8 +17,11 @@ SpamAssassinCard, SummaryCard, TinySurvey, + WhitelistCard, } from "$lib/components"; + type BlacklistRecords = Record; + let testId = $derived(page.params.test); let test = $state(null); let report = $state(null); @@ -321,17 +324,46 @@ {/if} - {#if report.blacklists && Object.keys(report.blacklists).length > 0} -
-
- + {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} + + {/snippet} + + + {#snippet whitelistChecks(whitelists: BlacklistRecords)} + + {/snippet} + + + {#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {@render whitelistChecks(report.whitelists)}
+ {:else} + {#if report.blacklists && Object.keys(report.blacklists).length > 0} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {/if} + + {#if report.whitelists && Object.keys(report.whitelists).length > 0} +
+
+ {@render whitelistChecks(report.whitelists)} +
+
+ {/if} {/if} From c7dc3577e42bb8decde65f9a44e812e8a939d5dd Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 16:26:40 +0700 Subject: [PATCH 02/62] fix: various small fixes and improvements - Add 'skipped' to authentication result enum in OpenAPI spec - Fix optional chaining on bimiResult.details check - Add rbls field to AppConfig interface - Restrict theme storage to valid 'light'/'dark' values only - Fix null coalescing for blacklist result data - Fix survey source to use domain instead of ip --- api/openapi.yaml | 2 +- web/src/lib/components/SummaryCard.svelte | 2 +- web/src/lib/stores/config.ts | 1 + web/src/lib/stores/theme.ts | 2 +- web/src/routes/blacklist/[ip]/+page.svelte | 2 +- web/src/routes/domain/[domain]/+page.svelte | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f724ae6..c0c3c70 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -789,7 +789,7 @@ components: properties: result: type: string - enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass] + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] description: Authentication result example: "pass" domain: diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index fe8af8e..5d93513 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -331,7 +331,7 @@ highlight: { color: "good", bold: true }, link: "#dns-bimi", }); - if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) { + if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) { segments.push({ text: " declined to participate" }); } else if (bimiResult?.result === "fail") { segments.push({ text: " but " }); diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts index 87662ba..c393dd2 100644 --- a/web/src/lib/stores/config.ts +++ b/web/src/lib/stores/config.ts @@ -25,6 +25,7 @@ interface AppConfig { report_retention?: number; survey_url?: string; custom_logo_url?: string; + rbls?: string[]; } const defaultConfig: AppConfig = { diff --git a/web/src/lib/stores/theme.ts b/web/src/lib/stores/theme.ts index 362202b..ea24293 100644 --- a/web/src/lib/stores/theme.ts +++ b/web/src/lib/stores/theme.ts @@ -26,7 +26,7 @@ const getInitialTheme = () => { if (!browser) return "light"; const stored = localStorage.getItem("theme"); - if (stored) return stored; + if (stored === "light" || stored === "dark") return stored; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; diff --git a/web/src/routes/blacklist/[ip]/+page.svelte b/web/src/routes/blacklist/[ip]/+page.svelte index 180bfde..0cddb22 100644 --- a/web/src/routes/blacklist/[ip]/+page.svelte +++ b/web/src/routes/blacklist/[ip]/+page.svelte @@ -28,7 +28,7 @@ }); if (response.response.ok) { - result = response.data; + result = response.data ?? null; } else if (response.error) { error = response.error.message || "Failed to check IP address"; } diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte index e191192..d866e21 100644 --- a/web/src/routes/domain/[domain]/+page.svelte +++ b/web/src/routes/domain/[domain]/+page.svelte @@ -130,7 +130,7 @@
From def0596d92299a53affd4e12b7a5bcc664bcaf8a Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 8 Mar 2026 12:09:51 +0000 Subject: [PATCH 03/62] chore(deps): update dependency @sveltejs/kit to v2.53.3 [security] --- web/package-lock.json | 57 ++++++++++--------------------------------- 1 file changed, 13 insertions(+), 44 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 835218b..77da83d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1250,25 +1250,23 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.50.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz", - "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==", + "version": "2.53.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.3.tgz", + "integrity": "sha512-tshOeBUid2v5LAblUpatIdFm5Cyykbw2EiKWOunAAX0A/oJaR7DOdC9wLR5Qqh9zUf3QUISA2m9A3suBdQSYQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.6.2", + "devalue": "^5.6.3", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", + "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "bin": { @@ -1279,10 +1277,10 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -1299,7 +1297,6 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -1378,7 +1375,6 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1428,7 +1424,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1774,7 +1769,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2275,9 +2269,9 @@ "license": "MIT" }, "node_modules/devalue": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", - "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", + "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", "dev": true, "license": "MIT" }, @@ -2362,7 +2356,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3368,7 +3361,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3408,7 +3400,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3542,7 +3533,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3694,9 +3684,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", "dev": true, "license": "MIT" }, @@ -3824,7 +3814,6 @@ "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4006,7 +3995,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4083,7 +4071,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4342,24 +4329,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From 55e9bcd3d043988cb7edb061f9661e40e4f00f9c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 16:18:10 +0700 Subject: [PATCH 04/62] refactor: handle DNS whitelists Introduce a single DNSListChecker struct with flags to avoid code duplication with already existing RBL checker. --- api/openapi.yaml | 13 ++ internal/config/config.go | 2 + pkg/analyzer/analyzer.go | 5 +- pkg/analyzer/rbl.go | 165 ++++++++++---------- pkg/analyzer/rbl_test.go | 26 +-- pkg/analyzer/report.go | 16 +- pkg/analyzer/report_test.go | 10 +- web/src/lib/components/WhitelistCard.svelte | 62 ++++++++ web/src/lib/components/index.ts | 1 + web/src/routes/test/[test]/+page.svelte | 52 ++++-- 10 files changed, 235 insertions(+), 117 deletions(-) create mode 100644 web/src/lib/components/WhitelistCard.svelte diff --git a/api/openapi.yaml b/api/openapi.yaml index 5c628fd..f724ae6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -350,6 +350,19 @@ components: listed: false - rbl: "bl.spamcop.net" listed: false + whitelists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their DNS whitelist check results (informational only) + example: + "192.0.2.1": + - rbl: "list.dnswl.org" + listed: false + - rbl: "swl.spamhaus.org" + listed: false content_analysis: $ref: '#/components/schemas/ContentAnalysis' header_analysis: diff --git a/internal/config/config.go b/internal/config/config.go index 4a335c9..468a2aa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,7 @@ type AnalysisConfig struct { DNSTimeout time.Duration HTTPTimeout time.Duration RBLs []string + DNSWLs []string CheckAllIPs bool // Check all IPs found in headers, not just the first one } @@ -88,6 +89,7 @@ func DefaultConfig() *Config { DNSTimeout: 5 * time.Second, HTTPTimeout: 10 * time.Second, RBLs: []string{}, + DNSWLs: []string{}, CheckAllIPs: false, // By default, only check the first IP }, } diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index e7ae561..83eafe6 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -44,6 +44,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { cfg.Analysis.DNSTimeout, cfg.Analysis.HTTPTimeout, cfg.Analysis.RBLs, + cfg.Analysis.DNSWLs, cfg.Analysis.CheckAllIPs, ) @@ -130,12 +131,12 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, int, int // Calculate score using the existing function // Create a minimal RBLResults structure for scoring - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ip: checks}, IPsChecked: []string{ip}, ListedCount: listedCount, } - score, grade := a.analyzer.generator.rblChecker.CalculateRBLScore(results) + score, grade := a.analyzer.generator.rblChecker.CalculateScore(results) return checks, listedCount, score, grade, nil } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index ff0e813..44c6e99 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -32,13 +32,15 @@ import ( "git.happydns.org/happyDeliver/internal/api" ) -// RBLChecker checks IP addresses against DNS-based blacklists -type RBLChecker struct { +// DNSListChecker checks IP addresses against DNS-based block/allow lists. +// It handles both RBL (blacklist) and DNSWL (whitelist) semantics via flags. +type DNSListChecker struct { Timeout time.Duration - RBLs []string + Lists []string CheckAllIPs bool // Check all IPs found in headers, not just the first one + filterErrorCodes bool // When true (RBL mode), treat 127.255.255.253/254/255 as operational errors resolver *net.Resolver - informationalSet map[string]bool + informationalSet map[string]bool // Lists whose hits don't count toward the score } // DefaultRBLs is a list of commonly used RBL providers @@ -68,10 +70,16 @@ var DefaultInformationalRBLs = []string{ "dnsbl-3.uceprotect.net", // UCEPROTECT Level 3: entire ASes, too broad for scoring } +// DefaultDNSWLs is a list of commonly used DNSWL providers +var DefaultDNSWLs = []string{ + "list.dnswl.org", // DNSWL.org — the main DNS whitelist + "swl.spamhaus.org", // Spamhaus Safe Whitelist +} + // NewRBLChecker creates a new RBL checker with configurable timeout and RBL list -func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLChecker { +func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *DNSListChecker { if timeout == 0 { - timeout = 5 * time.Second // Default timeout + timeout = 5 * time.Second } if len(rbls) == 0 { rbls = DefaultRBLs @@ -80,30 +88,48 @@ func NewRBLChecker(timeout time.Duration, rbls []string, checkAllIPs bool) *RBLC for _, rbl := range DefaultInformationalRBLs { informationalSet[rbl] = true } - return &RBLChecker{ + return &DNSListChecker{ Timeout: timeout, - RBLs: rbls, + Lists: rbls, CheckAllIPs: checkAllIPs, + filterErrorCodes: true, resolver: &net.Resolver{PreferGo: true}, informationalSet: informationalSet, } } -// RBLResults represents the results of RBL checks -type RBLResults struct { - Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP - IPsChecked []string - ListedCount int // Total listings including informational RBLs - RelevantListedCount int // Listings on scoring (non-informational) RBLs only +// NewDNSWLChecker creates a new DNSWL checker with configurable timeout and DNSWL list +func NewDNSWLChecker(timeout time.Duration, dnswls []string, checkAllIPs bool) *DNSListChecker { + if timeout == 0 { + timeout = 5 * time.Second + } + if len(dnswls) == 0 { + dnswls = DefaultDNSWLs + } + return &DNSListChecker{ + Timeout: timeout, + Lists: dnswls, + CheckAllIPs: checkAllIPs, + filterErrorCodes: false, + resolver: &net.Resolver{PreferGo: true}, + informationalSet: make(map[string]bool), + } } -// CheckEmail checks all IPs found in the email headers against RBLs -func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { - results := &RBLResults{ +// DNSListResults represents the results of DNS list checks +type DNSListResults struct { + Checks map[string][]api.BlacklistCheck // Map of IP -> list of checks for that IP + IPsChecked []string + ListedCount int // Total listings including informational entries + RelevantListedCount int // Listings on scoring (non-informational) lists only +} + +// CheckEmail checks all IPs found in the email headers against the configured lists +func (r *DNSListChecker) CheckEmail(email *EmailMessage) *DNSListResults { + results := &DNSListResults{ Checks: make(map[string][]api.BlacklistCheck), } - // Extract IPs from Received headers ips := r.extractIPs(email) if len(ips) == 0 { return results @@ -111,20 +137,18 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { results.IPsChecked = ips - // Check each IP against all RBLs for _, ip := range ips { - for _, rbl := range r.RBLs { - check := r.checkIP(ip, rbl) + for _, list := range r.Lists { + check := r.checkIP(ip, list) results.Checks[ip] = append(results.Checks[ip], check) if check.Listed { results.ListedCount++ - if !r.informationalSet[rbl] { + if !r.informationalSet[list] { results.RelevantListedCount++ } } } - // Only check the first IP unless CheckAllIPs is enabled if !r.CheckAllIPs { break } @@ -133,9 +157,8 @@ func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults { return results } -// CheckIP checks a single IP address against all configured RBLs -func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { - // Validate that it's a valid IP address +// CheckIP checks a single IP address against all configured lists +func (r *DNSListChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { if !r.isPublicIP(ip) { return nil, 0, fmt.Errorf("invalid or non-public IP address: %s", ip) } @@ -143,9 +166,8 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { var checks []api.BlacklistCheck listedCount := 0 - // Check the IP against all RBLs - for _, rbl := range r.RBLs { - check := r.checkIP(ip, rbl) + for _, list := range r.Lists { + check := r.checkIP(ip, list) checks = append(checks, check) if check.Listed { listedCount++ @@ -156,27 +178,19 @@ func (r *RBLChecker) CheckIP(ip string) ([]api.BlacklistCheck, int, error) { } // extractIPs extracts IP addresses from Received headers -func (r *RBLChecker) extractIPs(email *EmailMessage) []string { +func (r *DNSListChecker) extractIPs(email *EmailMessage) []string { var ips []string seenIPs := make(map[string]bool) - // Get all Received headers receivedHeaders := email.Header["Received"] - - // Regex patterns for IP addresses - // Match IPv4: xxx.xxx.xxx.xxx ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) - // Look for IPs in Received headers for _, received := range receivedHeaders { - // Find all IPv4 addresses matches := ipv4Pattern.FindAllString(received, -1) for _, match := range matches { - // Skip private/reserved IPs if !r.isPublicIP(match) { continue } - // Avoid duplicates if !seenIPs[match] { ips = append(ips, match) seenIPs[match] = true @@ -184,13 +198,10 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } } - // If no IPs found in Received headers, try X-Originating-IP if len(ips) == 0 { originatingIP := email.Header.Get("X-Originating-IP") if originatingIP != "" { - // Extract IP from formats like "[192.0.2.1]" or "192.0.2.1" cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]") - // Remove any whitespace cleanIP = strings.TrimSpace(cleanIP) matches := ipv4Pattern.FindString(cleanIP) if matches != "" && r.isPublicIP(matches) { @@ -203,19 +214,16 @@ func (r *RBLChecker) extractIPs(email *EmailMessage) []string { } // isPublicIP checks if an IP address is public (not private, loopback, or reserved) -func (r *RBLChecker) isPublicIP(ipStr string) bool { +func (r *DNSListChecker) isPublicIP(ipStr string) bool { ip := net.ParseIP(ipStr) if ip == nil { return false } - // Check if it's a private network if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return false } - // Additional checks for reserved ranges - // 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3) if ip.IsUnspecified() { return false } @@ -223,51 +231,43 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool { return true } -// checkIP checks a single IP against a single RBL -func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { +// checkIP checks a single IP against a single DNS list +func (r *DNSListChecker) checkIP(ip, list string) api.BlacklistCheck { check := api.BlacklistCheck{ - Rbl: rbl, + Rbl: list, } - // Reverse the IP for DNSBL query reversedIP := r.reverseIP(ip) if reversedIP == "" { check.Error = api.PtrTo("Failed to reverse IP address") return check } - // Construct DNSBL query: reversed-ip.rbl-domain - query := fmt.Sprintf("%s.%s", reversedIP, rbl) + query := fmt.Sprintf("%s.%s", reversedIP, list) - // Perform DNS lookup with timeout ctx, cancel := context.WithTimeout(context.Background(), r.Timeout) defer cancel() addrs, err := r.resolver.LookupHost(ctx, query) if err != nil { - // Most likely not listed (NXDOMAIN) if dnsErr, ok := err.(*net.DNSError); ok { if dnsErr.IsNotFound { check.Listed = false return check } } - // Other DNS errors check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err)) return check } - // If we got a response, check the return code if len(addrs) > 0 { - check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2) + check.Response = api.PtrTo(addrs[0]) - // Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255 - // These indicate RBL operational issues, not actual listings - if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" { + // In RBL mode, 127.255.255.253/254/255 indicate operational errors, not real listings. + if r.filterErrorCodes && (addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255") { check.Listed = false - check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0])) + check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", list, addrs[0])) } else { - // Normal listing response check.Listed = true } } @@ -275,50 +275,47 @@ func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck { return check } -// reverseIP reverses an IPv4 address for DNSBL queries +// reverseIP reverses an IPv4 address for DNSBL/DNSWL queries // Example: 192.0.2.1 -> 1.2.0.192 -func (r *RBLChecker) reverseIP(ipStr string) string { +func (r *DNSListChecker) reverseIP(ipStr string) string { ip := net.ParseIP(ipStr) if ip == nil { return "" } - // Convert to IPv4 ipv4 := ip.To4() if ipv4 == nil { return "" // IPv6 not supported yet } - // Reverse the octets return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// CalculateRBLScore calculates the blacklist contribution to deliverability. -// Informational RBLs are not counted in the score. -func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) { +// CalculateScore calculates the list contribution to deliverability. +// Informational lists are not counted in the score. +func (r *DNSListChecker) CalculateScore(results *DNSListResults) (int, string) { if results == nil || len(results.IPsChecked) == 0 { - // No IPs to check, give benefit of doubt return 100, "" } - scoringRBLCount := len(r.RBLs) - len(r.informationalSet) - if scoringRBLCount <= 0 { + scoringListCount := len(r.Lists) - len(r.informationalSet) + if scoringListCount <= 0 { return 100, "A+" } - percentage := 100 - results.RelevantListedCount*100/scoringRBLCount + percentage := 100 - results.RelevantListedCount*100/scoringListCount return percentage, ScoreToGrade(percentage) } -// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL -func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { +// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one entry +func (r *DNSListChecker) GetUniqueListedIPs(results *DNSListResults) []string { var listedIPs []string - for ip, rblChecks := range results.Checks { - for _, check := range rblChecks { + for ip, checks := range results.Checks { + for _, check := range checks { if check.Listed { listedIPs = append(listedIPs, ip) - break // Only add the IP once + break } } } @@ -326,17 +323,17 @@ func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string { return listedIPs } -// GetRBLsForIP returns all RBLs that list a specific IP -func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string { - var rbls []string +// GetListsForIP returns all lists that match a specific IP +func (r *DNSListChecker) GetListsForIP(results *DNSListResults, ip string) []string { + var lists []string - if rblChecks, exists := results.Checks[ip]; exists { - for _, check := range rblChecks { + if checks, exists := results.Checks[ip]; exists { + for _, check := range checks { if check.Listed { - rbls = append(rbls, check.Rbl) + lists = append(lists, check.Rbl) } } } - return rbls + return lists } diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index a1de270..1dd1262 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -59,8 +59,8 @@ func TestNewRBLChecker(t *testing.T) { if checker.Timeout != tt.expectedTimeout { t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout) } - if len(checker.RBLs) != tt.expectedRBLs { - t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs) + if len(checker.Lists) != tt.expectedRBLs { + t.Errorf("RBLs count = %d, want %d", len(checker.Lists), tt.expectedRBLs) } if checker.resolver == nil { t.Error("Resolver should not be nil") @@ -265,7 +265,7 @@ func TestExtractIPs(t *testing.T) { func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string - results *RBLResults + results *DNSListResults expectedScore int }{ { @@ -275,14 +275,14 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "No IPs checked", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{}, }, expectedScore: 100, }, { name: "Not listed on any RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, @@ -290,7 +290,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 1 RBL", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, @@ -298,7 +298,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 2 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, @@ -306,7 +306,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 3 RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, @@ -314,7 +314,7 @@ func TestGetBlacklistScore(t *testing.T) { }, { name: "Listed on 4+ RBLs", - results: &RBLResults{ + results: &DNSListResults{ IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, @@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score, _ := checker.CalculateRBLScore(tt.results) + score, _ := checker.CalculateScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } @@ -335,7 +335,7 @@ func TestGetBlacklistScore(t *testing.T) { } func TestGetUniqueListedIPs(t *testing.T) { - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, @@ -363,7 +363,7 @@ func TestGetUniqueListedIPs(t *testing.T) { } func TestGetRBLsForIP(t *testing.T) { - results := &RBLResults{ + results := &DNSListResults{ Checks: map[string][]api.BlacklistCheck{ "198.51.100.1": { {Rbl: "zen.spamhaus.org", Listed: true}, @@ -402,7 +402,7 @@ func TestGetRBLsForIP(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rbls := checker.GetRBLsForIP(results, tt.ip) + rbls := checker.GetListsForIP(results, tt.ip) if len(rbls) != len(tt.expectedRBLs) { t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs)) diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index dc420fb..bd12960 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -35,7 +35,8 @@ type ReportGenerator struct { spamAnalyzer *SpamAssassinAnalyzer rspamdAnalyzer *RspamdAnalyzer dnsAnalyzer *DNSAnalyzer - rblChecker *RBLChecker + rblChecker *DNSListChecker + dnswlChecker *DNSListChecker contentAnalyzer *ContentAnalyzer headerAnalyzer *HeaderAnalyzer } @@ -45,6 +46,7 @@ func NewReportGenerator( dnsTimeout time.Duration, httpTimeout time.Duration, rbls []string, + dnswls []string, checkAllIPs bool, ) *ReportGenerator { return &ReportGenerator{ @@ -53,6 +55,7 @@ func NewReportGenerator( rspamdAnalyzer: NewRspamdAnalyzer(), dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), rblChecker: NewRBLChecker(dnsTimeout, rbls, checkAllIPs), + dnswlChecker: NewDNSWLChecker(dnsTimeout, dnswls, checkAllIPs), contentAnalyzer: NewContentAnalyzer(httpTimeout), headerAnalyzer: NewHeaderAnalyzer(), } @@ -65,7 +68,8 @@ type AnalysisResults struct { Content *ContentResults DNS *api.DNSResults Headers *api.HeaderAnalysis - RBL *RBLResults + RBL *DNSListResults + DNSWL *DNSListResults SpamAssassin *api.SpamAssassinResult Rspamd *api.RspamdResult } @@ -81,6 +85,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) + results.DNSWL = r.dnswlChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email) results.Rspamd = r.rspamdAnalyzer.AnalyzeRspamd(email) results.Content = r.contentAnalyzer.AnalyzeContent(email) @@ -135,7 +140,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu blacklistScore := 0 var blacklistGrade string if results.RBL != nil { - blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL) + blacklistScore, blacklistGrade = r.rblChecker.CalculateScore(results.RBL) } saScore, saGrade := r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) @@ -197,6 +202,11 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu report.Blacklists = &results.RBL.Checks } + // Add whitelist checks as a map of IP -> array of BlacklistCheck (informational only) + if results.DNSWL != nil && len(results.DNSWL.Checks) > 0 { + report.Whitelists = &results.DNSWL.Checks + } + // Add SpamAssassin result with individual deliverability score if results.SpamAssassin != nil { saGradeTyped := api.SpamAssassinResultDeliverabilityGrade(saGrade) diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 5a325b1..82e923e 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -32,7 +32,7 @@ import ( ) func TestNewReportGenerator(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) if gen == nil { t.Fatal("Expected report generator, got nil") } @@ -55,7 +55,7 @@ func TestNewReportGenerator(t *testing.T) { } func TestAnalyzeEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) email := createTestEmail() @@ -75,7 +75,7 @@ func TestAnalyzeEmail(t *testing.T) { } func TestGenerateReport(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) testID := uuid.New() email := createTestEmail() @@ -130,7 +130,7 @@ func TestGenerateReport(t *testing.T) { } func TestGenerateReportWithSpamAssassin(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) testID := uuid.New() email := createTestEmailWithSpamAssassin() @@ -150,7 +150,7 @@ func TestGenerateReportWithSpamAssassin(t *testing.T) { } func TestGenerateRawEmail(t *testing.T) { - gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, false) + gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs, DefaultDNSWLs, false) tests := []struct { name string diff --git a/web/src/lib/components/WhitelistCard.svelte b/web/src/lib/components/WhitelistCard.svelte new file mode 100644 index 0000000..ee0b0e2 --- /dev/null +++ b/web/src/lib/components/WhitelistCard.svelte @@ -0,0 +1,62 @@ + + +
+
+

+ + + Whitelist Checks + + Informational +

+
+
+

+ DNS whitelists identify trusted senders. Being listed here is a positive signal, but has + no impact on the overall score. +

+ +
+ {#each Object.entries(whitelists) as [ip, checks]} +
+
+ + {ip} +
+ + + {#each checks as check} + + + + + {/each} + +
+ + {check.error + ? "Error" + : check.listed + ? "Listed" + : "Not listed"} + + {check.rbl}
+
+ {/each} +
+
+
diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index d577399..8ed409c 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -24,3 +24,4 @@ export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; export { default as SummaryCard } from "./SummaryCard.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte"; +export { default as WhitelistCard } from "./WhitelistCard.svelte"; diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index c5add96..0c8ea9d 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -3,7 +3,7 @@ import { onDestroy } from "svelte"; import { getReport, getTest, reanalyzeReport } from "$lib/api"; - import type { Report, Test } from "$lib/api/types.gen"; + import type { BlacklistCheck, Report, Test } from "$lib/api/types.gen"; import { AuthenticationCard, BlacklistCard, @@ -17,8 +17,11 @@ SpamAssassinCard, SummaryCard, TinySurvey, + WhitelistCard, } from "$lib/components"; + type BlacklistRecords = Record; + let testId = $derived(page.params.test); let test = $state(null); let report = $state(null); @@ -321,17 +324,46 @@ {/if} - {#if report.blacklists && Object.keys(report.blacklists).length > 0} -
-
- + {#snippet blacklistChecks(blacklists: BlacklistRecords, report: Report)} + + {/snippet} + + + {#snippet whitelistChecks(whitelists: BlacklistRecords)} + + {/snippet} + + + {#if report.blacklists && report.whitelists && Object.keys(report.blacklists).length == 1 && Object.keys(report.whitelists).length == 1} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {@render whitelistChecks(report.whitelists)}
+ {:else} + {#if report.blacklists && Object.keys(report.blacklists).length > 0} +
+
+ {@render blacklistChecks(report.blacklists, report)} +
+
+ {/if} + + {#if report.whitelists && Object.keys(report.whitelists).length > 0} +
+
+ {@render whitelistChecks(report.whitelists)} +
+
+ {/if} {/if} From 2a2bfe46a858b7cc2030f97e166337867a86732b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 7 Mar 2026 16:26:40 +0700 Subject: [PATCH 05/62] fix: various small fixes and improvements - Add 'skipped' to authentication result enum in OpenAPI spec - Fix optional chaining on bimiResult.details check - Add rbls field to AppConfig interface - Restrict theme storage to valid 'light'/'dark' values only - Fix null coalescing for blacklist result data - Fix survey source to use domain instead of ip --- api/openapi.yaml | 2 +- web/src/lib/components/SummaryCard.svelte | 2 +- web/src/lib/stores/config.ts | 1 + web/src/lib/stores/theme.ts | 2 +- web/src/routes/blacklist/[ip]/+page.svelte | 2 +- web/src/routes/domain/[domain]/+page.svelte | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f724ae6..c0c3c70 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -789,7 +789,7 @@ components: properties: result: type: string - enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass] + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] description: Authentication result example: "pass" domain: diff --git a/web/src/lib/components/SummaryCard.svelte b/web/src/lib/components/SummaryCard.svelte index fe8af8e..5d93513 100644 --- a/web/src/lib/components/SummaryCard.svelte +++ b/web/src/lib/components/SummaryCard.svelte @@ -331,7 +331,7 @@ highlight: { color: "good", bold: true }, link: "#dns-bimi", }); - if (bimiResult.details && bimiResult.details.indexOf("declined") == 0) { + if (bimiResult?.details && bimiResult.details.indexOf("declined") == 0) { segments.push({ text: " declined to participate" }); } else if (bimiResult?.result === "fail") { segments.push({ text: " but " }); diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts index 87662ba..c393dd2 100644 --- a/web/src/lib/stores/config.ts +++ b/web/src/lib/stores/config.ts @@ -25,6 +25,7 @@ interface AppConfig { report_retention?: number; survey_url?: string; custom_logo_url?: string; + rbls?: string[]; } const defaultConfig: AppConfig = { diff --git a/web/src/lib/stores/theme.ts b/web/src/lib/stores/theme.ts index 362202b..ea24293 100644 --- a/web/src/lib/stores/theme.ts +++ b/web/src/lib/stores/theme.ts @@ -26,7 +26,7 @@ const getInitialTheme = () => { if (!browser) return "light"; const stored = localStorage.getItem("theme"); - if (stored) return stored; + if (stored === "light" || stored === "dark") return stored; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; diff --git a/web/src/routes/blacklist/[ip]/+page.svelte b/web/src/routes/blacklist/[ip]/+page.svelte index 180bfde..0cddb22 100644 --- a/web/src/routes/blacklist/[ip]/+page.svelte +++ b/web/src/routes/blacklist/[ip]/+page.svelte @@ -28,7 +28,7 @@ }); if (response.response.ok) { - result = response.data; + result = response.data ?? null; } else if (response.error) { error = response.error.message || "Failed to check IP address"; } diff --git a/web/src/routes/domain/[domain]/+page.svelte b/web/src/routes/domain/[domain]/+page.svelte index e191192..d866e21 100644 --- a/web/src/routes/domain/[domain]/+page.svelte +++ b/web/src/routes/domain/[domain]/+page.svelte @@ -130,7 +130,7 @@
From da93d6d706dedc3de744081172de9c0803276521 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 12:47:24 +0700 Subject: [PATCH 06/62] Add rspamd tests --- pkg/analyzer/rspamd_test.go | 394 ++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 pkg/analyzer/rspamd_test.go diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go new file mode 100644 index 0000000..180bafd --- /dev/null +++ b/pkg/analyzer/rspamd_test.go @@ -0,0 +1,394 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package analyzer + +import ( + "bytes" + "net/mail" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestAnalyzeRspamdNoHeaders(t *testing.T) { + analyzer := NewRspamdAnalyzer() + email := &EmailMessage{Header: make(mail.Header)} + + result := analyzer.AnalyzeRspamd(email) + + if result != nil { + t.Errorf("Expected nil for email without rspamd headers, got %+v", result) + } +} + +func TestParseSpamdResult(t *testing.T) { + tests := []struct { + name string + header string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedSymbols map[string]float32 + expectedSymParams map[string]string + }{ + { + name: "Clean email negative score", + header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]", + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "DATE_IN_PAST": 0.10, + "ALL_TRUSTED": -1.00, + }, + expectedSymParams: map[string]string{ + "ALL_TRUSTED": "trusted", + }, + }, + { + name: "Spam email True flag", + header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)", + expectedScore: 16.50, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{ + "BAYES_99": 5.00, + "SPOOFED_SENDER": 3.50, + }, + expectedSymParams: map[string]string{ + "BAYES_99": "1.00", + }, + }, + { + name: "Zero threshold uses default", + header: "default: False [1.00 / 0.00]", + expectedScore: 1.00, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{}, + }, + { + name: "Symbol without params", + header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)", + expectedScore: 2.00, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "MISSING_DATE": 1.00, + }, + }, + { + name: "Case-insensitive true flag", + header: "default: true [8.00 / 6.00]", + expectedScore: 8.00, + expectedThreshold: 6.00, + expectedIsSpam: true, + expectedSymbols: map[string]float32{}, + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &api.RspamdResult{ + Symbols: make(map[string]api.RspamdSymbol), + } + analyzer.parseSpamdResult(tt.header, result) + + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + for symName, expectedScore := range tt.expectedSymbols { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found", symName) + continue + } + if sym.Score != expectedScore { + t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore) + } + } + for symName, expectedParam := range tt.expectedSymParams { + sym, ok := result.Symbols[symName] + if !ok { + t.Errorf("Symbol %s not found for params check", symName) + continue + } + if sym.Params == nil { + t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam) + } else if *sym.Params != expectedParam { + t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam) + } + } + }) + } +} + +func TestAnalyzeRspamd(t *testing.T) { + tests := []struct { + name string + headers map[string]string + expectedScore float32 + expectedThreshold float32 + expectedIsSpam bool + expectedServer *string + expectedSymCount int + }{ + { + name: "Full headers clean email", + headers: map[string]string{ + "X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]", + "X-Rspamd-Score": "-3.91", + "X-Rspamd-Server": "mail.example.com", + }, + expectedScore: -3.91, + expectedThreshold: 15.00, + expectedIsSpam: false, + expectedServer: func() *string { s := "mail.example.com"; return &s }(), + expectedSymCount: 1, + }, + { + name: "X-Rspamd-Score overrides spamd result score", + headers: map[string]string{ + "X-Spamd-Result": "default: False [2.00 / 15.00]", + "X-Rspamd-Score": "3.50", + }, + expectedScore: 3.50, + expectedThreshold: 15.00, + expectedIsSpam: false, + }, + { + name: "Spam email above threshold", + headers: map[string]string{ + "X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)", + "X-Rspamd-Score": "16.00", + }, + expectedScore: 16.00, + expectedThreshold: 15.00, + expectedIsSpam: true, + expectedSymCount: 1, + }, + { + name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold", + headers: map[string]string{ + "X-Rspamd-Score": "2.00", + }, + expectedScore: 2.00, + expectedIsSpam: false, + }, + { + name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold", + headers: map[string]string{ + "X-Rspamd-Score": "7.00", + }, + expectedScore: 7.00, + expectedIsSpam: true, + }, + { + name: "Server header is trimmed", + headers: map[string]string{ + "X-Rspamd-Score": "1.00", + "X-Rspamd-Server": " rspamd-01 ", + }, + expectedScore: 1.00, + expectedServer: func() *string { s := "rspamd-01"; return &s }(), + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{Header: make(mail.Header)} + for k, v := range tt.headers { + email.Header[k] = []string{v} + } + + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore) + } + if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold { + t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold) + } + if result.IsSpam != tt.expectedIsSpam { + t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam) + } + if tt.expectedServer != nil { + if result.Server == nil { + t.Errorf("Server = nil, want %q", *tt.expectedServer) + } else if *result.Server != *tt.expectedServer { + t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer) + } + } + if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount { + t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount) + } + }) + } +} + +func TestCalculateRspamdScore(t *testing.T) { + tests := []struct { + name string + result *api.RspamdResult + expectedScore int + expectedGrade string + }{ + { + name: "Nil result (rspamd not installed)", + result: nil, + expectedScore: 100, + expectedGrade: "", + }, + { + name: "Score well below threshold", + result: &api.RspamdResult{ + Score: -3.91, + Threshold: 15.00, + }, + expectedScore: 100, + expectedGrade: "A+", + }, + { + name: "Score at zero", + result: &api.RspamdResult{ + Score: 0, + Threshold: 15.00, + }, + // 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A" + expectedScore: 100, + expectedGrade: "A", + }, + { + name: "Score at threshold (half of 2*threshold)", + result: &api.RspamdResult{ + Score: 15.00, + Threshold: 15.00, + }, + // 100 - round(15*100/(2*15)) = 100 - 50 = 50 + expectedScore: 50, + }, + { + name: "Score above 2*threshold", + result: &api.RspamdResult{ + Score: 31.00, + Threshold: 15.00, + }, + expectedScore: 0, + expectedGrade: "F", + }, + { + name: "Score exactly at 2*threshold", + result: &api.RspamdResult{ + Score: 30.00, + Threshold: 15.00, + }, + // 100 - round(30*100/30) = 100 - 100 = 0 + expectedScore: 0, + expectedGrade: "F", + }, + } + + analyzer := NewRspamdAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score, grade := analyzer.CalculateRspamdScore(tt.result) + + if score != tt.expectedScore { + t.Errorf("Score = %d, want %d", score, tt.expectedScore) + } + if tt.expectedGrade != "" && grade != tt.expectedGrade { + t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade) + } + }) + } +} + +const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00]; + BAYES_HAM(-3.00)[99%]; + RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from]; + R_DKIM_ALLOW(-0.20)[example.com:s=dkim]; + FROM_HAS_DN(0.00)[]; + MIME_GOOD(-0.10)[text/plain]; +X-Rspamd-Score: -3.91 +X-Rspamd-Server: rspamd-01.example.com +Date: Mon, 09 Mar 2026 10:00:00 +0000 +From: sender@example.com +To: test@happydomain.org +Subject: Test email +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +Hello world` + +func TestAnalyzeRspamdRealEmail(t *testing.T) { + email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + analyzer := NewRspamdAnalyzer() + result := analyzer.AnalyzeRspamd(email) + + if result == nil { + t.Fatal("Expected non-nil result") + } + if result.IsSpam { + t.Error("Expected IsSpam=false") + } + if result.Score != -3.91 { + t.Errorf("Score = %v, want -3.91", result.Score) + } + if result.Threshold != 15.00 { + t.Errorf("Threshold = %v, want 15.00", result.Threshold) + } + if result.Server == nil || *result.Server != "rspamd-01.example.com" { + t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server) + } + + expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"} + for _, sym := range expectedSymbols { + if _, ok := result.Symbols[sym]; !ok { + t.Errorf("Symbol %s not found", sym) + } + } + + score, _ := analyzer.CalculateRspamdScore(result) + if score != 100 { + t.Errorf("CalculateRspamdScore = %d, want 100", score) + } +} + From bb47bb7c29eb9e59780d631772081daa42d111b7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 12:52:12 +0700 Subject: [PATCH 07/62] fix: handle nested brackets in rspamd symbol params --- pkg/analyzer/rspamd.go | 5 +++-- pkg/analyzer/rspamd_test.go | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index d394c62..c2ea1cf 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -111,8 +111,9 @@ func (a *RspamdAnalyzer) parseSpamdResult(header string, result *api.RspamdResul } // Parse symbols: SYMBOL(score)[params] - // Each symbol entry is separated by ";" - symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[([^\]]*)\])?`) + // Each symbol entry is separated by ";", so within each part we use a + // greedy match to capture params that may contain nested brackets. + symbolRe := regexp.MustCompile(`(\w+)\((-?\d+\.?\d*)\)(?:\[(.*)\])?`) for _, part := range strings.Split(header, ";") { part = strings.TrimSpace(part) matches := symbolRe.FindStringSubmatch(part) diff --git a/pkg/analyzer/rspamd_test.go b/pkg/analyzer/rspamd_test.go index 180bafd..de98fe8 100644 --- a/pkg/analyzer/rspamd_test.go +++ b/pkg/analyzer/rspamd_test.go @@ -104,6 +104,26 @@ func TestParseSpamdResult(t *testing.T) { expectedIsSpam: true, expectedSymbols: map[string]float32{}, }, + { + name: "Zero threshold with symbols containing nested brackets in params", + header: "default: False [0.90 / 0.00];\n" + + "\tARC_REJECT(1.00)[cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}];\n" + + "\tMIME_GOOD(-0.10)[multipart/alternative,text/plain];\n" + + "\tMIME_TRACE(0.00)[0:+,1:+,2:~]", + expectedScore: 0.90, + expectedThreshold: rspamdDefaultAddHeaderThreshold, + expectedIsSpam: false, + expectedSymbols: map[string]float32{ + "ARC_REJECT": 1.00, + "MIME_GOOD": -0.10, + "MIME_TRACE": 0.00, + }, + expectedSymParams: map[string]string{ + "ARC_REJECT": "cannot verify 1 of 1 signatures: {[1] = sig:mail-tester.local:signature has incorrect length: 12}", + "MIME_GOOD": "multipart/alternative,text/plain", + "MIME_TRACE": "0:+,1:+,2:~", + }, + }, } analyzer := NewRspamdAnalyzer() From d9b9ea87c6dfab9f42a225ecd94c0bde8a19e7a6 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 13:09:07 +0700 Subject: [PATCH 08/62] refactor: extract email path into standalone card component Move the received chain display out of BlacklistCard into EmailPathCard, giving it its own card styling and placing it as a dedicated section on the report page. --- web/src/lib/components/BlacklistCard.svelte | 10 ++-------- web/src/lib/components/EmailPathCard.svelte | 18 ++++++++++++++---- web/src/routes/test/[test]/+page.svelte | 11 ++++++++++- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index 7f9b7f2..fec7b09 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -1,18 +1,16 @@
@@ -35,10 +33,6 @@
- {#if receivedChain} - - {/if} -
{#each Object.entries(blacklists) as [ip, checks]}
diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index 8dc57b0..a4fda45 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -1,5 +1,6 @@ {#if receivedChain && receivedChain.length > 0} -
-
Email Path (Received Chain)
-
+
+
+

+ + Email Path +

+
+
{#each receivedChain as hop, i}
@@ -30,7 +40,7 @@ : "-"}
- {#if hop.with || hop.id} + {#if hop.with || hop.id || hop.from}

{#if hop.with} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 0c8ea9d..10c4f22 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -9,6 +9,7 @@ BlacklistCard, ContentAnalysisCard, DnsRecordsCard, + EmailPathCard, ErrorDisplay, HeaderAnalysisCard, PendingState, @@ -294,6 +295,15 @@

+ + {#if report.header_analysis?.received_chain && report.header_analysis.received_chain.length > 0} +
+
+ +
+
+ {/if} + {#if report.dns_results}
@@ -329,7 +339,6 @@ {blacklists} blacklistGrade={report.summary?.blacklist_grade} blacklistScore={report.summary?.blacklist_score} - receivedChain={report.header_analysis?.received_chain} /> {/snippet} From 27650a3496ed6c6d5ab916d96d6495fc10be1877 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 9 Mar 2026 13:12:36 +0700 Subject: [PATCH 09/62] feat: add raw report display to rspamd card Add a collapsible Raw Report section to RspamdCard, storing the raw X-Spamd-Result header value and displaying it like SpamAssassin's report. --- api/openapi.yaml | 3 +++ pkg/analyzer/rspamd.go | 2 ++ web/src/lib/components/RspamdCard.svelte | 27 +++++++++++++++++++++--- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index c0c3c70..1a9cbbf 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -982,6 +982,9 @@ components: name: "BAYES_HAM" score: -1.9 params: "0.02" + report: + type: string + description: Full rspamd report (raw X-Spamd-Result header) RspamdSymbol: type: object diff --git a/pkg/analyzer/rspamd.go b/pkg/analyzer/rspamd.go index c2ea1cf..f3f548b 100644 --- a/pkg/analyzer/rspamd.go +++ b/pkg/analyzer/rspamd.go @@ -58,6 +58,8 @@ func (a *RspamdAnalyzer) AnalyzeRspamd(email *EmailMessage) *api.RspamdResult { // Parse X-Spamd-Result header (primary source for score, threshold, and symbols) // Format: "default: False [-3.91 / 15.00];\n\tSYMBOL(score)[params]; ..." if spamdResult, ok := headers["X-Spamd-Result"]; ok { + report := strings.ReplaceAll(spamdResult, "; ", ";\n") + result.Report = &report a.parseSpamdResult(spamdResult, result) } diff --git a/web/src/lib/components/RspamdCard.svelte b/web/src/lib/components/RspamdCard.svelte index 2468f90..0db6378 100644 --- a/web/src/lib/components/RspamdCard.svelte +++ b/web/src/lib/components/RspamdCard.svelte @@ -17,8 +17,7 @@ const effectiveAction = $derived.by(() => { const rejectThreshold = rspamd.threshold > 0 ? rspamd.threshold : 15; - if (rspamd.score >= rejectThreshold) - return { label: "Reject", cls: "bg-danger" }; + if (rspamd.score >= rejectThreshold) return { label: "Reject", cls: "bg-danger" }; if (rspamd.score >= RSPAMD_ADD_HEADER_THRESHOLD) return { label: "Add header", cls: "bg-warning text-dark" }; if (rspamd.score >= RSPAMD_GREYLIST_THRESHOLD) @@ -31,7 +30,7 @@

- + rspamd Analysis @@ -108,10 +107,32 @@

{/if} + + {#if rspamd.report} +
+ Raw Report +
{rspamd.report}
+
+ {/if}
diff --git a/web/src/lib/components/index.ts b/web/src/lib/components/index.ts index 8ed409c..a593801 100644 --- a/web/src/lib/components/index.ts +++ b/web/src/lib/components/index.ts @@ -23,5 +23,6 @@ export { default as RspamdCard } from "./RspamdCard.svelte"; export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte"; export { default as SpfRecordsDisplay } from "./SpfRecordsDisplay.svelte"; export { default as SummaryCard } from "./SummaryCard.svelte"; +export { default as HistoryTable } from "./HistoryTable.svelte"; export { default as TinySurvey } from "./TinySurvey.svelte"; export { default as WhitelistCard } from "./WhitelistCard.svelte"; diff --git a/web/src/lib/stores/config.ts b/web/src/lib/stores/config.ts index c393dd2..962868c 100644 --- a/web/src/lib/stores/config.ts +++ b/web/src/lib/stores/config.ts @@ -26,6 +26,7 @@ interface AppConfig { survey_url?: string; custom_logo_url?: string; rbls?: string[]; + test_list_enabled?: boolean; } const defaultConfig: AppConfig = { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 077f340..92bb4db 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -40,7 +40,17 @@ {/if} -
+ {#if $appConfig.test_list_enabled} + + {/if} +
Open-Source Email Deliverability Tester diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 7c23d10..b9259fe 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,12 +1,30 @@ + + + Test History - happyDeliver + + +
+
+
+
+

+ + Test History +

+ +
+ + {#if loading} +
+
+ Loading... +
+

Loading tests...

+
+ {:else if error} + + {:else if tests.length === 0} +
+ +

No tests yet

+

+ Send a test email to get your first deliverability + report. +

+ +
+ {:else} + + + + {#if totalPages > 1} + + {/if} + {/if} +
+
+
From 3eec5ce96655060eb94f28403daacb720e2c8115 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 9 Apr 2026 17:49:52 +0700 Subject: [PATCH 41/62] Remove unused xAlignedFrom prop from HeaderAnalysisCard --- web/src/lib/components/HeaderAnalysisCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index b26b492..73c39e8 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -11,7 +11,7 @@ headerScore?: number; } - let { dmarcRecord, headerAnalysis, headerGrade, headerScore, xAlignedFrom }: Props = $props(); + let { dmarcRecord, headerAnalysis, headerGrade, headerScore }: Props = $props();
From 396c51974a9d81b6ea51ff9f24416fcb0c15c86c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 9 Apr 2026 18:36:18 +0700 Subject: [PATCH 42/62] Extract OpenAPI schemas to separate file and move models to internal/model package Split api/openapi.yaml schemas into api/schemas.yaml so structs can be generated independently from the API server code. Models now generate into internal/model/ via oapi-codegen, with the server referencing them through import-mapping. Moved PtrTo helper to internal/utils and removed storage.ReportSummary in favor of model.TestSummary. --- .gitignore | 4 +- api/config-models.yaml | 10 +- api/config-server.yaml | 3 + api/openapi.yaml | 1163 +--------------- api/schemas.yaml | 1173 +++++++++++++++++ generate.go | 2 +- internal/api/handlers.go | 167 +-- internal/storage/storage.go | 49 +- internal/{api/helpers.go => utils/ptr.go} | 8 +- pkg/analyzer/analyzer.go | 10 +- pkg/analyzer/authentication.go | 12 +- pkg/analyzer/authentication_arc.go | 25 +- pkg/analyzer/authentication_arc_test.go | 10 +- pkg/analyzer/authentication_bimi.go | 17 +- pkg/analyzer/authentication_bimi_test.go | 12 +- pkg/analyzer/authentication_dkim.go | 15 +- pkg/analyzer/authentication_dkim_test.go | 10 +- pkg/analyzer/authentication_dmarc.go | 17 +- pkg/analyzer/authentication_dmarc_test.go | 8 +- pkg/analyzer/authentication_iprev.go | 15 +- pkg/analyzer/authentication_iprev_test.go | 73 +- pkg/analyzer/authentication_spf.go | 25 +- pkg/analyzer/authentication_spf_test.go | 49 +- pkg/analyzer/authentication_test.go | 161 +-- pkg/analyzer/authentication_x_aligned_from.go | 17 +- .../authentication_x_aligned_from_test.go | 34 +- pkg/analyzer/authentication_x_google_dkim.go | 15 +- .../authentication_x_google_dkim_test.go | 12 +- pkg/analyzer/content.go | 97 +- pkg/analyzer/dns.go | 18 +- pkg/analyzer/dns_bimi.go | 19 +- pkg/analyzer/dns_dkim.go | 25 +- pkg/analyzer/dns_dmarc.go | 51 +- pkg/analyzer/dns_dmarc_test.go | 21 +- pkg/analyzer/dns_fcr.go | 4 +- pkg/analyzer/dns_mx.go | 19 +- pkg/analyzer/dns_spf.go | 45 +- pkg/analyzer/headers.go | 57 +- pkg/analyzer/headers_test.go | 24 +- pkg/analyzer/rbl.go | 23 +- pkg/analyzer/rbl_test.go | 6 +- pkg/analyzer/report.go | 40 +- pkg/analyzer/rspamd.go | 14 +- pkg/analyzer/rspamd_test.go | 18 +- pkg/analyzer/scoring.go | 8 +- pkg/analyzer/spamassassin.go | 25 +- pkg/analyzer/spamassassin_test.go | 33 +- 47 files changed, 1878 insertions(+), 1785 deletions(-) create mode 100644 api/schemas.yaml rename internal/{api/helpers.go => utils/ptr.go} (91%) diff --git a/.gitignore b/.gitignore index 7ece05e..e943630 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,5 @@ logs/ *.sqlite3 # OpenAPI generated files -internal/api/models.gen.go -internal/api/server.gen.go \ No newline at end of file +internal/api/server.gen.go +internal/model/types.gen.go diff --git a/api/config-models.yaml b/api/config-models.yaml index 9c3425c..aa2fb0e 100644 --- a/api/config-models.yaml +++ b/api/config-models.yaml @@ -1,5 +1,9 @@ -package: api +package: model generate: models: true - embedded-spec: false -output: internal/api/models.gen.go + embedded-spec: true +output: internal/model/types.gen.go +output-options: + skip-prune: true +import-mapping: + ./schemas.yaml: "-" diff --git a/api/config-server.yaml b/api/config-server.yaml index 20f8daf..347dbaf 100644 --- a/api/config-server.yaml +++ b/api/config-server.yaml @@ -1,5 +1,8 @@ package: api generate: gin-server: true + models: true embedded-spec: true output: internal/api/server.gen.go +import-mapping: + ./schemas.yaml: git.happydns.org/happyDeliver/internal/model diff --git a/api/openapi.yaml b/api/openapi.yaml index ee56cff..2dbf304 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -296,1165 +296,74 @@ paths: components: schemas: Test: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - description: Unique test email address - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending, analyzed] - description: Current test status (pending = no report yet, analyzed = report available) - example: "analyzed" - + $ref: './schemas.yaml#/components/schemas/Test' TestResponse: - type: object - required: - - id - - email - - status - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Unique test identifier (base32-encoded with hyphens) - example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" - email: - type: string - format: email - example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" - status: - type: string - enum: [pending] - example: "pending" - message: - type: string - example: "Send your test email to the address above" - + $ref: './schemas.yaml#/components/schemas/TestResponse' Report: - type: object - required: - - id - - test_id - - score - - grade - - created_at - properties: - id: - type: string - pattern: '^[a-z0-9-]+$' - description: Report identifier (base32-encoded with hyphens) - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Associated test ID (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score as percentage (0-100) - example: 85 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - summary: - $ref: '#/components/schemas/ScoreSummary' - authentication: - $ref: '#/components/schemas/AuthenticationResults' - spamassassin: - $ref: '#/components/schemas/SpamAssassinResult' - rspamd: - $ref: '#/components/schemas/RspamdResult' - dns_results: - $ref: '#/components/schemas/DNSResults' - blacklists: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: Map of IP addresses to their blacklist check results (array of checks per IP) - example: - "192.0.2.1": - - rbl: "zen.spamhaus.org" - listed: false - - rbl: "bl.spamcop.net" - listed: false - whitelists: - type: object - additionalProperties: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: Map of IP addresses to their DNS whitelist check results (informational only) - example: - "192.0.2.1": - - rbl: "list.dnswl.org" - listed: false - - rbl: "swl.spamhaus.org" - listed: false - content_analysis: - $ref: '#/components/schemas/ContentAnalysis' - header_analysis: - $ref: '#/components/schemas/HeaderAnalysis' - raw_headers: - type: string - description: Raw email headers - created_at: - type: string - format: date-time - + $ref: './schemas.yaml#/components/schemas/Report' ScoreSummary: - type: object - required: - - dns_score - - dns_grade - - authentication_score - - authentication_grade - - spam_score - - spam_grade - - blacklist_score - - blacklist_grade - - header_score - - header_grade - - content_score - - content_grade - properties: - dns_score: - type: integer - minimum: 0 - maximum: 100 - description: DNS records score (in percentage) - example: 42 - dns_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - authentication_score: - type: integer - minimum: 0 - maximum: 100 - description: SPF/DKIM/DMARC score (in percentage) - example: 28 - authentication_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - spam_score: - type: integer - minimum: 0 - maximum: 100 - description: Spam filter score (SpamAssassin + rspamd combined, in percentage) - example: 15 - spam_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - blacklist_score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist check score (in percentage) - example: 20 - blacklist_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - header_score: - type: integer - minimum: 0 - maximum: 100 - description: Header quality score (in percentage) - example: 9 - header_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - content_score: - type: integer - minimum: 0 - maximum: 100 - description: Content quality score (in percentage) - example: 18 - content_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score (A+ is best, F is worst) - example: "A" - + $ref: './schemas.yaml#/components/schemas/ScoreSummary' ContentAnalysis: - type: object - properties: - has_html: - type: boolean - description: Whether email contains HTML part - example: true - has_plaintext: - type: boolean - description: Whether email contains plaintext part - example: true - html_issues: - type: array - items: - $ref: '#/components/schemas/ContentIssue' - description: Issues found in HTML content - links: - type: array - items: - $ref: '#/components/schemas/LinkCheck' - description: Analysis of links found in the email - images: - type: array - items: - $ref: '#/components/schemas/ImageCheck' - description: Analysis of images in the email - text_to_image_ratio: - type: number - format: float - description: Ratio of text to images (higher is better) - example: 0.75 - has_unsubscribe_link: - type: boolean - description: Whether email contains an unsubscribe link - example: true - unsubscribe_methods: - type: array - items: - type: string - enum: [link, mailto, list-unsubscribe-header, one-click] - description: Available unsubscribe methods - example: ["link", "list-unsubscribe-header"] - + $ref: './schemas.yaml#/components/schemas/ContentAnalysis' ContentIssue: - type: object - required: - - type - - severity - - message - properties: - type: - type: string - enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] - description: Type of content issue - example: "missing_alt" - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - example: "medium" - message: - type: string - description: Human-readable description - example: "3 images are missing alt attributes" - location: - type: string - description: Where the issue was found - example: "HTML body line 42" - advice: - type: string - description: How to fix this issue - example: "Add descriptive alt text to all images for better accessibility and deliverability" - + $ref: './schemas.yaml#/components/schemas/ContentIssue' LinkCheck: - type: object - required: - - url - - status - properties: - url: - type: string - format: uri - description: The URL found in the email - example: "https://example.com/page" - status: - type: string - enum: [valid, broken, suspicious, redirected, timeout] - description: Link validation status - example: "valid" - http_code: - type: integer - description: HTTP status code received - example: 200 - redirect_chain: - type: array - items: - type: string - description: URLs in the redirect chain, if any - example: ["https://example.com", "https://www.example.com"] - is_shortened: - type: boolean - description: Whether this is a URL shortener - example: false - + $ref: './schemas.yaml#/components/schemas/LinkCheck' ImageCheck: - type: object - required: - - has_alt - properties: - src: - type: string - description: Image source URL or path - example: "https://example.com/logo.png" - has_alt: - type: boolean - description: Whether image has alt attribute - example: true - alt_text: - type: string - description: Alt text content - example: "Company Logo" - is_tracking_pixel: - type: boolean - description: Whether this appears to be a tracking pixel (1x1 image) - example: false - + $ref: './schemas.yaml#/components/schemas/ImageCheck' HeaderAnalysis: - type: object - properties: - has_mime_structure: - type: boolean - description: Whether body has a MIME structure - example: true - headers: - type: object - additionalProperties: - $ref: '#/components/schemas/HeaderCheck' - description: Map of header names to their check results (e.g., "from", "to", "dkim-signature") - example: - from: - present: true - value: "sender@example.com" - valid: true - importance: "required" - date: - present: true - value: "Mon, 1 Jan 2024 12:00:00 +0000" - valid: true - importance: "required" - received_chain: - type: array - items: - $ref: '#/components/schemas/ReceivedHop' - description: Chain of Received headers showing email path - domain_alignment: - $ref: '#/components/schemas/DomainAlignment' - issues: - type: array - items: - $ref: '#/components/schemas/HeaderIssue' - description: Issues found in headers - + $ref: './schemas.yaml#/components/schemas/HeaderAnalysis' HeaderCheck: - type: object - required: - - present - properties: - present: - type: boolean - description: Whether the header is present - example: true - value: - type: string - description: Header value - example: "sender@example.com" - valid: - type: boolean - description: Whether the value is valid/well-formed - example: true - importance: - type: string - enum: [required, recommended, optional, newsletter] - description: How important this header is for deliverability - example: "required" - issues: - type: array - items: - type: string - description: Any issues with this header - example: ["Invalid date format"] - + $ref: './schemas.yaml#/components/schemas/HeaderCheck' ReceivedHop: - type: object - properties: - from: - type: string - description: Sending server hostname - example: "mail.example.com" - by: - type: string - description: Receiving server hostname - example: "mx.receiver.com" - with: - type: string - description: Protocol used - example: "ESMTPS" - id: - type: string - description: Message ID at this hop - timestamp: - type: string - format: date-time - description: When this hop occurred - ip: - type: string - description: IP address of the sending server (IPv4 or IPv6) - example: "192.0.2.1" - reverse: - type: string - description: Reverse DNS (PTR record) for the IP address - example: "mail.example.com" - + $ref: './schemas.yaml#/components/schemas/ReceivedHop' DKIMDomainInfo: - type: object - required: - - domain - - org_domain - properties: - domain: - type: string - description: DKIM signature domain - example: "mail.example.com" - org_domain: - type: string - description: Organizational domain extracted from DKIM domain (using Public Suffix List) - example: "example.com" - + $ref: './schemas.yaml#/components/schemas/DKIMDomainInfo' DomainAlignment: - type: object - properties: - from_domain: - type: string - description: Domain from From header - example: "example.com" - from_org_domain: - type: string - description: Organizational domain extracted from From header (using Public Suffix List) - example: "example.com" - return_path_domain: - type: string - description: Domain from Return-Path header - example: "example.com" - return_path_org_domain: - type: string - description: Organizational domain extracted from Return-Path header (using Public Suffix List) - example: "example.com" - dkim_domains: - type: array - items: - $ref: '#/components/schemas/DKIMDomainInfo' - description: Domains from DKIM signatures with their organizational domains - aligned: - type: boolean - description: Whether all domains align (strict alignment - exact match) - example: true - relaxed_aligned: - type: boolean - description: Whether domains satisfy relaxed alignment (organizational domain match) - example: true - issues: - type: array - items: - type: string - description: Alignment issues - example: ["Return-Path domain does not match From domain"] - + $ref: './schemas.yaml#/components/schemas/DomainAlignment' HeaderIssue: - type: object - required: - - header - - severity - - message - properties: - header: - type: string - description: Header name - example: "Date" - severity: - type: string - enum: [critical, high, medium, low, info] - description: Issue severity - example: "medium" - message: - type: string - description: Human-readable description - example: "Date header is in the future" - advice: - type: string - description: How to fix this issue - example: "Ensure your mail server clock is synchronized with NTP" - + $ref: './schemas.yaml#/components/schemas/HeaderIssue' AuthenticationResults: - type: object - properties: - spf: - $ref: '#/components/schemas/AuthResult' - dkim: - type: array - items: - $ref: '#/components/schemas/AuthResult' - dmarc: - $ref: '#/components/schemas/AuthResult' - bimi: - $ref: '#/components/schemas/AuthResult' - arc: - $ref: '#/components/schemas/ARCResult' - iprev: - $ref: '#/components/schemas/IPRevResult' - x_google_dkim: - $ref: '#/components/schemas/AuthResult' - description: Google-specific DKIM authentication result (x-google-dkim) - x_aligned_from: - $ref: '#/components/schemas/AuthResult' - description: X-Aligned-From authentication result (checks address alignment) - + $ref: './schemas.yaml#/components/schemas/AuthenticationResults' AuthResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] - description: Authentication result - example: "pass" - domain: - type: string - description: Domain being authenticated - example: "example.com" - selector: - type: string - description: DKIM selector (for DKIM only) - example: "default" - details: - type: string - description: Additional details about the result - + $ref: './schemas.yaml#/components/schemas/AuthResult' ARCResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, none] - description: Overall ARC chain validation result - example: "pass" - chain_valid: - type: boolean - description: Whether the ARC chain signatures are valid - example: true - chain_length: - type: integer - description: Number of ARC sets in the chain - example: 2 - details: - type: string - description: Additional details about ARC validation - example: "ARC chain valid with 2 intermediaries" - + $ref: './schemas.yaml#/components/schemas/ARCResult' IPRevResult: - type: object - required: - - result - properties: - result: - type: string - enum: [pass, fail, temperror, permerror] - description: IP reverse DNS lookup result - example: "pass" - ip: - type: string - description: IP address that was checked - example: "195.110.101.58" - hostname: - type: string - description: Hostname from reverse DNS lookup (PTR record) - example: "authsmtp74.register.it" - details: - type: string - description: Additional details about the IP reverse lookup - example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" - + $ref: './schemas.yaml#/components/schemas/IPRevResult' SpamAssassinResult: - type: object - required: - - score - - required_score - - is_spam - - test_details - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: SpamAssassin deliverability score (0-100, higher is better) - example: 80 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for SpamAssassin deliverability score - example: "B" - version: - type: string - description: SpamAssassin version - example: "SpamAssassin 4.0.1" - score: - type: number - format: float - description: SpamAssassin spam score - example: 2.3 - required_score: - type: number - format: float - description: Threshold for spam classification - example: 5.0 - is_spam: - type: boolean - description: Whether message is classified as spam - example: false - tests: - type: array - items: - type: string - description: List of triggered SpamAssassin tests - example: ["BAYES_00", "DKIM_SIGNED"] - test_details: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of test names to their detailed results - example: - BAYES_00: - name: "BAYES_00" - score: -1.9 - description: "Bayes spam probability is 0 to 1%" - DKIM_SIGNED: - name: "DKIM_SIGNED" - score: 0.1 - description: "Message has a DKIM or DK signature, not necessarily valid" - report: - type: string - description: Full SpamAssassin report - + $ref: './schemas.yaml#/components/schemas/SpamAssassinResult' SpamTestDetail: - type: object - required: - - name - - score - properties: - name: - type: string - description: Test name - example: "BAYES_00" - score: - type: number - format: float - description: Score contribution of this test - example: -1.9 - params: - type: string - description: Symbol parameters or options - example: "0.02" - description: - type: string - description: Human-readable description of what this test checks - example: "Bayes spam probability is 0 to 1%" - + $ref: './schemas.yaml#/components/schemas/SpamTestDetail' RspamdResult: - type: object - required: - - score - - threshold - - is_spam - - symbols - properties: - deliverability_score: - type: integer - minimum: 0 - maximum: 100 - description: rspamd deliverability score (0-100, higher is better) - example: 85 - deliverability_grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade for rspamd deliverability score - example: "A" - score: - type: number - format: float - description: rspamd spam score - example: -3.91 - threshold: - type: number - format: float - description: Score threshold for spam classification - example: 15.0 - action: - type: string - description: rspamd action (no action, add header, rewrite subject, soft reject, reject) - example: "no action" - is_spam: - type: boolean - description: Whether message is classified as spam (action is reject or soft reject) - example: false - server: - type: string - description: rspamd server that processed the message - example: "rspamd.example.com" - symbols: - type: object - additionalProperties: - $ref: '#/components/schemas/SpamTestDetail' - description: Map of triggered rspamd symbols to their details - example: - BAYES_HAM: - name: "BAYES_HAM" - score: -1.9 - params: "0.02" - report: - type: string - description: Full rspamd report (raw X-Spamd-Result header) - - + $ref: './schemas.yaml#/components/schemas/RspamdResult' DNSResults: - type: object - required: - - from_domain - properties: - from_domain: - type: string - description: From Domain name - example: "example.com" - rp_domain: - type: string - description: Return Path Domain name - example: "example.com" - from_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the From domain - rp_mx_records: - type: array - items: - $ref: '#/components/schemas/MXRecord' - description: MX records for the Return-Path domain - spf_records: - type: array - items: - $ref: '#/components/schemas/SPFRecord' - description: SPF records found (includes resolved include directives) - dkim_records: - type: array - items: - $ref: '#/components/schemas/DKIMRecord' - description: DKIM records found - dmarc_record: - $ref: '#/components/schemas/DMARCRecord' - bimi_record: - $ref: '#/components/schemas/BIMIRecord' - ptr_records: - type: array - items: - type: string - description: PTR (reverse DNS) records for the sender IP address - example: ["mail.example.com", "smtp.example.com"] - ptr_forward_records: - type: array - items: - type: string - description: A or AAAA records resolved from the PTR hostnames (forward confirmation) - example: ["192.0.2.1", "2001:db8::1"] - errors: - type: array - items: - type: string - description: DNS lookup errors - + $ref: './schemas.yaml#/components/schemas/DNSResults' MXRecord: - type: object - required: - - host - - priority - - valid - properties: - host: - type: string - description: MX hostname - example: "mail.example.com" - priority: - type: integer - format: uint16 - description: MX priority (lower is higher priority) - example: 10 - valid: - type: boolean - description: Whether the MX record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "Failed to lookup MX records" - + $ref: './schemas.yaml#/components/schemas/MXRecord' SPFRecord: - type: object - required: - - valid - properties: - domain: - type: string - description: Domain this SPF record belongs to - example: "example.com" - record: - type: string - description: SPF record content - example: "v=spf1 include:_spf.example.com ~all" - valid: - type: boolean - description: Whether the SPF record is valid - example: true - all_qualifier: - type: string - enum: ["+", "-", "~", "?"] - description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" - example: "~" - error: - type: string - description: Error message if validation failed - example: "No SPF record found" - + $ref: './schemas.yaml#/components/schemas/SPFRecord' DKIMRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: DKIM selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: DKIM record content - example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." - valid: - type: boolean - description: Whether the DKIM record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DKIM record found" - + $ref: './schemas.yaml#/components/schemas/DKIMRecord' DMARCRecord: - type: object - required: - - valid - properties: - record: - type: string - description: DMARC record content - example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" - policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC policy - example: "quarantine" - subdomain_policy: - type: string - enum: [none, quarantine, reject, unknown] - description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy - example: "quarantine" - percentage: - type: integer - minimum: 0 - maximum: 100 - description: Percentage of messages subjected to filtering (pct tag, default 100) - example: 100 - spf_alignment: - type: string - enum: [relaxed, strict] - description: SPF alignment mode (aspf tag) - example: "relaxed" - dkim_alignment: - type: string - enum: [relaxed, strict] - description: DKIM alignment mode (adkim tag) - example: "relaxed" - valid: - type: boolean - description: Whether the DMARC record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No DMARC record found" - + $ref: './schemas.yaml#/components/schemas/DMARCRecord' BIMIRecord: - type: object - required: - - selector - - domain - - valid - properties: - selector: - type: string - description: BIMI selector - example: "default" - domain: - type: string - description: Domain name - example: "example.com" - record: - type: string - description: BIMI record content - example: "v=BIMI1; l=https://example.com/logo.svg" - logo_url: - type: string - format: uri - description: URL to the brand logo (SVG) - example: "https://example.com/logo.svg" - vmc_url: - type: string - format: uri - description: URL to Verified Mark Certificate (optional) - example: "https://example.com/vmc.pem" - valid: - type: boolean - description: Whether the BIMI record is valid - example: true - error: - type: string - description: Error message if validation failed - example: "No BIMI record found" - + $ref: './schemas.yaml#/components/schemas/BIMIRecord' BlacklistCheck: - type: object - required: - - rbl - - listed - properties: - rbl: - type: string - description: RBL/DNSBL name - example: "zen.spamhaus.org" - listed: - type: boolean - description: Whether IP is listed - example: false - response: - type: string - description: RBL response code or message - example: "127.0.0.2" - error: - type: string - description: RBL error if any - + $ref: './schemas.yaml#/components/schemas/BlacklistCheck' Status: - type: object - required: - - status - - version - properties: - status: - type: string - enum: [healthy, degraded, unhealthy] - description: Overall service status - example: "healthy" - version: - type: string - description: Service version - example: "0.1.0-dev" - components: - type: object - properties: - database: - type: string - enum: [up, down] - example: "up" - mta: - type: string - enum: [up, down] - example: "up" - uptime: - type: integer - description: Service uptime in seconds - example: 3600 - + $ref: './schemas.yaml#/components/schemas/Status' Error: - type: object - required: - - error - - message - properties: - error: - type: string - description: Error code - example: "not_found" - message: - type: string - description: Human-readable error message - example: "Test not found" - details: - type: string - description: Additional error details - + $ref: './schemas.yaml#/components/schemas/Error' DomainTestRequest: - type: object - required: - - domain - properties: - domain: - type: string - pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' - description: Domain name to test (e.g., example.com) - example: "example.com" - + $ref: './schemas.yaml#/components/schemas/DomainTestRequest' DomainTestResponse: - type: object - required: - - domain - - score - - grade - - dns_results - properties: - domain: - type: string - description: The tested domain name - example: "example.com" - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall domain configuration score (0-100) - example: 85 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A" - dns_results: - $ref: '#/components/schemas/DNSResults' - + $ref: './schemas.yaml#/components/schemas/DomainTestResponse' BlacklistCheckRequest: - type: object - required: - - ip - properties: - ip: - type: string - description: IPv4 or IPv6 address to check against blacklists - example: "192.0.2.1" - pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' - + $ref: './schemas.yaml#/components/schemas/BlacklistCheckRequest' BlacklistCheckResponse: - type: object - required: - - ip - - blacklists - - listed_count - - score - - grade - properties: - ip: - type: string - description: The IP address that was checked - example: "192.0.2.1" - blacklists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of blacklist check results - listed_count: - type: integer - description: Number of blacklists that have this IP listed - example: 0 - score: - type: integer - minimum: 0 - maximum: 100 - description: Blacklist score (0-100, higher is better) - example: 100 - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade representation of the score - example: "A+" - whitelists: - type: array - items: - $ref: '#/components/schemas/BlacklistCheck' - description: List of DNS whitelist check results (informational only) - + $ref: './schemas.yaml#/components/schemas/BlacklistCheckResponse' TestSummary: - type: object - required: - - test_id - - score - - grade - - created_at - properties: - test_id: - type: string - pattern: '^[a-z0-9-]+$' - description: Test identifier (base32-encoded with hyphens) - score: - type: integer - minimum: 0 - maximum: 100 - description: Overall deliverability score (0-100) - grade: - type: string - enum: [A+, A, B, C, D, E, F] - description: Letter grade - from_domain: - type: string - description: Sender domain extracted from the report - created_at: - type: string - format: date-time - + $ref: './schemas.yaml#/components/schemas/TestSummary' TestListResponse: - type: object - required: - - tests - - total - - offset - - limit - properties: - tests: - type: array - items: - $ref: '#/components/schemas/TestSummary' - total: - type: integer - description: Total number of tests - offset: - type: integer - description: Current offset - limit: - type: integer - description: Current limit + $ref: './schemas.yaml#/components/schemas/TestListResponse' diff --git a/api/schemas.yaml b/api/schemas.yaml new file mode 100644 index 0000000..df0b416 --- /dev/null +++ b/api/schemas.yaml @@ -0,0 +1,1173 @@ +openapi: 3.0.3 +info: + title: happyDeliver Schemas + description: Shared schema definitions for happyDeliver + version: 0.1.0 + +paths: {} + +components: + schemas: + Test: + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + description: Unique test email address + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending, analyzed] + description: Current test status (pending = no report yet, analyzed = report available) + example: "analyzed" + + TestResponse: + type: object + required: + - id + - email + - status + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Unique test identifier (base32-encoded with hyphens) + example: "krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a" + email: + type: string + format: email + example: "test-krfwg4z-amrqw4z-zmorsw2-djmfzgk-3a@example.com" + status: + type: string + enum: [pending] + example: "pending" + message: + type: string + example: "Send your test email to the address above" + + Report: + type: object + required: + - id + - test_id + - score + - grade + - created_at + properties: + id: + type: string + pattern: '^[a-z0-9-]+$' + description: Report identifier (base32-encoded with hyphens) + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Associated test ID (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score as percentage (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + summary: + $ref: '#/components/schemas/ScoreSummary' + authentication: + $ref: '#/components/schemas/AuthenticationResults' + spamassassin: + $ref: '#/components/schemas/SpamAssassinResult' + rspamd: + $ref: '#/components/schemas/RspamdResult' + dns_results: + $ref: '#/components/schemas/DNSResults' + blacklists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their blacklist check results (array of checks per IP) + example: + "192.0.2.1": + - rbl: "zen.spamhaus.org" + listed: false + - rbl: "bl.spamcop.net" + listed: false + whitelists: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: Map of IP addresses to their DNS whitelist check results (informational only) + example: + "192.0.2.1": + - rbl: "list.dnswl.org" + listed: false + - rbl: "swl.spamhaus.org" + listed: false + content_analysis: + $ref: '#/components/schemas/ContentAnalysis' + header_analysis: + $ref: '#/components/schemas/HeaderAnalysis' + raw_headers: + type: string + description: Raw email headers + created_at: + type: string + format: date-time + + ScoreSummary: + type: object + required: + - dns_score + - dns_grade + - authentication_score + - authentication_grade + - spam_score + - spam_grade + - blacklist_score + - blacklist_grade + - header_score + - header_grade + - content_score + - content_grade + properties: + dns_score: + type: integer + minimum: 0 + maximum: 100 + description: DNS records score (in percentage) + example: 42 + dns_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + authentication_score: + type: integer + minimum: 0 + maximum: 100 + description: SPF/DKIM/DMARC score (in percentage) + example: 28 + authentication_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + spam_score: + type: integer + minimum: 0 + maximum: 100 + description: Spam filter score (SpamAssassin + rspamd combined, in percentage) + example: 15 + spam_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + blacklist_score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist check score (in percentage) + example: 20 + blacklist_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + header_score: + type: integer + minimum: 0 + maximum: 100 + description: Header quality score (in percentage) + example: 9 + header_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + content_score: + type: integer + minimum: 0 + maximum: 100 + description: Content quality score (in percentage) + example: 18 + content_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" + + ContentAnalysis: + type: object + properties: + has_html: + type: boolean + description: Whether email contains HTML part + example: true + has_plaintext: + type: boolean + description: Whether email contains plaintext part + example: true + html_issues: + type: array + items: + $ref: '#/components/schemas/ContentIssue' + description: Issues found in HTML content + links: + type: array + items: + $ref: '#/components/schemas/LinkCheck' + description: Analysis of links found in the email + images: + type: array + items: + $ref: '#/components/schemas/ImageCheck' + description: Analysis of images in the email + text_to_image_ratio: + type: number + format: float + description: Ratio of text to images (higher is better) + example: 0.75 + has_unsubscribe_link: + type: boolean + description: Whether email contains an unsubscribe link + example: true + unsubscribe_methods: + type: array + items: + type: string + enum: [link, mailto, list-unsubscribe-header, one-click] + description: Available unsubscribe methods + example: ["link", "list-unsubscribe-header"] + + ContentIssue: + type: object + required: + - type + - severity + - message + properties: + type: + type: string + enum: [broken_html, missing_alt, excessive_images, obfuscated_url, suspicious_link, dangerous_html] + description: Type of content issue + example: "missing_alt" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "medium" + message: + type: string + description: Human-readable description + example: "3 images are missing alt attributes" + location: + type: string + description: Where the issue was found + example: "HTML body line 42" + advice: + type: string + description: How to fix this issue + example: "Add descriptive alt text to all images for better accessibility and deliverability" + + LinkCheck: + type: object + required: + - url + - status + properties: + url: + type: string + format: uri + description: The URL found in the email + example: "https://example.com/page" + status: + type: string + enum: [valid, broken, suspicious, redirected, timeout] + description: Link validation status + example: "valid" + http_code: + type: integer + description: HTTP status code received + example: 200 + redirect_chain: + type: array + items: + type: string + description: URLs in the redirect chain, if any + example: ["https://example.com", "https://www.example.com"] + is_shortened: + type: boolean + description: Whether this is a URL shortener + example: false + + ImageCheck: + type: object + required: + - has_alt + properties: + src: + type: string + description: Image source URL or path + example: "https://example.com/logo.png" + has_alt: + type: boolean + description: Whether image has alt attribute + example: true + alt_text: + type: string + description: Alt text content + example: "Company Logo" + is_tracking_pixel: + type: boolean + description: Whether this appears to be a tracking pixel (1x1 image) + example: false + + HeaderAnalysis: + type: object + properties: + has_mime_structure: + type: boolean + description: Whether body has a MIME structure + example: true + headers: + type: object + additionalProperties: + $ref: '#/components/schemas/HeaderCheck' + description: Map of header names to their check results (e.g., "from", "to", "dkim-signature") + example: + from: + present: true + value: "sender@example.com" + valid: true + importance: "required" + date: + present: true + value: "Mon, 1 Jan 2024 12:00:00 +0000" + valid: true + importance: "required" + received_chain: + type: array + items: + $ref: '#/components/schemas/ReceivedHop' + description: Chain of Received headers showing email path + domain_alignment: + $ref: '#/components/schemas/DomainAlignment' + issues: + type: array + items: + $ref: '#/components/schemas/HeaderIssue' + description: Issues found in headers + + HeaderCheck: + type: object + required: + - present + properties: + present: + type: boolean + description: Whether the header is present + example: true + value: + type: string + description: Header value + example: "sender@example.com" + valid: + type: boolean + description: Whether the value is valid/well-formed + example: true + importance: + type: string + enum: [required, recommended, optional, newsletter] + description: How important this header is for deliverability + example: "required" + issues: + type: array + items: + type: string + description: Any issues with this header + example: ["Invalid date format"] + + ReceivedHop: + type: object + properties: + from: + type: string + description: Sending server hostname + example: "mail.example.com" + by: + type: string + description: Receiving server hostname + example: "mx.receiver.com" + with: + type: string + description: Protocol used + example: "ESMTPS" + id: + type: string + description: Message ID at this hop + timestamp: + type: string + format: date-time + description: When this hop occurred + ip: + type: string + description: IP address of the sending server (IPv4 or IPv6) + example: "192.0.2.1" + reverse: + type: string + description: Reverse DNS (PTR record) for the IP address + example: "mail.example.com" + + DKIMDomainInfo: + type: object + required: + - domain + - org_domain + properties: + domain: + type: string + description: DKIM signature domain + example: "mail.example.com" + org_domain: + type: string + description: Organizational domain extracted from DKIM domain (using Public Suffix List) + example: "example.com" + + DomainAlignment: + type: object + properties: + from_domain: + type: string + description: Domain from From header + example: "example.com" + from_org_domain: + type: string + description: Organizational domain extracted from From header (using Public Suffix List) + example: "example.com" + return_path_domain: + type: string + description: Domain from Return-Path header + example: "example.com" + return_path_org_domain: + type: string + description: Organizational domain extracted from Return-Path header (using Public Suffix List) + example: "example.com" + dkim_domains: + type: array + items: + $ref: '#/components/schemas/DKIMDomainInfo' + description: Domains from DKIM signatures with their organizational domains + aligned: + type: boolean + description: Whether all domains align (strict alignment - exact match) + example: true + relaxed_aligned: + type: boolean + description: Whether domains satisfy relaxed alignment (organizational domain match) + example: true + issues: + type: array + items: + type: string + description: Alignment issues + example: ["Return-Path domain does not match From domain"] + + HeaderIssue: + type: object + required: + - header + - severity + - message + properties: + header: + type: string + description: Header name + example: "Date" + severity: + type: string + enum: [critical, high, medium, low, info] + description: Issue severity + example: "medium" + message: + type: string + description: Human-readable description + example: "Date header is in the future" + advice: + type: string + description: How to fix this issue + example: "Ensure your mail server clock is synchronized with NTP" + + AuthenticationResults: + type: object + properties: + spf: + $ref: '#/components/schemas/AuthResult' + dkim: + type: array + items: + $ref: '#/components/schemas/AuthResult' + dmarc: + $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' + arc: + $ref: '#/components/schemas/ARCResult' + iprev: + $ref: '#/components/schemas/IPRevResult' + x_google_dkim: + $ref: '#/components/schemas/AuthResult' + description: Google-specific DKIM authentication result (x-google-dkim) + x_aligned_from: + $ref: '#/components/schemas/AuthResult' + description: X-Aligned-From authentication result (checks address alignment) + + AuthResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, invalid, missing, none, neutral, softfail, temperror, permerror, declined, domain_pass, orgdomain_pass, skipped] + description: Authentication result + example: "pass" + domain: + type: string + description: Domain being authenticated + example: "example.com" + selector: + type: string + description: DKIM selector (for DKIM only) + example: "default" + details: + type: string + description: Additional details about the result + + ARCResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, none] + description: Overall ARC chain validation result + example: "pass" + chain_valid: + type: boolean + description: Whether the ARC chain signatures are valid + example: true + chain_length: + type: integer + description: Number of ARC sets in the chain + example: 2 + details: + type: string + description: Additional details about ARC validation + example: "ARC chain valid with 2 intermediaries" + + IPRevResult: + type: object + required: + - result + properties: + result: + type: string + enum: [pass, fail, temperror, permerror] + description: IP reverse DNS lookup result + example: "pass" + ip: + type: string + description: IP address that was checked + example: "195.110.101.58" + hostname: + type: string + description: Hostname from reverse DNS lookup (PTR record) + example: "authsmtp74.register.it" + details: + type: string + description: Additional details about the IP reverse lookup + example: "smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)" + + SpamAssassinResult: + type: object + required: + - score + - required_score + - is_spam + - test_details + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: SpamAssassin deliverability score (0-100, higher is better) + example: 80 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for SpamAssassin deliverability score + example: "B" + version: + type: string + description: SpamAssassin version + example: "SpamAssassin 4.0.1" + score: + type: number + format: float + description: SpamAssassin spam score + example: 2.3 + required_score: + type: number + format: float + description: Threshold for spam classification + example: 5.0 + is_spam: + type: boolean + description: Whether message is classified as spam + example: false + tests: + type: array + items: + type: string + description: List of triggered SpamAssassin tests + example: ["BAYES_00", "DKIM_SIGNED"] + test_details: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of test names to their detailed results + example: + BAYES_00: + name: "BAYES_00" + score: -1.9 + description: "Bayes spam probability is 0 to 1%" + DKIM_SIGNED: + name: "DKIM_SIGNED" + score: 0.1 + description: "Message has a DKIM or DK signature, not necessarily valid" + report: + type: string + description: Full SpamAssassin report + + SpamTestDetail: + type: object + required: + - name + - score + properties: + name: + type: string + description: Test name + example: "BAYES_00" + score: + type: number + format: float + description: Score contribution of this test + example: -1.9 + params: + type: string + description: Symbol parameters or options + example: "0.02" + description: + type: string + description: Human-readable description of what this test checks + example: "Bayes spam probability is 0 to 1%" + + RspamdResult: + type: object + required: + - score + - threshold + - is_spam + - symbols + properties: + deliverability_score: + type: integer + minimum: 0 + maximum: 100 + description: rspamd deliverability score (0-100, higher is better) + example: 85 + deliverability_grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade for rspamd deliverability score + example: "A" + score: + type: number + format: float + description: rspamd spam score + example: -3.91 + threshold: + type: number + format: float + description: Score threshold for spam classification + example: 15.0 + action: + type: string + description: rspamd action (no action, add header, rewrite subject, soft reject, reject) + example: "no action" + is_spam: + type: boolean + description: Whether message is classified as spam (action is reject or soft reject) + example: false + server: + type: string + description: rspamd server that processed the message + example: "rspamd.example.com" + symbols: + type: object + additionalProperties: + $ref: '#/components/schemas/SpamTestDetail' + description: Map of triggered rspamd symbols to their details + example: + BAYES_HAM: + name: "BAYES_HAM" + score: -1.9 + params: "0.02" + report: + type: string + description: Full rspamd report (raw X-Spamd-Result header) + + + DNSResults: + type: object + required: + - from_domain + properties: + from_domain: + type: string + description: From Domain name + example: "example.com" + rp_domain: + type: string + description: Return Path Domain name + example: "example.com" + from_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the From domain + rp_mx_records: + type: array + items: + $ref: '#/components/schemas/MXRecord' + description: MX records for the Return-Path domain + spf_records: + type: array + items: + $ref: '#/components/schemas/SPFRecord' + description: SPF records found (includes resolved include directives) + dkim_records: + type: array + items: + $ref: '#/components/schemas/DKIMRecord' + description: DKIM records found + dmarc_record: + $ref: '#/components/schemas/DMARCRecord' + bimi_record: + $ref: '#/components/schemas/BIMIRecord' + ptr_records: + type: array + items: + type: string + description: PTR (reverse DNS) records for the sender IP address + example: ["mail.example.com", "smtp.example.com"] + ptr_forward_records: + type: array + items: + type: string + description: A or AAAA records resolved from the PTR hostnames (forward confirmation) + example: ["192.0.2.1", "2001:db8::1"] + errors: + type: array + items: + type: string + description: DNS lookup errors + + MXRecord: + type: object + required: + - host + - priority + - valid + properties: + host: + type: string + description: MX hostname + example: "mail.example.com" + priority: + type: integer + format: uint16 + description: MX priority (lower is higher priority) + example: 10 + valid: + type: boolean + description: Whether the MX record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "Failed to lookup MX records" + + SPFRecord: + type: object + required: + - valid + properties: + domain: + type: string + description: Domain this SPF record belongs to + example: "example.com" + record: + type: string + description: SPF record content + example: "v=spf1 include:_spf.example.com ~all" + valid: + type: boolean + description: Whether the SPF record is valid + example: true + all_qualifier: + type: string + enum: ["+", "-", "~", "?"] + description: "Qualifier for the 'all' mechanism: + (pass), - (fail), ~ (softfail), ? (neutral)" + example: "~" + error: + type: string + description: Error message if validation failed + example: "No SPF record found" + + DKIMRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: DKIM selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: DKIM record content + example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." + valid: + type: boolean + description: Whether the DKIM record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DKIM record found" + + DMARCRecord: + type: object + required: + - valid + properties: + record: + type: string + description: DMARC record content + example: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" + policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC policy + example: "quarantine" + subdomain_policy: + type: string + enum: [none, quarantine, reject, unknown] + description: DMARC subdomain policy (sp tag) - policy for subdomains if different from main policy + example: "quarantine" + percentage: + type: integer + minimum: 0 + maximum: 100 + description: Percentage of messages subjected to filtering (pct tag, default 100) + example: 100 + spf_alignment: + type: string + enum: [relaxed, strict] + description: SPF alignment mode (aspf tag) + example: "relaxed" + dkim_alignment: + type: string + enum: [relaxed, strict] + description: DKIM alignment mode (adkim tag) + example: "relaxed" + valid: + type: boolean + description: Whether the DMARC record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No DMARC record found" + + BIMIRecord: + type: object + required: + - selector + - domain + - valid + properties: + selector: + type: string + description: BIMI selector + example: "default" + domain: + type: string + description: Domain name + example: "example.com" + record: + type: string + description: BIMI record content + example: "v=BIMI1; l=https://example.com/logo.svg" + logo_url: + type: string + format: uri + description: URL to the brand logo (SVG) + example: "https://example.com/logo.svg" + vmc_url: + type: string + format: uri + description: URL to Verified Mark Certificate (optional) + example: "https://example.com/vmc.pem" + valid: + type: boolean + description: Whether the BIMI record is valid + example: true + error: + type: string + description: Error message if validation failed + example: "No BIMI record found" + + BlacklistCheck: + type: object + required: + - rbl + - listed + properties: + rbl: + type: string + description: RBL/DNSBL name + example: "zen.spamhaus.org" + listed: + type: boolean + description: Whether IP is listed + example: false + response: + type: string + description: RBL response code or message + example: "127.0.0.2" + error: + type: string + description: RBL error if any + + Status: + type: object + required: + - status + - version + properties: + status: + type: string + enum: [healthy, degraded, unhealthy] + description: Overall service status + example: "healthy" + version: + type: string + description: Service version + example: "0.1.0-dev" + components: + type: object + properties: + database: + type: string + enum: [up, down] + example: "up" + mta: + type: string + enum: [up, down] + example: "up" + uptime: + type: integer + description: Service uptime in seconds + example: 3600 + + Error: + type: object + required: + - error + - message + properties: + error: + type: string + description: Error code + example: "not_found" + message: + type: string + description: Human-readable error message + example: "Test not found" + details: + type: string + description: Additional error details + + DomainTestRequest: + type: object + required: + - domain + properties: + domain: + type: string + pattern: '^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' + description: Domain name to test (e.g., example.com) + example: "example.com" + + DomainTestResponse: + type: object + required: + - domain + - score + - grade + - dns_results + properties: + domain: + type: string + description: The tested domain name + example: "example.com" + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall domain configuration score (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A" + dns_results: + $ref: '#/components/schemas/DNSResults' + + BlacklistCheckRequest: + type: object + required: + - ip + properties: + ip: + type: string + description: IPv4 or IPv6 address to check against blacklists + example: "192.0.2.1" + pattern: '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^([0-9a-fA-F]{0,4}:){1,6}:([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$' + + BlacklistCheckResponse: + type: object + required: + - ip + - blacklists + - listed_count + - score + - grade + properties: + ip: + type: string + description: The IP address that was checked + example: "192.0.2.1" + blacklists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of blacklist check results + listed_count: + type: integer + description: Number of blacklists that have this IP listed + example: 0 + score: + type: integer + minimum: 0 + maximum: 100 + description: Blacklist score (0-100, higher is better) + example: 100 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score + example: "A+" + whitelists: + type: array + items: + $ref: '#/components/schemas/BlacklistCheck' + description: List of DNS whitelist check results (informational only) + + TestSummary: + type: object + required: + - test_id + - score + - grade + - created_at + properties: + test_id: + type: string + pattern: '^[a-z0-9-]+$' + description: Test identifier (base32-encoded with hyphens) + score: + type: integer + minimum: 0 + maximum: 100 + description: Overall deliverability score (0-100) + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade + from_domain: + type: string + description: Sender domain extracted from the report + created_at: + type: string + format: date-time + + TestListResponse: + type: object + required: + - tests + - total + - offset + - limit + properties: + tests: + type: array + items: + $ref: '#/components/schemas/TestSummary' + total: + type: integer + description: Total number of tests + offset: + type: integer + description: Current offset + limit: + type: integer + description: Current limit diff --git a/generate.go b/generate.go index d1ee5ab..324c52c 100644 --- a/generate.go +++ b/generate.go @@ -21,5 +21,5 @@ package main -//go:generate go tool oapi-codegen -config api/config-models.yaml api/openapi.yaml +//go:generate go tool oapi-codegen -config api/config-models.yaml api/schemas.yaml //go:generate go tool oapi-codegen -config api/config-server.yaml api/openapi.yaml diff --git a/internal/api/handlers.go b/internal/api/handlers.go index e524b40..de2d5df 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -31,6 +31,7 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/storage" "git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/version" @@ -40,8 +41,8 @@ import ( // This interface breaks the circular dependency with pkg/analyzer type EmailAnalyzer interface { AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (reportJSON []byte, err error) - AnalyzeDomain(domain string) (dnsResults *DNSResults, score int, grade string) - CheckBlacklistIP(ip string) (checks []BlacklistCheck, whitelists []BlacklistCheck, listedCount int, score int, grade string, err error) + AnalyzeDomain(domain string) (dnsResults *model.DNSResults, score int, grade string) + CheckBlacklistIP(ip string) (checks []model.BlacklistCheck, whitelists []model.BlacklistCheck, listedCount int, score int, grade string, err error) } // APIHandler implements the ServerInterface for handling API requests @@ -79,11 +80,11 @@ func (h *APIHandler) CreateTest(c *gin.Context) { ) // Return response - c.JSON(http.StatusCreated, TestResponse{ + c.JSON(http.StatusCreated, model.TestResponse{ Id: base32ID, Email: openapi_types.Email(email), - Status: TestResponseStatusPending, - Message: stringPtr("Send your test email to the given address"), + Status: model.TestResponseStatusPending, + Message: utils.PtrTo("Send your test email to the given address"), }) } @@ -93,10 +94,10 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -104,20 +105,20 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { // Check if a report exists for this test ID reportExists, err := h.storage.ReportExists(testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to check test status", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Determine status based on report existence - var apiStatus TestStatus + var apiStatus model.TestStatus if reportExists { - apiStatus = TestStatusAnalyzed + apiStatus = model.TestStatusAnalyzed } else { - apiStatus = TestStatusPending + apiStatus = model.TestStatusPending } // Generate test email address using Base32-encoded UUID @@ -127,7 +128,7 @@ func (h *APIHandler) GetTest(c *gin.Context, id string) { h.config.Email.Domain, ) - c.JSON(http.StatusOK, Test{ + c.JSON(http.StatusOK, model.Test{ Id: id, Email: openapi_types.Email(email), Status: apiStatus, @@ -140,10 +141,10 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -151,16 +152,16 @@ func (h *APIHandler) GetReport(c *gin.Context, id string) { reportJSON, _, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Report not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve report", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -175,10 +176,10 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -186,16 +187,16 @@ func (h *APIHandler) GetRawEmail(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve raw email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -209,10 +210,10 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Convert base32 ID to UUID testUUID, err := utils.Base32ToUUID(id) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_id", Message: "Invalid test ID format", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -221,16 +222,16 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { _, rawEmail, err := h.storage.GetReport(testUUID) if err != nil { if err == storage.ErrNotFound { - c.JSON(http.StatusNotFound, Error{ + c.JSON(http.StatusNotFound, model.Error{ Error: "not_found", Message: "Email not found", }) return } - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to retrieve email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -238,20 +239,20 @@ func (h *APIHandler) ReanalyzeReport(c *gin.Context, id string) { // Re-analyze the email using the current analyzer reportJSON, err := h.analyzer.AnalyzeEmailBytes(rawEmail, testUUID) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "analysis_error", Message: "Failed to re-analyze email", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Update the report in storage if err := h.storage.UpdateReport(testUUID, reportJSON); err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to update report", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -267,24 +268,24 @@ func (h *APIHandler) GetStatus(c *gin.Context) { uptime := int(time.Since(h.startTime).Seconds()) // Check database connectivity by trying to check if a report exists - dbStatus := StatusComponentsDatabaseUp + dbStatus := model.StatusComponentsDatabaseUp if _, err := h.storage.ReportExists(uuid.New()); err != nil { - dbStatus = StatusComponentsDatabaseDown + dbStatus = model.StatusComponentsDatabaseDown } // Determine overall status - overallStatus := Healthy - if dbStatus == StatusComponentsDatabaseDown { - overallStatus = Unhealthy + overallStatus := model.Healthy + if dbStatus == model.StatusComponentsDatabaseDown { + overallStatus = model.Unhealthy } - mtaStatus := StatusComponentsMtaUp - c.JSON(http.StatusOK, Status{ + mtaStatus := model.StatusComponentsMtaUp + c.JSON(http.StatusOK, model.Status{ Status: overallStatus, Version: version.Version, Components: &struct { - Database *StatusComponentsDatabase `json:"database,omitempty"` - Mta *StatusComponentsMta `json:"mta,omitempty"` + Database *model.StatusComponentsDatabase `json:"database,omitempty"` + Mta *model.StatusComponentsMta `json:"mta,omitempty"` }{ Database: &dbStatus, Mta: &mtaStatus, @@ -296,14 +297,14 @@ func (h *APIHandler) GetStatus(c *gin.Context) { // TestDomain performs synchronous domain analysis // (POST /domain) func (h *APIHandler) TestDomain(c *gin.Context) { - var request DomainTestRequest + var request model.DomainTestRequest // Bind and validate request if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_request", Message: "Invalid request body", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -312,28 +313,28 @@ func (h *APIHandler) TestDomain(c *gin.Context) { dnsResults, score, grade := h.analyzer.AnalyzeDomain(request.Domain) // Convert grade string to DomainTestResponseGrade enum - var responseGrade DomainTestResponseGrade + var responseGrade model.DomainTestResponseGrade switch grade { case "A+": - responseGrade = DomainTestResponseGradeA + responseGrade = model.DomainTestResponseGradeA case "A": - responseGrade = DomainTestResponseGradeA1 + responseGrade = model.DomainTestResponseGradeA1 case "B": - responseGrade = DomainTestResponseGradeB + responseGrade = model.DomainTestResponseGradeB case "C": - responseGrade = DomainTestResponseGradeC + responseGrade = model.DomainTestResponseGradeC case "D": - responseGrade = DomainTestResponseGradeD + responseGrade = model.DomainTestResponseGradeD case "E": - responseGrade = DomainTestResponseGradeE + responseGrade = model.DomainTestResponseGradeE case "F": - responseGrade = DomainTestResponseGradeF + responseGrade = model.DomainTestResponseGradeF default: - responseGrade = DomainTestResponseGradeF + responseGrade = model.DomainTestResponseGradeF } // Build response - response := DomainTestResponse{ + response := model.DomainTestResponse{ Domain: request.Domain, Score: score, Grade: responseGrade, @@ -346,14 +347,14 @@ func (h *APIHandler) TestDomain(c *gin.Context) { // CheckBlacklist checks an IP address against DNS blacklists // (POST /blacklist) func (h *APIHandler) CheckBlacklist(c *gin.Context) { - var request BlacklistCheckRequest + var request model.BlacklistCheckRequest // Bind and validate request if err := c.ShouldBindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_request", Message: "Invalid request body", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } @@ -361,22 +362,22 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { // Perform blacklist check using analyzer checks, whitelists, listedCount, score, grade, err := h.analyzer.CheckBlacklistIP(request.Ip) if err != nil { - c.JSON(http.StatusBadRequest, Error{ + c.JSON(http.StatusBadRequest, model.Error{ Error: "invalid_ip", Message: "Invalid IP address", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } // Build response - response := BlacklistCheckResponse{ + response := model.BlacklistCheckResponse{ Ip: request.Ip, Blacklists: checks, Whitelists: &whitelists, ListedCount: listedCount, Score: score, - Grade: BlacklistCheckResponseGrade(grade), + Grade: model.BlacklistCheckResponseGrade(grade), } c.JSON(http.StatusOK, response) @@ -386,7 +387,7 @@ func (h *APIHandler) CheckBlacklist(c *gin.Context) { // (GET /tests) func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { if h.config.DisableTestList { - c.JSON(http.StatusForbidden, Error{ + c.JSON(http.StatusForbidden, model.Error{ Error: "feature_disabled", Message: "Test listing is disabled on this instance", }) @@ -405,51 +406,17 @@ func (h *APIHandler) ListTests(c *gin.Context, params ListTestsParams) { } } - summaries, total, err := h.storage.ListReportSummaries(offset, limit) + tests, total, err := h.storage.ListReportSummaries(offset, limit) if err != nil { - c.JSON(http.StatusInternalServerError, Error{ + c.JSON(http.StatusInternalServerError, model.Error{ Error: "internal_error", Message: "Failed to list tests", - Details: stringPtr(err.Error()), + Details: utils.PtrTo(err.Error()), }) return } - tests := make([]TestSummary, 0, len(summaries)) - for _, s := range summaries { - base32ID := utils.UUIDToBase32(s.TestID) - - var grade TestSummaryGrade - switch s.Grade { - case "A+": - grade = TestSummaryGradeA - case "A": - grade = TestSummaryGradeA1 - case "B": - grade = TestSummaryGradeB - case "C": - grade = TestSummaryGradeC - case "D": - grade = TestSummaryGradeD - case "E": - grade = TestSummaryGradeE - default: - grade = TestSummaryGradeF - } - - summary := TestSummary{ - TestId: base32ID, - Score: s.Score, - Grade: grade, - CreatedAt: s.CreatedAt, - } - if s.FromDomain != "" { - summary.FromDomain = stringPtr(s.FromDomain) - } - tests = append(tests, summary) - } - - c.JSON(http.StatusOK, TestListResponse{ + c.JSON(http.StatusOK, model.TestListResponse{ Tests: tests, Total: int(total), Offset: offset, diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1077e74..86605df 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -30,6 +30,9 @@ import ( "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" + + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) var ( @@ -45,21 +48,12 @@ type Storage interface { ReportExists(testID uuid.UUID) (bool, error) UpdateReport(testID uuid.UUID, reportJSON []byte) error DeleteOldReports(olderThan time.Time) (int64, error) - ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) + ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) // Close closes the database connection Close() error } -// ReportSummary is a lightweight projection of Report for listing -type ReportSummary struct { - TestID uuid.UUID - Score int - Grade string - FromDomain string - CreatedAt time.Time -} - // DBStorage implements Storage using GORM type DBStorage struct { db *gorm.DB @@ -149,15 +143,24 @@ func (s *DBStorage) DeleteOldReports(olderThan time.Time) (int64, error) { return result.RowsAffected, nil } +// reportSummaryRow is used internally to scan SQL results before converting to model.TestSummary +type reportSummaryRow struct { + TestID uuid.UUID + Score int + Grade string + FromDomain string + CreatedAt time.Time +} + // ListReportSummaries returns a paginated list of lightweight report summaries -func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int64, error) { +func (s *DBStorage) ListReportSummaries(offset, limit int) ([]model.TestSummary, int64, error) { var total int64 if err := s.db.Model(&Report{}).Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count reports: %w", err) } if total == 0 { - return []ReportSummary{}, 0, nil + return []model.TestSummary{}, 0, nil } var selectExpr string @@ -168,25 +171,41 @@ func (s *DBStorage) ListReportSummaries(offset, limit int) ([]ReportSummary, int `convert_from(report_json, 'UTF8')::jsonb->>'grade' as grade, ` + `convert_from(report_json, 'UTF8')::jsonb->'dns_results'->>'from_domain' as from_domain, ` + `created_at` - default: // sqlite + case "sqlite": selectExpr = `test_id, ` + `json_extract(report_json, '$.score') as score, ` + `json_extract(report_json, '$.grade') as grade, ` + `json_extract(report_json, '$.dns_results.from_domain') as from_domain, ` + `created_at` + default: + return nil, 0, fmt.Errorf("history tests list not implemented in this database dialect") } - var summaries []ReportSummary + var rows []reportSummaryRow err := s.db.Model(&Report{}). Select(selectExpr). Order("created_at DESC"). Offset(offset). Limit(limit). - Scan(&summaries).Error + Scan(&rows).Error if err != nil { return nil, 0, fmt.Errorf("failed to list report summaries: %w", err) } + summaries := make([]model.TestSummary, 0, len(rows)) + for _, r := range rows { + s := model.TestSummary{ + TestId: utils.UUIDToBase32(r.TestID), + Score: r.Score, + Grade: model.TestSummaryGrade(r.Grade), + CreatedAt: r.CreatedAt, + } + if r.FromDomain != "" { + s.FromDomain = utils.PtrTo(r.FromDomain) + } + summaries = append(summaries, s) + } + return summaries, total, nil } diff --git a/internal/api/helpers.go b/internal/utils/ptr.go similarity index 91% rename from internal/api/helpers.go rename to internal/utils/ptr.go index cce306a..748d6ba 100644 --- a/internal/api/helpers.go +++ b/internal/utils/ptr.go @@ -1,5 +1,5 @@ // This file is part of the happyDeliver (R) project. -// Copyright (c) 2025 happyDomain +// Copyright (c) 2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. @@ -19,11 +19,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package api - -func stringPtr(s string) *string { - return &s -} +package utils // PtrTo returns a pointer to the provided value func PtrTo[T any](v T) *T { diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index f21d1f8..5f57df3 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -28,7 +28,7 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/config" ) @@ -59,7 +59,7 @@ func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer { type AnalysisResult struct { Email *EmailMessage Results *AnalysisResults - Report *api.Report + Report *model.Report } // AnalyzeEmailBytes performs complete email analysis from raw bytes @@ -113,7 +113,7 @@ func (a *APIAdapter) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) ([]byt } // AnalyzeDomain performs DNS analysis for a domain and returns the results -func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) { +func (a *APIAdapter) AnalyzeDomain(domain string) (*model.DNSResults, int, string) { // Perform DNS analysis dnsResults := a.analyzer.generator.dnsAnalyzer.AnalyzeDomainOnly(domain) @@ -124,7 +124,7 @@ func (a *APIAdapter) AnalyzeDomain(domain string) (*api.DNSResults, int, string) } // CheckBlacklistIP checks a single IP address against DNS blacklists and whitelists -func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.BlacklistCheck, int, int, string, error) { +func (a *APIAdapter) CheckBlacklistIP(ip string) ([]model.BlacklistCheck, []model.BlacklistCheck, int, int, string, error) { // Check the IP against all configured RBLs checks, listedCount, err := a.analyzer.generator.rblChecker.CheckIP(ip) if err != nil { @@ -134,7 +134,7 @@ func (a *APIAdapter) CheckBlacklistIP(ip string) ([]api.BlacklistCheck, []api.Bl // Calculate score using the existing function // Create a minimal RBLResults structure for scoring results := &DNSListResults{ - Checks: map[string][]api.BlacklistCheck{ip: checks}, + Checks: map[string][]model.BlacklistCheck{ip: checks}, IPsChecked: []string{ip}, ListedCount: listedCount, } diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 2beeb1f..da31b1c 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -24,7 +24,7 @@ package analyzer import ( "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) // AuthenticationAnalyzer analyzes email authentication results @@ -38,8 +38,8 @@ func NewAuthenticationAnalyzer(receiverHostname string) *AuthenticationAnalyzer } // AnalyzeAuthentication extracts and analyzes authentication results from email headers -func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { - results := &api.AuthenticationResults{} +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *model.AuthenticationResults { + results := &model.AuthenticationResults{} // Parse Authentication-Results headers authHeaders := email.GetAuthenticationResults(a.receiverHostname) @@ -65,7 +65,7 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api // parseAuthenticationResultsHeader parses an Authentication-Results header // Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com -func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) { +func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *model.AuthenticationResults) { // Split by semicolon to get individual results parts := strings.Split(header, ";") if len(parts) < 2 { @@ -91,7 +91,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, dkimResult := a.parseDKIMResult(part) if dkimResult != nil { if results.Dkim == nil { - dkimList := []api.AuthResult{*dkimResult} + dkimList := []model.AuthResult{*dkimResult} results.Dkim = &dkimList } else { *results.Dkim = append(*results.Dkim, *dkimResult) @@ -145,7 +145,7 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, // CalculateAuthenticationScore calculates the authentication score from auth results // Returns a score from 0-100 where higher is better -func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) { +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.AuthenticationResults) (int, string) { if results == nil { return 0, "" } diff --git a/pkg/analyzer/authentication_arc.go b/pkg/analyzer/authentication_arc.go index 01b7505..e7333ce 100644 --- a/pkg/analyzer/authentication_arc.go +++ b/pkg/analyzer/authentication_arc.go @@ -27,7 +27,8 @@ import ( "slices" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // textprotoCanonical converts a header name to canonical form @@ -52,24 +53,24 @@ func pluralize(count int) string { // parseARCResult parses ARC result from Authentication-Results // Example: arc=pass -func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { - result := &api.ARCResult{} +func (a *AuthenticationAnalyzer) parseARCResult(part string) *model.ARCResult { + result := &model.ARCResult{} // Extract result (pass, fail, none) re := regexp.MustCompile(`arc=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.ARCResultResult(resultStr) + result.Result = model.ARCResultResult(resultStr) } - result.Details = api.PtrTo(strings.TrimPrefix(part, "arc=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "arc=")) return result } // parseARCHeaders parses ARC headers from email message // ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal -func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult { +func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *model.ARCResult { // Get all ARC-related headers arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] @@ -80,8 +81,8 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe return nil } - result := &api.ARCResult{ - Result: api.ARCResultResultNone, + result := &model.ARCResult{ + Result: model.ARCResultResultNone, } // Count the ARC chain length (number of sets) @@ -94,15 +95,15 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe // Determine overall result if chainLength == 0 { - result.Result = api.ARCResultResultNone + result.Result = model.ARCResultResultNone details := "No ARC chain present" result.Details = &details } else if !chainValid { - result.Result = api.ARCResultResultFail + result.Result = model.ARCResultResultFail details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) result.Details = &details } else { - result.Result = api.ARCResultResultPass + result.Result = model.ARCResultResultPass details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) result.Details = &details } @@ -111,7 +112,7 @@ func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCRe } // enhanceARCResult enhances an existing ARC result with chain information -func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *model.ARCResult) { if arcResult == nil { return } diff --git a/pkg/analyzer/authentication_arc_test.go b/pkg/analyzer/authentication_arc_test.go index 7f2f99e..ac51d0b 100644 --- a/pkg/analyzer/authentication_arc_test.go +++ b/pkg/analyzer/authentication_arc_test.go @@ -24,29 +24,29 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.ARCResultResult + expectedResult model.ARCResultResult }{ { name: "ARC pass", part: "arc=pass", - expectedResult: api.ARCResultResultPass, + expectedResult: model.ARCResultResultPass, }, { name: "ARC fail", part: "arc=fail", - expectedResult: api.ARCResultResultFail, + expectedResult: model.ARCResultResultFail, }, { name: "ARC none", part: "arc=none", - expectedResult: api.ARCResultResultNone, + expectedResult: model.ARCResultResultNone, }, } diff --git a/pkg/analyzer/authentication_bimi.go b/pkg/analyzer/authentication_bimi.go index 0d68281..9654ac7 100644 --- a/pkg/analyzer/authentication_bimi.go +++ b/pkg/analyzer/authentication_bimi.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseBIMIResult parses BIMI result from Authentication-Results // Example: bimi=pass header.d=example.com header.selector=default -func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`bimi=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,17 +55,17 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "bimi=")) return result } -func (a *AuthenticationAnalyzer) calculateBIMIScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateBIMIScore(results *model.AuthenticationResults) (score int) { if results.Bimi != nil { switch results.Bimi.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultDeclined: + case model.AuthResultResultDeclined: return 59 default: // fail return 0 diff --git a/pkg/analyzer/authentication_bimi_test.go b/pkg/analyzer/authentication_bimi_test.go index 7cb9c85..440f356 100644 --- a/pkg/analyzer/authentication_bimi_test.go +++ b/pkg/analyzer/authentication_bimi_test.go @@ -24,42 +24,42 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseBIMIResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string expectedSelector string }{ { name: "BIMI pass with domain and selector", part: "bimi=pass header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "BIMI fail", part: "bimi=fail header.d=example.com header.selector=default", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", expectedSelector: "default", }, { name: "BIMI with short form (d= and selector=)", part: "bimi=pass d=example.com selector=v1", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "v1", }, { name: "BIMI none", part: "bimi=none header.d=example.com", - expectedResult: api.AuthResultResultNone, + expectedResult: model.AuthResultResultNone, expectedDomain: "example.com", }, } diff --git a/pkg/analyzer/authentication_dkim.go b/pkg/analyzer/authentication_dkim.go index b6cf5f8..4165d8b 100644 --- a/pkg/analyzer/authentication_dkim.go +++ b/pkg/analyzer/authentication_dkim.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseDKIMResult parses DKIM result from Authentication-Results // Example: dkim=pass header.d=example.com header.s=selector1 -func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`dkim=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,18 +55,18 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dkim=")) return result } -func (a *AuthenticationAnalyzer) calculateDKIMScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateDKIMScore(results *model.AuthenticationResults) (score int) { // Expect at least one passing signature if results.Dkim != nil && len(*results.Dkim) > 0 { hasPass := false hasNonPass := false for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { + if dkim.Result == model.AuthResultResultPass { hasPass = true } else { hasNonPass = true diff --git a/pkg/analyzer/authentication_dkim_test.go b/pkg/analyzer/authentication_dkim_test.go index 3218639..0576854 100644 --- a/pkg/analyzer/authentication_dkim_test.go +++ b/pkg/analyzer/authentication_dkim_test.go @@ -24,35 +24,35 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string expectedSelector string }{ { name: "DKIM pass with domain and selector", part: "dkim=pass header.d=example.com header.s=default", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, { name: "DKIM fail", part: "dkim=fail header.d=example.com header.s=selector1", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", expectedSelector: "selector1", }, { name: "DKIM with short form (d= and s=)", part: "dkim=pass d=example.com s=default", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", expectedSelector: "default", }, diff --git a/pkg/analyzer/authentication_dmarc.go b/pkg/analyzer/authentication_dmarc.go index 329a5c9..c89093d 100644 --- a/pkg/analyzer/authentication_dmarc.go +++ b/pkg/analyzer/authentication_dmarc.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseDMARCResult parses DMARC result from Authentication-Results // Example: dmarc=pass action=none header.from=example.com -func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`dmarc=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.from) @@ -47,17 +48,17 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { result.Domain = &domain } - result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "dmarc=")) return result } -func (a *AuthenticationAnalyzer) calculateDMARCScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateDMARCScore(results *model.AuthenticationResults) (score int) { if results.Dmarc != nil { switch results.Dmarc.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultNone: + case model.AuthResultResultNone: return 33 default: // fail return 0 diff --git a/pkg/analyzer/authentication_dmarc_test.go b/pkg/analyzer/authentication_dmarc_test.go index 3b8fb08..69779a7 100644 --- a/pkg/analyzer/authentication_dmarc_test.go +++ b/pkg/analyzer/authentication_dmarc_test.go @@ -24,26 +24,26 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseDMARCResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string }{ { name: "DMARC pass", part: "dmarc=pass action=none header.from=example.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", }, { name: "DMARC fail", part: "dmarc=fail action=quarantine header.from=example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, } diff --git a/pkg/analyzer/authentication_iprev.go b/pkg/analyzer/authentication_iprev.go index e799094..3ed045c 100644 --- a/pkg/analyzer/authentication_iprev.go +++ b/pkg/analyzer/authentication_iprev.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseIPRevResult parses IP reverse lookup result from Authentication-Results // Example: iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it) -func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult { - result := &api.IPRevResult{} +func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *model.IPRevResult { + result := &model.IPRevResult{} // Extract result (pass, fail, temperror, permerror, none) re := regexp.MustCompile(`iprev=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.IPRevResultResult(resultStr) + result.Result = model.IPRevResultResult(resultStr) } // Extract IP address (smtp.remote-ip or remote-ip) @@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseIPRevResult(part string) *api.IPRevResult result.Hostname = &hostname } - result.Details = api.PtrTo(strings.TrimPrefix(part, "iprev=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "iprev=")) return result } -func (a *AuthenticationAnalyzer) calculateIPRevScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateIPRevScore(results *model.AuthenticationResults) (score int) { if results.Iprev != nil { switch results.Iprev.Result { - case api.Pass: + case model.Pass: return 100 default: // fail, temperror, permerror return 0 diff --git a/pkg/analyzer/authentication_iprev_test.go b/pkg/analyzer/authentication_iprev_test.go index 5b46995..55f85d5 100644 --- a/pkg/analyzer/authentication_iprev_test.go +++ b/pkg/analyzer/authentication_iprev_test.go @@ -24,71 +24,72 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseIPRevResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.IPRevResultResult + expectedResult model.IPRevResultResult expectedIP *string expectedHostname *string }{ { name: "IPRev pass with IP and hostname", part: "iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("195.110.101.58"), + expectedHostname: utils.PtrTo("authsmtp74.register.it"), }, { name: "IPRev pass without smtp prefix", part: "iprev=pass remote-ip=192.0.2.1 (mail.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("mail.example.com"), }, { name: "IPRev fail", part: "iprev=fail smtp.remote-ip=198.51.100.42 (unknown.host.com)", - expectedResult: api.Fail, - expectedIP: api.PtrTo("198.51.100.42"), - expectedHostname: api.PtrTo("unknown.host.com"), + expectedResult: model.Fail, + expectedIP: utils.PtrTo("198.51.100.42"), + expectedHostname: utils.PtrTo("unknown.host.com"), }, { name: "IPRev temperror", part: "iprev=temperror smtp.remote-ip=203.0.113.1", - expectedResult: api.Temperror, - expectedIP: api.PtrTo("203.0.113.1"), + expectedResult: model.Temperror, + expectedIP: utils.PtrTo("203.0.113.1"), expectedHostname: nil, }, { name: "IPRev permerror", part: "iprev=permerror smtp.remote-ip=192.0.2.100", - expectedResult: api.Permerror, - expectedIP: api.PtrTo("192.0.2.100"), + expectedResult: model.Permerror, + expectedIP: utils.PtrTo("192.0.2.100"), expectedHostname: nil, }, { name: "IPRev with IPv6", part: "iprev=pass smtp.remote-ip=2001:db8::1 (ipv6.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("2001:db8::1"), - expectedHostname: api.PtrTo("ipv6.example.com"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("2001:db8::1"), + expectedHostname: utils.PtrTo("ipv6.example.com"), }, { name: "IPRev with subdomain hostname", part: "iprev=pass smtp.remote-ip=192.0.2.50 (mail.subdomain.example.com)", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.50"), - expectedHostname: api.PtrTo("mail.subdomain.example.com"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.50"), + expectedHostname: utils.PtrTo("mail.subdomain.example.com"), }, { name: "IPRev pass without parentheses", part: "iprev=pass smtp.remote-ip=192.0.2.200", - expectedResult: api.Pass, - expectedIP: api.PtrTo("192.0.2.200"), + expectedResult: model.Pass, + expectedIP: utils.PtrTo("192.0.2.200"), expectedHostname: nil, }, } @@ -142,29 +143,29 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { tests := []struct { name string header string - expectedIPRevResult *api.IPRevResultResult + expectedIPRevResult *model.IPRevResultResult expectedIP *string expectedHostname *string }{ { name: "IPRev pass in Authentication-Results", header: "mx.google.com; iprev=pass smtp.remote-ip=195.110.101.58 (authsmtp74.register.it)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("195.110.101.58"), - expectedHostname: api.PtrTo("authsmtp74.register.it"), + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("195.110.101.58"), + expectedHostname: utils.PtrTo("authsmtp74.register.it"), }, { name: "IPRev with other authentication methods", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; iprev=pass smtp.remote-ip=192.0.2.1 (mail.example.com); dkim=pass header.d=example.com", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("mail.example.com"), + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("mail.example.com"), }, { name: "IPRev fail", header: "mx.google.com; iprev=fail smtp.remote-ip=198.51.100.42", - expectedIPRevResult: api.PtrTo(api.Fail), - expectedIP: api.PtrTo("198.51.100.42"), + expectedIPRevResult: utils.PtrTo(model.Fail), + expectedIP: utils.PtrTo("198.51.100.42"), expectedHostname: nil, }, { @@ -175,9 +176,9 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { { name: "Multiple IPRev results - only first is parsed", header: "mx.google.com; iprev=pass smtp.remote-ip=192.0.2.1 (first.com); iprev=fail smtp.remote-ip=192.0.2.2 (second.com)", - expectedIPRevResult: api.PtrTo(api.Pass), - expectedIP: api.PtrTo("192.0.2.1"), - expectedHostname: api.PtrTo("first.com"), + expectedIPRevResult: utils.PtrTo(model.Pass), + expectedIP: utils.PtrTo("192.0.2.1"), + expectedHostname: utils.PtrTo("first.com"), }, } @@ -185,7 +186,7 @@ func TestParseAuthenticationResultsHeader_IPRev(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check IPRev diff --git a/pkg/analyzer/authentication_spf.go b/pkg/analyzer/authentication_spf.go index fc41e3c..1488c98 100644 --- a/pkg/analyzer/authentication_spf.go +++ b/pkg/analyzer/authentication_spf.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseSPFResult parses SPF result from Authentication-Results // Example: spf=pass smtp.mailfrom=sender@example.com -func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`spf=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain @@ -51,13 +52,13 @@ func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { } } - result.Details = api.PtrTo(strings.TrimPrefix(part, "spf=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "spf=")) return result } // parseLegacySPF attempts to parse SPF from Received-SPF header -func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *model.AuthResult { receivedSPF := email.Header.Get("Received-SPF") if receivedSPF == "" { return nil @@ -73,13 +74,13 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe } } - result := &api.AuthResult{} + result := &model.AuthResult{} // Extract result (first word) parts := strings.Fields(receivedSPF) if len(parts) > 0 { resultStr := strings.ToLower(parts[0]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } result.Details = &receivedSPF @@ -97,14 +98,14 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe return result } -func (a *AuthenticationAnalyzer) calculateSPFScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateSPFScore(results *model.AuthenticationResults) (score int) { if results.Spf != nil { switch results.Spf.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: return 100 - case api.AuthResultResultNeutral, api.AuthResultResultNone: + case model.AuthResultResultNeutral, model.AuthResultResultNone: return 50 - case api.AuthResultResultSoftfail: + case model.AuthResultResultSoftfail: return 17 default: // fail, temperror, permerror return 0 diff --git a/pkg/analyzer/authentication_spf_test.go b/pkg/analyzer/authentication_spf_test.go index 960aef5..210505a 100644 --- a/pkg/analyzer/authentication_spf_test.go +++ b/pkg/analyzer/authentication_spf_test.go @@ -24,38 +24,39 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestParseSPFResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string }{ { name: "SPF pass with domain", part: "spf=pass smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "example.com", }, { name: "SPF fail", part: "spf=fail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, { name: "SPF neutral", part: "spf=neutral smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultNeutral, + expectedResult: model.AuthResultResultNeutral, expectedDomain: "example.com", }, { name: "SPF softfail", part: "spf=softfail smtp.mailfrom=sender@example.com", - expectedResult: api.AuthResultResultSoftfail, + expectedResult: model.AuthResultResultSoftfail, expectedDomain: "example.com", }, } @@ -84,7 +85,7 @@ func TestParseLegacySPF(t *testing.T) { tests := []struct { name string receivedSPF string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain *string expectNil bool }{ @@ -97,8 +98,8 @@ func TestParseLegacySPF(t *testing.T) { envelope-from="user@example.com"; helo=smtp.example.com; client-ip=192.0.2.10`, - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("example.com"), + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("example.com"), }, { name: "SPF fail with sender", @@ -109,43 +110,43 @@ func TestParseLegacySPF(t *testing.T) { sender="sender@test.com"; helo=smtp.test.com; client-ip=192.0.2.20`, - expectedResult: api.AuthResultResultFail, - expectedDomain: api.PtrTo("test.com"), + expectedResult: model.AuthResultResultFail, + expectedDomain: utils.PtrTo("test.com"), }, { name: "SPF softfail", receivedSPF: "softfail (example.com: transitioning domain of admin@example.org does not designate 192.0.2.30 as permitted sender) envelope-from=\"admin@example.org\"", - expectedResult: api.AuthResultResultSoftfail, - expectedDomain: api.PtrTo("example.org"), + expectedResult: model.AuthResultResultSoftfail, + expectedDomain: utils.PtrTo("example.org"), }, { name: "SPF neutral", receivedSPF: "neutral (example.com: 192.0.2.40 is neither permitted nor denied by domain of info@domain.net) envelope-from=\"info@domain.net\"", - expectedResult: api.AuthResultResultNeutral, - expectedDomain: api.PtrTo("domain.net"), + expectedResult: model.AuthResultResultNeutral, + expectedDomain: utils.PtrTo("domain.net"), }, { name: "SPF none", receivedSPF: "none (example.com: domain of noreply@company.io has no SPF record) envelope-from=\"noreply@company.io\"", - expectedResult: api.AuthResultResultNone, - expectedDomain: api.PtrTo("company.io"), + expectedResult: model.AuthResultResultNone, + expectedDomain: utils.PtrTo("company.io"), }, { name: "SPF temperror", receivedSPF: "temperror (example.com: error in processing SPF record) envelope-from=\"support@shop.example\"", - expectedResult: api.AuthResultResultTemperror, - expectedDomain: api.PtrTo("shop.example"), + expectedResult: model.AuthResultResultTemperror, + expectedDomain: utils.PtrTo("shop.example"), }, { name: "SPF permerror", receivedSPF: "permerror (example.com: domain of contact@invalid.test has invalid SPF record) envelope-from=\"contact@invalid.test\"", - expectedResult: api.AuthResultResultPermerror, - expectedDomain: api.PtrTo("invalid.test"), + expectedResult: model.AuthResultResultPermerror, + expectedDomain: utils.PtrTo("invalid.test"), }, { name: "SPF pass without domain extraction", receivedSPF: "pass (example.com: 192.0.2.50 is authorized)", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: nil, }, { @@ -156,8 +157,8 @@ func TestParseLegacySPF(t *testing.T) { { name: "SPF with unquoted envelope-from", receivedSPF: "pass (example.com: sender SPF authorized) envelope-from=postmaster@mail.example.net", - expectedResult: api.AuthResultResultPass, - expectedDomain: api.PtrTo("mail.example.net"), + expectedResult: model.AuthResultResultPass, + expectedDomain: utils.PtrTo("mail.example.net"), }, } diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 7122f53..44c1abb 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -24,76 +24,77 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string - results *api.AuthenticationResults + results *model.AuthenticationResults expectedScore int }{ { name: "Perfect authentication (SPF + DKIM + DMARC)", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, - Dmarc: &api.AuthResult{ - Result: api.AuthResultResultPass, + Dmarc: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, expectedScore: 73, // SPF=25 + DKIM=23 + DMARC=25 }, { name: "SPF and DKIM only", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, expectedScore: 48, // SPF=25 + DKIM=23 }, { name: "SPF fail, DKIM pass", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultFail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultFail, }, - Dkim: &[]api.AuthResult{ - {Result: api.AuthResultResultPass}, + Dkim: &[]model.AuthResult{ + {Result: model.AuthResultResultPass}, }, }, expectedScore: 23, // SPF=0 + DKIM=23 }, { name: "SPF softfail", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultSoftfail, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultSoftfail, }, }, expectedScore: 4, }, { name: "No authentication", - results: &api.AuthenticationResults{}, + results: &model.AuthenticationResults{}, expectedScore: 0, }, { name: "BIMI adds to score", - results: &api.AuthenticationResults{ - Spf: &api.AuthResult{ - Result: api.AuthResultResultPass, + results: &model.AuthenticationResults{ + Spf: &model.AuthResult{ + Result: model.AuthResultResultPass, }, - Bimi: &api.AuthResult{ - Result: api.AuthResultResultPass, + Bimi: &model.AuthResult{ + Result: model.AuthResultResultPass, }, }, expectedScore: 35, // SPF (25) + BIMI (10) @@ -117,30 +118,30 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { name string header string - expectedSPFResult *api.AuthResultResult + expectedSPFResult *model.AuthResultResult expectedSPFDomain *string expectedDKIMCount int - expectedDKIMResult *api.AuthResultResult - expectedDMARCResult *api.AuthResultResult + expectedDKIMResult *model.AuthResultResult + expectedDMARCResult *model.AuthResultResult expectedDMARCDomain *string - expectedBIMIResult *api.AuthResultResult - expectedARCResult *api.ARCResultResult + expectedBIMIResult *model.AuthResultResult + expectedARCResult *model.ARCResultResult }{ { name: "Complete authentication results", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCDomain: api.PtrTo("example.com"), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCDomain: utils.PtrTo("example.com"), }, { name: "SPF only", header: "mail.example.com; spf=pass smtp.mailfrom=user@domain.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("domain.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("domain.com"), expectedDKIMCount: 0, expectedDMARCResult: nil, }, @@ -149,68 +150,68 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass header.d=example.com header.s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "Multiple DKIM signatures", header: "mail.example.com; dkim=pass header.d=example.com header.s=s1; dkim=pass header.d=example.com header.s=s2", expectedSPFResult: nil, expectedDKIMCount: 2, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), expectedDMARCResult: nil, }, { name: "SPF fail with DKIM pass", header: "mail.example.com; spf=fail smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default", - expectedSPFResult: api.PtrTo(api.AuthResultResultFail), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultFail), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), expectedDMARCResult: nil, }, { name: "SPF softfail", header: "mail.example.com; spf=softfail smtp.mailfrom=sender@example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultSoftfail), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultSoftfail), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, expectedDMARCResult: nil, }, { name: "DMARC fail", header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=fail action=quarantine header.from=example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultFail), - expectedDMARCDomain: api.PtrTo("example.com"), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultFail), + expectedDMARCDomain: utils.PtrTo("example.com"), }, { name: "BIMI pass", header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, - expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "ARC pass", header: "mail.example.com; arc=pass", expectedSPFResult: nil, expectedDKIMCount: 0, - expectedARCResult: api.PtrTo(api.ARCResultResultPass), + expectedARCResult: utils.PtrTo(model.ARCResultResultPass), }, { name: "All authentication methods", header: "mx.google.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com header.s=default; dmarc=pass action=none header.from=example.com; bimi=pass header.d=example.com header.selector=v1; arc=pass", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCResult: api.PtrTo(api.AuthResultResultPass), - expectedDMARCDomain: api.PtrTo("example.com"), - expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), - expectedARCResult: api.PtrTo(api.ARCResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCResult: utils.PtrTo(model.AuthResultResultPass), + expectedDMARCDomain: utils.PtrTo("example.com"), + expectedBIMIResult: utils.PtrTo(model.AuthResultResultPass), + expectedARCResult: utils.PtrTo(model.ARCResultResultPass), }, { name: "Empty header (authserv-id only)", @@ -221,8 +222,8 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { { name: "Empty parts with semicolons", header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultPass), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, }, { @@ -230,19 +231,19 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { header: "mail.example.com; dkim=pass d=example.com s=selector1", expectedSPFResult: nil, expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMResult: utils.PtrTo(model.AuthResultResultPass), }, { name: "SPF neutral", header: "mail.example.com; spf=neutral smtp.mailfrom=sender@example.com", - expectedSPFResult: api.PtrTo(api.AuthResultResultNeutral), - expectedSPFDomain: api.PtrTo("example.com"), + expectedSPFResult: utils.PtrTo(model.AuthResultResultNeutral), + expectedSPFDomain: utils.PtrTo("example.com"), expectedDKIMCount: 0, }, { name: "SPF none", header: "mail.example.com; spf=none", - expectedSPFResult: api.PtrTo(api.AuthResultResultNone), + expectedSPFResult: utils.PtrTo(model.AuthResultResultNone), expectedDKIMCount: 0, }, } @@ -251,7 +252,7 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(tt.header, results) // Check SPF @@ -357,13 +358,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple SPF results - only first is parsed", func(t *testing.T) { header := "mail.example.com; spf=pass smtp.mailfrom=first@example.com; spf=fail smtp.mailfrom=second@example.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Spf == nil { t.Fatal("Expected SPF result, got nil") } - if results.Spf.Result != api.AuthResultResultPass { + if results.Spf.Result != model.AuthResultResultPass { t.Errorf("Expected first SPF result (pass), got %v", results.Spf.Result) } if results.Spf.Domain == nil || *results.Spf.Domain != "example.com" { @@ -373,13 +374,13 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DMARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; dmarc=pass header.from=first.com; dmarc=fail header.from=second.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dmarc == nil { t.Fatal("Expected DMARC result, got nil") } - if results.Dmarc.Result != api.AuthResultResultPass { + if results.Dmarc.Result != model.AuthResultResultPass { t.Errorf("Expected first DMARC result (pass), got %v", results.Dmarc.Result) } if results.Dmarc.Domain == nil || *results.Dmarc.Domain != "first.com" { @@ -389,26 +390,26 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple ARC results - only first is parsed", func(t *testing.T) { header := "mail.example.com; arc=pass; arc=fail" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Arc == nil { t.Fatal("Expected ARC result, got nil") } - if results.Arc.Result != api.ARCResultResultPass { + if results.Arc.Result != model.ARCResultResultPass { t.Errorf("Expected first ARC result (pass), got %v", results.Arc.Result) } }) t.Run("Multiple BIMI results - only first is parsed", func(t *testing.T) { header := "mail.example.com; bimi=pass header.d=first.com; bimi=fail header.d=second.com" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Bimi == nil { t.Fatal("Expected BIMI result, got nil") } - if results.Bimi.Result != api.AuthResultResultPass { + if results.Bimi.Result != model.AuthResultResultPass { t.Errorf("Expected first BIMI result (pass), got %v", results.Bimi.Result) } if results.Bimi.Domain == nil || *results.Bimi.Domain != "first.com" { @@ -419,7 +420,7 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { t.Run("Multiple DKIM results - all are parsed", func(t *testing.T) { // DKIM is special - multiple signatures should all be collected header := "mail.example.com; dkim=pass header.d=first.com header.s=s1; dkim=fail header.d=second.com header.s=s2" - results := &api.AuthenticationResults{} + results := &model.AuthenticationResults{} analyzer.parseAuthenticationResultsHeader(header, results) if results.Dkim == nil { @@ -428,10 +429,10 @@ func TestParseAuthenticationResultsHeader_OnlyFirstResultParsed(t *testing.T) { if len(*results.Dkim) != 2 { t.Errorf("Expected 2 DKIM results, got %d", len(*results.Dkim)) } - if (*results.Dkim)[0].Result != api.AuthResultResultPass { + if (*results.Dkim)[0].Result != model.AuthResultResultPass { t.Errorf("Expected first DKIM result to be pass, got %v", (*results.Dkim)[0].Result) } - if (*results.Dkim)[1].Result != api.AuthResultResultFail { + if (*results.Dkim)[1].Result != model.AuthResultResultFail { t.Errorf("Expected second DKIM result to be fail, got %v", (*results.Dkim)[1].Result) } }) diff --git a/pkg/analyzer/authentication_x_aligned_from.go b/pkg/analyzer/authentication_x_aligned_from.go index eb0cf98..ec1571c 100644 --- a/pkg/analyzer/authentication_x_aligned_from.go +++ b/pkg/analyzer/authentication_x_aligned_from.go @@ -25,34 +25,35 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseXAlignedFromResult parses X-Aligned-From result from Authentication-Results // Example: x-aligned-from=pass (Address match) -func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseXAlignedFromResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`x-aligned-from=([\w]+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract details (everything after the result) - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-aligned-from=")) return result } -func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXAlignedFromScore(results *model.AuthenticationResults) (score int) { if results.XAlignedFrom != nil { switch results.XAlignedFrom.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: // pass: positive contribution return 100 - case api.AuthResultResultFail: + case model.AuthResultResultFail: // fail: negative contribution return 0 default: diff --git a/pkg/analyzer/authentication_x_aligned_from_test.go b/pkg/analyzer/authentication_x_aligned_from_test.go index 0fdd69d..1ea6d1c 100644 --- a/pkg/analyzer/authentication_x_aligned_from_test.go +++ b/pkg/analyzer/authentication_x_aligned_from_test.go @@ -24,44 +24,44 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseXAlignedFromResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDetail string }{ { name: "x-aligned-from pass with details", part: "x-aligned-from=pass (Address match)", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDetail: "pass (Address match)", }, { name: "x-aligned-from fail with reason", part: "x-aligned-from=fail (Address mismatch)", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDetail: "fail (Address mismatch)", }, { name: "x-aligned-from pass minimal", part: "x-aligned-from=pass", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDetail: "pass", }, { name: "x-aligned-from neutral", part: "x-aligned-from=neutral (No alignment check performed)", - expectedResult: api.AuthResultResultNeutral, + expectedResult: model.AuthResultResultNeutral, expectedDetail: "neutral (No alignment check performed)", }, { name: "x-aligned-from none", part: "x-aligned-from=none", - expectedResult: api.AuthResultResultNone, + expectedResult: model.AuthResultResultNone, expectedDetail: "none", }, } @@ -88,34 +88,34 @@ func TestParseXAlignedFromResult(t *testing.T) { func TestCalculateXAlignedFromScore(t *testing.T) { tests := []struct { name string - result *api.AuthResult + result *model.AuthResult expectedScore int }{ { name: "pass result gives positive score", - result: &api.AuthResult{ - Result: api.AuthResultResultPass, + result: &model.AuthResult{ + Result: model.AuthResultResultPass, }, expectedScore: 100, }, { name: "fail result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultFail, + result: &model.AuthResult{ + Result: model.AuthResultResultFail, }, expectedScore: 0, }, { name: "neutral result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultNeutral, + result: &model.AuthResult{ + Result: model.AuthResultResultNeutral, }, expectedScore: 0, }, { name: "none result gives zero score", - result: &api.AuthResult{ - Result: api.AuthResultResultNone, + result: &model.AuthResult{ + Result: model.AuthResultResultNone, }, expectedScore: 0, }, @@ -130,7 +130,7 @@ func TestCalculateXAlignedFromScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - results := &api.AuthenticationResults{ + results := &model.AuthenticationResults{ XAlignedFrom: tt.result, } diff --git a/pkg/analyzer/authentication_x_google_dkim.go b/pkg/analyzer/authentication_x_google_dkim.go index 4bba469..b33279e 100644 --- a/pkg/analyzer/authentication_x_google_dkim.go +++ b/pkg/analyzer/authentication_x_google_dkim.go @@ -25,19 +25,20 @@ import ( "regexp" "strings" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" ) // parseXGoogleDKIMResult parses Google DKIM result from Authentication-Results // Example: x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6 -func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthResult { - result := &api.AuthResult{} +func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *model.AuthResult { + result := &model.AuthResult{} // Extract result (pass, fail, etc.) re := regexp.MustCompile(`x-google-dkim=(\w+)`) if matches := re.FindStringSubmatch(part); len(matches) > 1 { resultStr := strings.ToLower(matches[1]) - result.Result = api.AuthResultResult(resultStr) + result.Result = model.AuthResultResult(resultStr) } // Extract domain (header.d or d) @@ -54,15 +55,15 @@ func (a *AuthenticationAnalyzer) parseXGoogleDKIMResult(part string) *api.AuthRe result.Selector = &selector } - result.Details = api.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) + result.Details = utils.PtrTo(strings.TrimPrefix(part, "x-google-dkim=")) return result } -func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *api.AuthenticationResults) (score int) { +func (a *AuthenticationAnalyzer) calculateXGoogleDKIMScore(results *model.AuthenticationResults) (score int) { if results.XGoogleDkim != nil { switch results.XGoogleDkim.Result { - case api.AuthResultResultPass: + case model.AuthResultResultPass: // pass: don't alter the score default: // fail return -100 diff --git a/pkg/analyzer/authentication_x_google_dkim_test.go b/pkg/analyzer/authentication_x_google_dkim_test.go index f9704c0..4013340 100644 --- a/pkg/analyzer/authentication_x_google_dkim_test.go +++ b/pkg/analyzer/authentication_x_google_dkim_test.go @@ -24,39 +24,39 @@ package analyzer import ( "testing" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" ) func TestParseXGoogleDKIMResult(t *testing.T) { tests := []struct { name string part string - expectedResult api.AuthResultResult + expectedResult model.AuthResultResult expectedDomain string expectedSelector string }{ { name: "x-google-dkim pass with domain", part: "x-google-dkim=pass (2048-bit rsa key) header.d=1e100.net header.i=@1e100.net header.b=fauiPVZ6", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "1e100.net", }, { name: "x-google-dkim pass with short form", part: "x-google-dkim=pass d=gmail.com", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, expectedDomain: "gmail.com", }, { name: "x-google-dkim fail", part: "x-google-dkim=fail header.d=example.com", - expectedResult: api.AuthResultResultFail, + expectedResult: model.AuthResultResultFail, expectedDomain: "example.com", }, { name: "x-google-dkim with minimal info", part: "x-google-dkim=pass", - expectedResult: api.AuthResultResultPass, + expectedResult: model.AuthResultResultPass, }, } diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index d14d157..06f8ddf 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -32,7 +32,8 @@ import ( "time" "unicode" - "git.happydns.org/happyDeliver/internal/api" + "git.happydns.org/happyDeliver/internal/model" + "git.happydns.org/happyDeliver/internal/utils" "golang.org/x/net/html" ) @@ -728,16 +729,16 @@ func (c *ContentAnalyzer) normalizeText(text string) string { } // GenerateContentAnalysis creates structured content analysis from results -func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis { +func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *model.ContentAnalysis { if results == nil { return nil } - analysis := &api.ContentAnalysis{ - HasHtml: api.PtrTo(results.HTMLContent != ""), - HasPlaintext: api.PtrTo(results.TextContent != ""), - HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe), - UnsubscribeMethods: &[]api.ContentAnalysisUnsubscribeMethods{}, + analysis := &model.ContentAnalysis{ + HasHtml: utils.PtrTo(results.HTMLContent != ""), + HasPlaintext: utils.PtrTo(results.TextContent != ""), + HasUnsubscribeLink: utils.PtrTo(results.HasUnsubscribe), + UnsubscribeMethods: &[]model.ContentAnalysisUnsubscribeMethods{}, } // Calculate text-to-image ratio (inverse of image-to-text) @@ -750,16 +751,16 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api. } // Build HTML issues - htmlIssues := []api.ContentIssue{} + htmlIssues := []model.ContentIssue{} // Add HTML parsing errors if !results.HTMLValid && len(results.HTMLErrors) > 0 { for _, errMsg := range results.HTMLErrors { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.BrokenHtml, - Severity: api.ContentIssueSeverityHigh, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.BrokenHtml, + Severity: model.ContentIssueSeverityHigh, Message: errMsg, - Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"), + Advice: utils.PtrTo("Fix HTML structure errors to improve email rendering across clients"), }) } } @@ -773,53 +774,53 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api. } } if missingAltCount > 0 { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.MissingAlt, - Severity: api.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.MissingAlt, + Severity: model.ContentIssueSeverityMedium, Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount), - Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), + Advice: utils.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"), }) } } // Add excessive images issue if results.ImageTextRatio > 10.0 { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.ExcessiveImages, - Severity: api.ContentIssueSeverityMedium, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.ExcessiveImages, + Severity: model.ContentIssueSeverityMedium, Message: "Email is excessively image-heavy", - Advice: api.PtrTo("Reduce the number of images relative to text content"), + Advice: utils.PtrTo("Reduce the number of images relative to text content"), }) } // Add suspicious URL issues for _, suspURL := range results.SuspiciousURLs { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.SuspiciousLink, - Severity: api.ContentIssueSeverityHigh, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.SuspiciousLink, + Severity: model.ContentIssueSeverityHigh, Message: "Suspicious URL detected", Location: &suspURL, - Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), + Advice: utils.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"), }) } // Add harmful HTML tag issues for _, harmfulIssue := range results.HarmfullIssues { - htmlIssues = append(htmlIssues, api.ContentIssue{ - Type: api.DangerousHtml, - Severity: api.ContentIssueSeverityCritical, + htmlIssues = append(htmlIssues, model.ContentIssue{ + Type: model.DangerousHtml, + Severity: model.ContentIssueSeverityCritical, Message: harmfulIssue, - Advice: api.PtrTo("Remove dangerous HTML tags like {#if dmarcRecord} @@ -68,9 +78,12 @@ No DMARC record exists for {fromDomain}. The record above was inherited from {#if isPsdFallback} - the Public Suffix Domain {dmarcRecord.domain} per RFC 9091. + the Public Suffix Domain {dmarcRecord.domain} via the DMARCbis + DNS Tree Walk (which obsoletes the RFC 9091 PSD DMARC experiment). {:else} - the organizational domain {dmarcRecord.domain} per RFC 7489. + the organizational domain {dmarcRecord.domain} via the + DMARCbis DNS Tree Walk (compatible with RFC 7489 organizational domain + fallback). {/if}
@@ -123,6 +136,53 @@
{/if} + + {#if dmarcRecord.test_mode} +
+ Test Mode: + t=y (active) +
+ + Test mode active — DMARCbis-compliant receivers will + downgrade the effective policy one level: + {#if dmarcRecord.policy === "reject"} + p=reject is applied as p=quarantine. + {:else if dmarcRecord.policy === "quarantine"} + p=quarantine is applied as p=none (no action taken). + {:else} + p=none is unaffected by test mode. + {/if} + Aggregate reports are still generated normally. + This tag replaces the deprecated pct= for gradual rollout. +
+
+ {/if} + + + {#if dmarcRecord.psd === "y"} +
+ Public Suffix Domain: + psd=y +
+ + PSD declared — this domain is declared as a Public Suffix + Domain. DMARCbis-compliant receivers will apply this policy to subdomains + that have no DMARC record of their own when using the DNS Tree Walk algorithm. +
+
+ {:else if dmarcRecord.psd === "n"} +
+ Organizational Domain Boundary: + psd=n +
+ + Org Domain declaredpsd=n explicitly declares + this as an Organizational Domain boundary. Subdomains with separate DNS + delegation will use their own independent DMARCbis Tree Walk. +
+
+ {/if} + {#if dmarcRecord.subdomain_policy} {@const mainStrength = policyStrength(dmarcRecord.policy)} @@ -202,7 +262,7 @@
{/if} - + {#if dmarcRecord.percentage !== undefined}
Enforcement Percentage: @@ -215,25 +275,35 @@ > {dmarcRecord.percentage}% +
+ + Deprecated tag — the pct= tag is removed in + DMARCbis. Many receivers already ignore it. For gradual rollout, replace it + with t=y (test mode); for full enforcement, simply remove + pct= from your record. + {#if dmarcRecord.percentage === 0} +
pct=0 is an anti-pattern — it was widely misused + as a signal to bypass DMARC entirely, which is one reason the tag was + removed. Use t=y instead. + {/if} +
{#if dmarcRecord.percentage === 100}
Full enforcement — all messages are subject to DMARC policy. - This provides maximum protection.
- {:else if dmarcRecord.percentage >= 50} + {:else if dmarcRecord.percentage > 0 && dmarcRecord.percentage >= 50}
Partial enforcement — only {dmarcRecord.percentage}% of - messages are subject to DMARC policy. Consider increasing to - pct=100 once you've validated your configuration. + messages are subject to DMARC policy. Receivers ignoring pct= will apply + the full policy regardless.
- {:else} + {:else if dmarcRecord.percentage > 0}
Low enforcement — only {dmarcRecord.percentage}% of - messages are protected. Gradually increase to pct=100 for full - protection. + messages are protected. Receivers ignoring pct= will apply full policy.
{/if}
@@ -319,6 +389,30 @@
{/if} + + {#if dmarcRecord.deprecated_rf || dmarcRecord.deprecated_ri} +
+ + Deprecated tags detected — your record contains + {#if dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri} + rf= and ri= tags that are + {:else if dmarcRecord.deprecated_rf} + the rf= tag that is + {:else} + the ri= tag that is + {/if} + removed in DMARCbis. Modern receivers will ignore + {dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "them" : "it"}. + {#if dmarcRecord.deprecated_ri} + Aggregate reporting interval is now fixed at ≥ 24 hours regardless of + ri=. + {/if} + You can safely remove + {dmarcRecord.deprecated_rf && dmarcRecord.deprecated_ri ? "these tags" : "this tag"} + from your DMARC record. +
+ {/if} + {#if dmarcRecord.error}
From b3b1a094dec4e6d4a8d766918c246f99e4553e07 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 18 May 2026 20:57:47 +0800 Subject: [PATCH 62/62] dmarc: refactor parseDMARCRecord to use shared tag parser and eliminate helper methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-field regex extractor methods with a single parseDKIMTags call, removing eight redundant private methods and unifying DMARC tag parsing with the existing DKIM tag parser. Tests are updated to drive through parseDMARCRecord instead of the removed helpers, and the NP scoring logic is corrected to award +15/−15 symmetrically like the SP scoring path. --- pkg/analyzer/dns_dmarc.go | 215 +++++++------------- pkg/analyzer/dns_dmarc_test.go | 349 +++++++++++---------------------- 2 files changed, 187 insertions(+), 377 deletions(-) diff --git a/pkg/analyzer/dns_dmarc.go b/pkg/analyzer/dns_dmarc.go index 28548ea..b89500b 100644 --- a/pkg/analyzer/dns_dmarc.go +++ b/pkg/analyzer/dns_dmarc.go @@ -25,13 +25,15 @@ import ( "context" "fmt" "net" - "regexp" + "strconv" "strings" "git.happydns.org/happyDeliver/internal/model" "git.happydns.org/happyDeliver/internal/utils" ) +var dmarcPolicyStrength = map[string]int{"none": 0, "quarantine": 1, "reject": 2} + // lookupDMARCAt queries _dmarc. and returns the raw DMARC1 TXT record. // notFound=true means no record exists (NXDOMAIN or empty); false means a real DNS error occurred. func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool, err error) { @@ -56,17 +58,62 @@ func (d *DNSAnalyzer) lookupDMARCAt(domain string) (record string, notFound bool // parseDMARCRecord parses a raw DMARC TXT record into a DMARCRecord model. func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMARCRecord { - policy := d.extractDMARCPolicy(rawRecord) - subdomainPolicy := d.extractDMARCSubdomainPolicy(rawRecord) - nonexistentSubdomainPolicy := d.extractDMARCNonexistentSubdomainPolicy(rawRecord) - percentage := d.extractDMARCPercentage(rawRecord) - testMode := d.extractDMARCTestMode(rawRecord) - psd := d.extractDMARCPSD(rawRecord) - spfAlignment := d.extractDMARCSPFAlignment(rawRecord) - dkimAlignment := d.extractDMARCDKIMAlignment(rawRecord) - deprecatedPct := percentage != nil - deprecatedRf := d.hasDMARCTag(rawRecord, "rf") - deprecatedRi := d.hasDMARCTag(rawRecord, "ri") + tags := parseDKIMTags(rawRecord) + + // Policy + policy := "unknown" + switch tags["p"] { + case "none", "quarantine", "reject": + policy = tags["p"] + } + + // SPF alignment (default: relaxed) + spfAlignment := utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) + if tags["aspf"] == "s" { + spfAlignment = utils.PtrTo(model.DMARCRecordSpfAlignmentStrict) + } + + // DKIM alignment (default: relaxed) + dkimAlignment := utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) + if tags["adkim"] == "s" { + dkimAlignment = utils.PtrTo(model.DMARCRecordDkimAlignmentStrict) + } + + // Subdomain policy + var subdomainPolicy *model.DMARCRecordSubdomainPolicy + switch tags["sp"] { + case "none", "quarantine", "reject": + subdomainPolicy = utils.PtrTo(model.DMARCRecordSubdomainPolicy(tags["sp"])) + } + + // Non-existent subdomain policy (DMARCbis np=) + var nonexistentSubdomainPolicy *model.DMARCRecordNonexistentSubdomainPolicy + switch tags["np"] { + case "none", "quarantine", "reject": + nonexistentSubdomainPolicy = utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(tags["np"])) + } + + // Percentage (pct=, deprecated in DMARCbis) + var percentage *int + if pctStr, ok := tags["pct"]; ok { + if pct, err := strconv.Atoi(pctStr); err == nil && pct >= 0 && pct <= 100 { + percentage = &pct + } + } + + // Test mode (DMARCbis t=) + var testMode *bool + if t, ok := tags["t"]; ok { + v := t == "y" + testMode = &v + } + + // PSD (DMARCbis psd=) + var psd *model.DMARCRecordPsd + switch tags["psd"] { + case "y", "n", "u": + psd = utils.PtrTo(model.DMARCRecordPsd(tags["psd"])) + } rec := &model.DMARCRecord{ Domain: &foundDomain, @@ -80,13 +127,13 @@ func (d *DNSAnalyzer) parseDMARCRecord(foundDomain, rawRecord string) *model.DMA SpfAlignment: spfAlignment, DkimAlignment: dkimAlignment, } - if deprecatedPct { + if percentage != nil { rec.DeprecatedPct = utils.PtrTo(true) } - if deprecatedRf { + if _, ok := tags["rf"]; ok { rec.DeprecatedRf = utils.PtrTo(true) } - if deprecatedRi { + if _, ok := tags["ri"]; ok { rec.DeprecatedRi = utils.PtrTo(true) } @@ -158,129 +205,17 @@ func (d *DNSAnalyzer) checkDMARCRecord(domain string) *model.DMARCRecord { return d.parseDMARCRecord(foundDomain, raw) } -// extractDMARCPolicy extracts the policy from a DMARC record -func (d *DNSAnalyzer) extractDMARCPolicy(record string) string { - // Look for p=none, p=quarantine, or p=reject - re := regexp.MustCompile(`p=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] - } - return "unknown" -} - -// extractDMARCSPFAlignment extracts SPF alignment mode from a DMARC record -// Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCSPFAlignment(record string) *model.DMARCRecordSpfAlignment { - // Look for aspf=s (strict) or aspf=r (relaxed) - re := regexp.MustCompile(`aspf=(r|s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - if matches[1] == "s" { - return utils.PtrTo(model.DMARCRecordSpfAlignmentStrict) - } - return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) - } - // Default is relaxed if not specified - return utils.PtrTo(model.DMARCRecordSpfAlignmentRelaxed) -} - -// extractDMARCDKIMAlignment extracts DKIM alignment mode from a DMARC record -// Returns "relaxed" (default) or "strict" -func (d *DNSAnalyzer) extractDMARCDKIMAlignment(record string) *model.DMARCRecordDkimAlignment { - // Look for adkim=s (strict) or adkim=r (relaxed) - re := regexp.MustCompile(`adkim=(r|s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - if matches[1] == "s" { - return utils.PtrTo(model.DMARCRecordDkimAlignmentStrict) - } - return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) - } - // Default is relaxed if not specified - return utils.PtrTo(model.DMARCRecordDkimAlignmentRelaxed) -} - -// extractDMARCSubdomainPolicy extracts subdomain policy from a DMARC record -// Returns the sp tag value or nil if not specified (defaults to main policy) -func (d *DNSAnalyzer) extractDMARCSubdomainPolicy(record string) *model.DMARCRecordSubdomainPolicy { - // Look for sp=none, sp=quarantine, or sp=reject - re := regexp.MustCompile(`sp=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return utils.PtrTo(model.DMARCRecordSubdomainPolicy(matches[1])) - } - // If sp is not specified, it defaults to the main policy (p tag) - // Return nil to indicate it's using the default - return nil -} - -// extractDMARCNonexistentSubdomainPolicy extracts non-existent subdomain policy from a DMARC record. -// Returns the np tag value or nil if not specified (defaults to effective sp/p policy). -// The np= tag is introduced by DMARCbis (draft-ietf-dmarc-dmarcbis). -func (d *DNSAnalyzer) extractDMARCNonexistentSubdomainPolicy(record string) *model.DMARCRecordNonexistentSubdomainPolicy { - re := regexp.MustCompile(`np=(none|quarantine|reject)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return utils.PtrTo(model.DMARCRecordNonexistentSubdomainPolicy(matches[1])) - } - return nil -} - -// extractDMARCPercentage extracts the percentage from a DMARC record. -// Returns the pct tag value or nil if not specified (defaults to 100). -// Note: pct= is deprecated in DMARCbis; use t= (test_mode) instead. -func (d *DNSAnalyzer) extractDMARCPercentage(record string) *int { - re := regexp.MustCompile(`pct=(\d+)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - var pct int - fmt.Sscanf(matches[1], "%d", &pct) - if pct >= 0 && pct <= 100 { - return &pct - } - } - return nil -} - -// extractDMARCTestMode extracts the DMARCbis t= tag (test mode). -// Returns true for t=y, false for t=n, nil if absent (defaults to false / full enforcement). -func (d *DNSAnalyzer) extractDMARCTestMode(record string) *bool { - re := regexp.MustCompile(`(?:^|;)\s*t=(y|n)(?:;|$|\s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - v := matches[1] == "y" - return &v - } - return nil -} - -// extractDMARCPSD extracts the DMARCbis psd= tag value as a typed enum. -// Returns nil if the tag is absent (defaults to "u" / unknown). -func (d *DNSAnalyzer) extractDMARCPSD(record string) *model.DMARCRecordPsd { - v := d.extractDMARCPSDValue(record) - if v == "" { - return nil - } - return utils.PtrTo(model.DMARCRecordPsd(v)) -} - -// extractDMARCPSDValue returns the raw string value of psd= ("y", "n", "u") or "". +// extractDMARCPSDValue returns the raw psd= value ("y", "n", "u") or "" if absent. +// Used during DNS Tree Walk before full record parsing. func (d *DNSAnalyzer) extractDMARCPSDValue(record string) string { - re := regexp.MustCompile(`(?:^|;)\s*psd=(y|n|u)(?:;|$|\s)`) - matches := re.FindStringSubmatch(record) - if len(matches) > 1 { - return matches[1] + v := parseDKIMTags(record)["psd"] + switch v { + case "y", "n", "u": + return v } return "" } -// hasDMARCTag reports whether the given tag name appears in the record. -func (d *DNSAnalyzer) hasDMARCTag(record, tag string) bool { - re := regexp.MustCompile(`(?:^|;)\s*` + regexp.QuoteMeta(tag) + `=`) - return re.MatchString(record) -} - // validateDMARC performs basic DMARC record validation. // Per DMARCbis, p= is now RECOMMENDED (not required): a record with a valid // rua= but no p= is treated as p=none and considered valid. @@ -343,12 +278,10 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) score += 5 } - policyStrength := map[string]int{"none": 0, "quarantine": 1, "reject": 2} - // Subdomain policy scoring (sp tag): +15 for equal-or-stricter, -15 for weaker if results.DmarcRecord.SubdomainPolicy != nil { subPolicy := string(*results.DmarcRecord.SubdomainPolicy) - if policyStrength[subPolicy] >= policyStrength[effectivePolicy] { + if dmarcPolicyStrength[subPolicy] >= dmarcPolicyStrength[effectivePolicy] { score += 15 } else { score -= 15 @@ -357,19 +290,17 @@ func (d *DNSAnalyzer) calculateDMARCScore(results *model.DNSResults) (score int) score += 15 // inherits main policy — good default } - // Non-existent subdomain policy scoring (np tag, DMARCbis) - score -= 15 + // Non-existent subdomain policy scoring (np tag, DMARCbis): +15 for equal-or-stricter, -15 for weaker effectiveSubPolicy := effectivePolicy if results.DmarcRecord.SubdomainPolicy != nil { effectiveSubPolicy = string(*results.DmarcRecord.SubdomainPolicy) } if results.DmarcRecord.NonexistentSubdomainPolicy == nil { score += 15 // inherits subdomain/main policy — good default + } else if dmarcPolicyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] >= dmarcPolicyStrength[effectiveSubPolicy] { + score += 15 } else { - npStrength := policyStrength[string(*results.DmarcRecord.NonexistentSubdomainPolicy)] - if npStrength >= policyStrength[effectiveSubPolicy] { - score += 15 - } + score -= 15 } // pct= scaling (deprecated in DMARCbis, kept for backward compatibility). diff --git a/pkg/analyzer/dns_dmarc_test.go b/pkg/analyzer/dns_dmarc_test.go index 46a3518..5c34a32 100644 --- a/pkg/analyzer/dns_dmarc_test.go +++ b/pkg/analyzer/dns_dmarc_test.go @@ -221,7 +221,7 @@ func containsStr(s, sub string) bool { return false } -func TestExtractDMARCPolicy(t *testing.T) { +func TestParseDMARCRecordPolicy(t *testing.T) { tests := []struct { name string record string @@ -253,15 +253,18 @@ func TestExtractDMARCPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPolicy(tt.record) - if result != tt.expectedPolicy { - t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy) + rec := analyzer.parseDMARCRecord("example.com", tt.record) + if rec.Policy == nil { + t.Fatalf("parseDMARCRecord(%q).Policy = nil", tt.record) + } + if string(*rec.Policy) != tt.expectedPolicy { + t.Errorf("parseDMARCRecord(%q).Policy = %q, want %q", tt.record, string(*rec.Policy), tt.expectedPolicy) } }) } } -func TestExtractDMARCTestMode(t *testing.T) { +func TestParseDMARCRecordTestMode(t *testing.T) { tests := []struct { name string record string @@ -288,24 +291,24 @@ func TestExtractDMARCTestMode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCTestMode(tt.record) + result := analyzer.parseDMARCRecord("example.com", tt.record).TestMode if tt.wantMode == nil { if result != nil { - t.Errorf("extractDMARCTestMode(%q) = %v, want nil", tt.record, *result) + t.Errorf("parseDMARCRecord(%q).TestMode = %v, want nil", tt.record, *result) } } else { if result == nil { - t.Fatalf("extractDMARCTestMode(%q) = nil, want %v", tt.record, *tt.wantMode) + t.Fatalf("parseDMARCRecord(%q).TestMode = nil, want %v", tt.record, *tt.wantMode) } if *result != *tt.wantMode { - t.Errorf("extractDMARCTestMode(%q) = %v, want %v", tt.record, *result, *tt.wantMode) + t.Errorf("parseDMARCRecord(%q).TestMode = %v, want %v", tt.record, *result, *tt.wantMode) } } }) } } -func TestExtractDMARCPSD(t *testing.T) { +func TestParseDMARCRecordPSD(t *testing.T) { tests := []struct { name string record string @@ -337,43 +340,48 @@ func TestExtractDMARCPSD(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPSD(tt.record) + result := analyzer.parseDMARCRecord("example.com", tt.record).Psd if tt.wantPSD == nil { if result != nil { - t.Errorf("extractDMARCPSD(%q) = %v, want nil", tt.record, *result) + t.Errorf("parseDMARCRecord(%q).Psd = %v, want nil", tt.record, *result) } } else { if result == nil { - t.Fatalf("extractDMARCPSD(%q) = nil, want %q", tt.record, *tt.wantPSD) + t.Fatalf("parseDMARCRecord(%q).Psd = nil, want %q", tt.record, *tt.wantPSD) } if string(*result) != *tt.wantPSD { - t.Errorf("extractDMARCPSD(%q) = %q, want %q", tt.record, string(*result), *tt.wantPSD) + t.Errorf("parseDMARCRecord(%q).Psd = %q, want %q", tt.record, string(*result), *tt.wantPSD) } } }) } } -func TestHasDMARCTag(t *testing.T) { +func TestParseDMARCRecordDeprecatedTags(t *testing.T) { tests := []struct { - name string - record string - tag string - want bool + name string + record string + wantRf bool + wantRi bool }{ - {name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", tag: "rf", want: true}, - {name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", tag: "ri", want: true}, - {name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", tag: "rf", want: false}, - {name: "ri tag absent", record: "v=DMARC1; p=quarantine", tag: "ri", want: false}, + {name: "rf tag present", record: "v=DMARC1; p=none; rf=afrf", wantRf: true, wantRi: false}, + {name: "ri tag present", record: "v=DMARC1; p=none; ri=86400", wantRf: false, wantRi: true}, + {name: "rf tag absent", record: "v=DMARC1; p=quarantine; rua=mailto:x@example.com", wantRf: false, wantRi: false}, + {name: "ri tag absent", record: "v=DMARC1; p=quarantine", wantRf: false, wantRi: false}, } analyzer := NewDNSAnalyzer(5 * time.Second) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.hasDMARCTag(tt.record, tt.tag) - if result != tt.want { - t.Errorf("hasDMARCTag(%q, %q) = %v, want %v", tt.record, tt.tag, result, tt.want) + rec := analyzer.parseDMARCRecord("example.com", tt.record) + gotRf := rec.DeprecatedRf != nil && *rec.DeprecatedRf + gotRi := rec.DeprecatedRi != nil && *rec.DeprecatedRi + if gotRf != tt.wantRf { + t.Errorf("parseDMARCRecord(%q).DeprecatedRf = %v, want %v", tt.record, gotRf, tt.wantRf) + } + if gotRi != tt.wantRi { + t.Errorf("parseDMARCRecord(%q).DeprecatedRi = %v, want %v", tt.record, gotRi, tt.wantRi) } }) } @@ -429,142 +437,36 @@ func TestValidateDMARC(t *testing.T) { } } -func TestExtractDMARCSPFAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "SPF alignment - strict", - record: "v=DMARC1; p=quarantine; aspf=s", - expectedAlignment: "strict", - }, - { - name: "SPF alignment - relaxed (explicit)", - record: "v=DMARC1; p=quarantine; aspf=r", - expectedAlignment: "relaxed", - }, - { - name: "SPF alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=quarantine", - expectedAlignment: "relaxed", - }, - { - name: "Both alignments specified - check SPF strict", - record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", - expectedAlignment: "strict", - }, - { - name: "Both alignments specified - check SPF relaxed", - record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", - expectedAlignment: "relaxed", - }, - { - name: "Complex record with SPF strict", - record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", - expectedAlignment: "strict", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCSPFAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCSPFAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCSPFAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCDKIMAlignment(t *testing.T) { - tests := []struct { - name string - record string - expectedAlignment string - }{ - { - name: "DKIM alignment - strict", - record: "v=DMARC1; p=reject; adkim=s", - expectedAlignment: "strict", - }, - { - name: "DKIM alignment - relaxed (explicit)", - record: "v=DMARC1; p=reject; adkim=r", - expectedAlignment: "relaxed", - }, - { - name: "DKIM alignment - relaxed (default, not specified)", - record: "v=DMARC1; p=none", - expectedAlignment: "relaxed", - }, - { - name: "Both alignments specified - check DKIM strict", - record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", - expectedAlignment: "strict", - }, - { - name: "Both alignments specified - check DKIM relaxed", - record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", - expectedAlignment: "relaxed", - }, - { - name: "Complex record with DKIM strict", - record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=r; adkim=s; pct=100", - expectedAlignment: "strict", - }, - } - - analyzer := NewDNSAnalyzer(5 * time.Second) - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCDKIMAlignment(tt.record) - if result == nil { - t.Fatalf("extractDMARCDKIMAlignment(%q) returned nil, expected non-nil", tt.record) - } - if string(*result) != tt.expectedAlignment { - t.Errorf("extractDMARCDKIMAlignment(%q) = %q, want %q", tt.record, string(*result), tt.expectedAlignment) - } - }) - } -} - -func TestExtractDMARCSubdomainPolicy(t *testing.T) { +func TestParseDMARCRecordAlignment(t *testing.T) { tests := []struct { name string record string - expectedPolicy *string + expectedSPF string + expectedDKIM string }{ { - name: "Subdomain policy - none", - record: "v=DMARC1; p=quarantine; sp=none", - expectedPolicy: utils.PtrTo("none"), + name: "SPF strict, DKIM relaxed", + record: "v=DMARC1; p=quarantine; aspf=s; adkim=r", + expectedSPF: "strict", + expectedDKIM: "relaxed", }, { - name: "Subdomain policy - quarantine", - record: "v=DMARC1; p=reject; sp=quarantine", - expectedPolicy: utils.PtrTo("quarantine"), + name: "SPF relaxed explicit, DKIM strict", + record: "v=DMARC1; p=quarantine; aspf=r; adkim=s", + expectedSPF: "relaxed", + expectedDKIM: "strict", }, { - name: "Subdomain policy - reject", - record: "v=DMARC1; p=quarantine; sp=reject", - expectedPolicy: utils.PtrTo("reject"), + name: "Defaults when neither specified", + record: "v=DMARC1; p=quarantine", + expectedSPF: "relaxed", + expectedDKIM: "relaxed", }, { - name: "No subdomain policy specified (defaults to main policy)", - record: "v=DMARC1; p=quarantine", - expectedPolicy: nil, - }, - { - name: "Complex record with subdomain policy", - record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=100", - expectedPolicy: utils.PtrTo("quarantine"), + name: "Both strict in complex record", + record: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; aspf=s; adkim=s; pct=100", + expectedSPF: "strict", + expectedDKIM: "strict", }, } @@ -572,53 +474,53 @@ func TestExtractDMARCSubdomainPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCSubdomainPolicy(tt.record) - if tt.expectedPolicy == nil { - if result != nil { - t.Errorf("extractDMARCSubdomainPolicy(%q) = %v, want nil", tt.record, result) - } - } else { - if result == nil { - t.Fatalf("extractDMARCSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) - } - if string(*result) != *tt.expectedPolicy { - t.Errorf("extractDMARCSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) - } + rec := analyzer.parseDMARCRecord("example.com", tt.record) + if rec.SpfAlignment == nil { + t.Fatalf("parseDMARCRecord(%q).SpfAlignment = nil", tt.record) + } + if string(*rec.SpfAlignment) != tt.expectedSPF { + t.Errorf("SpfAlignment = %q, want %q", string(*rec.SpfAlignment), tt.expectedSPF) + } + if rec.DkimAlignment == nil { + t.Fatalf("parseDMARCRecord(%q).DkimAlignment = nil", tt.record) + } + if string(*rec.DkimAlignment) != tt.expectedDKIM { + t.Errorf("DkimAlignment = %q, want %q", string(*rec.DkimAlignment), tt.expectedDKIM) } }) } } -func TestExtractDMARCNonexistentSubdomainPolicy(t *testing.T) { +func TestParseDMARCRecordSubdomainPolicy(t *testing.T) { tests := []struct { name string record string - expectedPolicy *string + expectedSP *string + expectedNP *string }{ { - name: "Non-existent subdomain policy - none", - record: "v=DMARC1; p=quarantine; np=none", - expectedPolicy: utils.PtrTo("none"), + name: "sp=none, no np", + record: "v=DMARC1; p=quarantine; sp=none", + expectedSP: utils.PtrTo("none"), + expectedNP: nil, }, { - name: "Non-existent subdomain policy - quarantine", - record: "v=DMARC1; p=reject; np=quarantine", - expectedPolicy: utils.PtrTo("quarantine"), + name: "sp=reject, np=reject", + record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", + expectedSP: utils.PtrTo("quarantine"), + expectedNP: utils.PtrTo("reject"), }, { - name: "Non-existent subdomain policy - reject", - record: "v=DMARC1; p=quarantine; np=reject", - expectedPolicy: utils.PtrTo("reject"), + name: "No sp or np (both default)", + record: "v=DMARC1; p=quarantine", + expectedSP: nil, + expectedNP: nil, }, { - name: "No np tag (defaults to effective sp/p policy)", - record: "v=DMARC1; p=quarantine", - expectedPolicy: nil, - }, - { - name: "Complex record with np and sp tags", - record: "v=DMARC1; p=reject; sp=quarantine; np=reject; rua=mailto:dmarc@example.com; pct=100", - expectedPolicy: utils.PtrTo("reject"), + name: "np=quarantine, no sp", + record: "v=DMARC1; p=reject; np=quarantine", + expectedSP: nil, + expectedNP: utils.PtrTo("quarantine"), }, } @@ -626,86 +528,63 @@ func TestExtractDMARCNonexistentSubdomainPolicy(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCNonexistentSubdomainPolicy(tt.record) - if tt.expectedPolicy == nil { - if result != nil { - t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %v, want nil", tt.record, result) + rec := analyzer.parseDMARCRecord("example.com", tt.record) + if tt.expectedSP == nil { + if rec.SubdomainPolicy != nil { + t.Errorf("parseDMARCRecord(%q).SubdomainPolicy = %v, want nil", tt.record, *rec.SubdomainPolicy) } } else { - if result == nil { - t.Fatalf("extractDMARCNonexistentSubdomainPolicy(%q) returned nil, expected %q", tt.record, *tt.expectedPolicy) + if rec.SubdomainPolicy == nil { + t.Fatalf("parseDMARCRecord(%q).SubdomainPolicy = nil, want %q", tt.record, *tt.expectedSP) } - if string(*result) != *tt.expectedPolicy { - t.Errorf("extractDMARCNonexistentSubdomainPolicy(%q) = %q, want %q", tt.record, string(*result), *tt.expectedPolicy) + if string(*rec.SubdomainPolicy) != *tt.expectedSP { + t.Errorf("SubdomainPolicy = %q, want %q", string(*rec.SubdomainPolicy), *tt.expectedSP) + } + } + if tt.expectedNP == nil { + if rec.NonexistentSubdomainPolicy != nil { + t.Errorf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = %v, want nil", tt.record, *rec.NonexistentSubdomainPolicy) + } + } else { + if rec.NonexistentSubdomainPolicy == nil { + t.Fatalf("parseDMARCRecord(%q).NonexistentSubdomainPolicy = nil, want %q", tt.record, *tt.expectedNP) + } + if string(*rec.NonexistentSubdomainPolicy) != *tt.expectedNP { + t.Errorf("NonexistentSubdomainPolicy = %q, want %q", string(*rec.NonexistentSubdomainPolicy), *tt.expectedNP) } } }) } } -func TestExtractDMARCPercentage(t *testing.T) { +func TestParseDMARCRecordPercentage(t *testing.T) { tests := []struct { name string record string expectedPercentage *int }{ - { - name: "Percentage - 100", - record: "v=DMARC1; p=quarantine; pct=100", - expectedPercentage: utils.PtrTo(100), - }, - { - name: "Percentage - 50", - record: "v=DMARC1; p=quarantine; pct=50", - expectedPercentage: utils.PtrTo(50), - }, - { - name: "Percentage - 25", - record: "v=DMARC1; p=reject; pct=25", - expectedPercentage: utils.PtrTo(25), - }, - { - name: "Percentage - 0", - record: "v=DMARC1; p=none; pct=0", - expectedPercentage: utils.PtrTo(0), - }, - { - name: "No percentage specified (defaults to 100)", - record: "v=DMARC1; p=quarantine", - expectedPercentage: nil, - }, - { - name: "Complex record with percentage", - record: "v=DMARC1; p=reject; sp=quarantine; rua=mailto:dmarc@example.com; pct=75", - expectedPercentage: utils.PtrTo(75), - }, - { - name: "Invalid percentage > 100 (ignored)", - record: "v=DMARC1; p=quarantine; pct=150", - expectedPercentage: nil, - }, - { - name: "Invalid percentage < 0 (ignored)", - record: "v=DMARC1; p=quarantine; pct=-10", - expectedPercentage: nil, - }, + {name: "pct=100", record: "v=DMARC1; p=quarantine; pct=100", expectedPercentage: utils.PtrTo(100)}, + {name: "pct=50", record: "v=DMARC1; p=quarantine; pct=50", expectedPercentage: utils.PtrTo(50)}, + {name: "pct=0", record: "v=DMARC1; p=none; pct=0", expectedPercentage: utils.PtrTo(0)}, + {name: "no pct", record: "v=DMARC1; p=quarantine", expectedPercentage: nil}, + {name: "pct=150 ignored", record: "v=DMARC1; p=quarantine; pct=150", expectedPercentage: nil}, } analyzer := NewDNSAnalyzer(5 * time.Second) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractDMARCPercentage(tt.record) + result := analyzer.parseDMARCRecord("example.com", tt.record).Percentage if tt.expectedPercentage == nil { if result != nil { - t.Errorf("extractDMARCPercentage(%q) = %v, want nil", tt.record, *result) + t.Errorf("parseDMARCRecord(%q).Percentage = %d, want nil", tt.record, *result) } } else { if result == nil { - t.Fatalf("extractDMARCPercentage(%q) returned nil, expected %d", tt.record, *tt.expectedPercentage) + t.Fatalf("parseDMARCRecord(%q).Percentage = nil, want %d", tt.record, *tt.expectedPercentage) } if *result != *tt.expectedPercentage { - t.Errorf("extractDMARCPercentage(%q) = %d, want %d", tt.record, *result, *tt.expectedPercentage) + t.Errorf("parseDMARCRecord(%q).Percentage = %d, want %d", tt.record, *result, *tt.expectedPercentage) } } })