From 74866d210ceb7d8932c4e6b6376e9473865d5e06 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 18:11:23 +0700 Subject: [PATCH 1/5] Implement BIMI checks --- README.md | 4 +- api/openapi.yaml | 4 +- internal/analyzer/authentication.go | 86 +++++++++++++ internal/analyzer/dns.go | 153 +++++++++++++++++++++++ internal/analyzer/dns_test.go | 187 ++++++++++++++++++++++++++++ web/src/routes/+page.svelte | 10 +- 6 files changed, 440 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c76e248..a509d8c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An open-source email deliverability testing platform that analyzes test emails a ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration - **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers @@ -194,6 +194,8 @@ The deliverability score is calculated from 0 to 10 based on: - **Content (2 pts)**: HTML quality, links, images, unsubscribe - **Headers (1 pt)**: Required headers, MIME structure +**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor. + **Ratings:** - 9-10: Excellent - 7-8.9: Good diff --git a/api/openapi.yaml b/api/openapi.yaml index 467f62c..4f48869 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -353,6 +353,8 @@ components: $ref: '#/components/schemas/AuthResult' dmarc: $ref: '#/components/schemas/AuthResult' + bimi: + $ref: '#/components/schemas/AuthResult' AuthResult: type: object @@ -420,7 +422,7 @@ components: example: "example.com" record_type: type: string - enum: [MX, SPF, DKIM, DMARC] + enum: [MX, SPF, DKIM, DMARC, BIMI] description: DNS record type example: "SPF" status: diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go index 45df0a3..a0fd191 100644 --- a/internal/analyzer/authentication.go +++ b/internal/analyzer/authentication.go @@ -104,6 +104,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Dmarc = a.parseDMARCResult(part) } } + + // Parse BIMI + if strings.HasPrefix(part, "bimi=") { + if results.Bimi == nil { + results.Bimi = a.parseBIMIResult(part) + } + } } } @@ -214,6 +221,44 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { return result } +// 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{} + + // 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) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.selector or selector) + selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + // parseLegacySPF attempts to parse SPF from Received-SPF header func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { receivedSPF := email.Header.Get("Received-SPF") @@ -383,6 +428,12 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe }) } + // BIMI check (optional, informational only) + if results.Bimi != nil { + check := a.generateBIMICheck(results.Bimi) + checks = append(checks, check) + } + return checks } @@ -509,3 +560,38 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C return check } + +func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "BIMI (Brand Indicators)", + } + + switch bimi.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Message = "BIMI validation passed" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI") + case api.AuthResultResultFail: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "BIMI validation failed" + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record") + default: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result) + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients") + } + + if bimi.Domain != nil { + details := fmt.Sprintf("Domain: %s", *bimi.Domain) + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/dns.go b/internal/analyzer/dns.go index 07c0346..b411386 100644 --- a/internal/analyzer/dns.go +++ b/internal/analyzer/dns.go @@ -58,6 +58,7 @@ type DNSResults struct { SPFRecord *SPFRecord DKIMRecords []DKIMRecord DMARCRecord *DMARCRecord + BIMIRecord *BIMIRecord Errors []string } @@ -93,6 +94,17 @@ type DMARCRecord struct { Error string } +// BIMIRecord represents a BIMI record +type BIMIRecord struct { + Selector string + Domain string + Record string + LogoURL string // URL to the brand logo (SVG) + VMCURL string // URL to Verified Mark Certificate (optional) + Valid bool + Error string +} + // AnalyzeDNS performs DNS validation for the email's domain func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults { // Extract domain from From address @@ -128,6 +140,9 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // Check DMARC record results.DMARCRecord = d.checkDMARCRecord(domain) + // Check BIMI record (using default selector) + results.BIMIRecord = d.checkBIMIRecord(domain, "default") + return results } @@ -395,6 +410,89 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool { return true } +// checkBIMIRecord looks up and validates BIMI record for a domain and selector +func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord { + // BIMI records are at: selector._bimi.domain + bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain) + + ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) + defer cancel() + + txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain) + if err != nil { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err), + } + } + + if len(txtRecords) == 0 { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Valid: false, + Error: "No BIMI record found", + } + } + + // Concatenate all TXT record parts (BIMI can be split) + bimiRecord := strings.Join(txtRecords, "") + + // Extract logo URL and VMC URL + logoURL := d.extractBIMITag(bimiRecord, "l") + vmcURL := d.extractBIMITag(bimiRecord, "a") + + // Basic validation - should contain "v=BIMI1" and "l=" (logo URL) + if !d.validateBIMI(bimiRecord) { + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Record: bimiRecord, + LogoURL: logoURL, + VMCURL: vmcURL, + Valid: false, + Error: "BIMI record appears malformed", + } + } + + return &BIMIRecord{ + Selector: selector, + Domain: domain, + Record: bimiRecord, + LogoURL: logoURL, + VMCURL: vmcURL, + Valid: true, + } +} + +// extractBIMITag extracts a tag value from a BIMI record +func (d *DNSAnalyzer) extractBIMITag(record, tag string) string { + // Look for tag=value pattern + re := regexp.MustCompile(tag + `=([^;]+)`) + matches := re.FindStringSubmatch(record) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// validateBIMI performs basic BIMI record validation +func (d *DNSAnalyzer) validateBIMI(record string) bool { + // Must start with v=BIMI1 + if !strings.HasPrefix(record, "v=BIMI1") { + return false + } + + // Must have a logo URL tag (l=) + if !strings.Contains(record, "l=") { + return false + } + + return true +} + // GenerateDNSChecks generates check results for DNS validation func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { var checks []api.Check @@ -421,6 +519,11 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { checks = append(checks, d.generateDMARCCheck(results.DMARCRecord)) } + // BIMI record check (optional) + if results.BIMIRecord != nil { + checks = append(checks, d.generateBIMICheck(results.BIMIRecord)) + } + return checks } @@ -564,3 +667,53 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { return check } + +// generateBIMICheck creates a check for BIMI records +func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { + check := api.Check{ + Category: api.Dns, + Name: "BIMI Record", + } + + if !bimi.Valid { + // BIMI is optional, so missing record is just informational + if bimi.Record == "" { + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "No BIMI record found (optional)" + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)") + } else { + // If record exists but is invalid + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error) + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)") + check.Details = &bimi.Record + } + } else { + check.Status = api.CheckStatusPass + check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Message = "Valid BIMI record found" + check.Severity = api.PtrTo(api.Info) + + // Build details with logo and VMC URLs + var detailsParts []string + detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector)) + if bimi.LogoURL != "" { + detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL)) + } + if bimi.VMCURL != "" { + detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL)) + check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate") + } else { + check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust") + } + + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/dns_test.go b/internal/analyzer/dns_test.go index fe501d5..12a6bd0 100644 --- a/internal/analyzer/dns_test.go +++ b/internal/analyzer/dns_test.go @@ -631,3 +631,190 @@ func TestAnalyzeDNS_NoDomain(t *testing.T) { t.Error("Expected error when no domain can be extracted") } } + +func TestExtractBIMITag(t *testing.T) { + tests := []struct { + name string + record string + tag string + expectedValue string + }{ + { + name: "Extract logo URL (l tag)", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Extract VMC URL (a tag)", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + tag: "a", + expectedValue: "https://example.com/vmc.pem", + }, + { + name: "Tag not found", + record: "v=BIMI1; l=https://example.com/logo.svg", + tag: "a", + expectedValue: "", + }, + { + name: "Tag with spaces", + record: "v=BIMI1; l= https://example.com/logo.svg ", + tag: "l", + expectedValue: "https://example.com/logo.svg", + }, + { + name: "Empty record", + record: "", + tag: "l", + expectedValue: "", + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.extractBIMITag(tt.record, tt.tag) + if result != tt.expectedValue { + t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue) + } + }) + } +} + +func TestValidateBIMI(t *testing.T) { + tests := []struct { + name string + record string + expected bool + }{ + { + name: "Valid BIMI with logo URL", + record: "v=BIMI1; l=https://example.com/logo.svg", + expected: true, + }, + { + name: "Valid BIMI with logo and VMC", + record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + expected: true, + }, + { + name: "Invalid BIMI - no version", + record: "l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - wrong version", + record: "v=BIMI2; l=https://example.com/logo.svg", + expected: false, + }, + { + name: "Invalid BIMI - no logo URL", + record: "v=BIMI1", + expected: false, + }, + { + name: "Invalid BIMI - empty", + record: "", + expected: false, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.validateBIMI(tt.record) + if result != tt.expected { + t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected) + } + }) + } +} + +func TestGenerateBIMICheck(t *testing.T) { + tests := []struct { + name string + bimi *BIMIRecord + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "Valid BIMI with logo only", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1; l=https://example.com/logo.svg", + LogoURL: "https://example.com/logo.svg", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, // BIMI doesn't contribute to score + }, + { + name: "Valid BIMI with VMC", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem", + LogoURL: "https://example.com/logo.svg", + VMCURL: "https://example.com/vmc.pem", + Valid: true, + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, + }, + { + name: "No BIMI record (optional)", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Valid: false, + Error: "No BIMI record found", + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + { + name: "Invalid BIMI record", + bimi: &BIMIRecord{ + Selector: "default", + Domain: "example.com", + Record: "v=BIMI1", + Valid: false, + Error: "BIMI record appears malformed", + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.0, + }, + } + + analyzer := NewDNSAnalyzer(5 * time.Second) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateBIMICheck(tt.bimi) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Dns { + t.Errorf("Category = %v, want %v", check.Category, api.Dns) + } + if check.Name != "BIMI Record" { + t.Errorf("Name = %q, want %q", check.Name, "BIMI Record") + } + + // Check details for valid BIMI with VMC + if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil { + if !strings.Contains(*check.Details, "VMC URL") { + t.Error("Details should contain VMC URL for valid BIMI with VMC") + } + } + }) + } +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index f0709a1..8da8dc2 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -26,13 +26,19 @@ icon: "bi-shield-check", title: "Authentication", description: - "SPF, DKIM, and DMARC validation with detailed results and recommendations.", + "SPF, DKIM, DMARC, and BIMI validation with detailed results and recommendations.", variant: "primary" as const, }, + { + icon: "bi-patch-check", + title: "BIMI Support", + description: "Brand Indicators for Message Identification - verify your brand logo configuration.", + variant: "info" as const, + }, { icon: "bi-globe", title: "DNS Records", - description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.", + description: "Verify MX, SPF, DKIM, DMARC, and BIMI records are properly configured.", variant: "success" as const, }, { From 78c070cdcfca40e48304834363f4a7ae05f54710 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 18:26:37 +0700 Subject: [PATCH 2/5] Implement ARC header check --- api/openapi.yaml | 25 +++ internal/analyzer/authentication.go | 259 ++++++++++++++++++++++++++-- internal/analyzer/content.go | 32 ++-- internal/analyzer/dns.go | 24 +-- internal/analyzer/rbl.go | 16 +- internal/analyzer/rbl_test.go | 6 +- internal/analyzer/scoring.go | 20 +-- internal/analyzer/spamassassin.go | 18 +- 8 files changed, 325 insertions(+), 75 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 4f48869..83151de 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -355,6 +355,8 @@ components: $ref: '#/components/schemas/AuthResult' bimi: $ref: '#/components/schemas/AuthResult' + arc: + $ref: '#/components/schemas/ARCResult' AuthResult: type: object @@ -378,6 +380,29 @@ components: 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" + SpamAssassinResult: type: object required: diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go index a0fd191..a8c8df9 100644 --- a/internal/analyzer/authentication.go +++ b/internal/analyzer/authentication.go @@ -59,6 +59,14 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api } } + // Parse ARC headers if not already parsed from Authentication-Results + if results.Arc == nil { + results.Arc = a.parseARCHeaders(email) + } else { + // Enhance the ARC result with chain information from raw headers + a.enhanceARCResult(email, results.Arc) + } + return results } @@ -111,6 +119,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results.Bimi = a.parseBIMIResult(part) } } + + // Parse ARC + if strings.HasPrefix(part, "arc=") { + if results.Arc == nil { + results.Arc = a.parseARCResult(part) + } + } } } @@ -259,6 +274,163 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { return result } +// parseARCResult parses ARC result from Authentication-Results +// Example: arc=pass +func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { + result := &api.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) + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + 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 { + // Get all ARC-related headers + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + + // If no ARC headers present, return nil + if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 { + return nil + } + + result := &api.ARCResult{ + Result: api.ARCResultResultNone, + } + + // Count the ARC chain length (number of sets) + chainLength := len(arcSeal) + result.ChainLength = &chainLength + + // Validate the ARC chain + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + result.ChainValid = &chainValid + + // Determine overall result + if chainLength == 0 { + result.Result = api.ARCResultResultNone + details := "No ARC chain present" + result.Details = &details + } else if !chainValid { + result.Result = api.ARCResultResultFail + details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength) + result.Details = &details + } else { + result.Result = api.ARCResultResultPass + details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength)) + result.Details = &details + } + + return result +} + +// enhanceARCResult enhances an existing ARC result with chain information +func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) { + if arcResult == nil { + return + } + + // Get ARC headers + arcSeal := email.Header[textprotoCanonical("ARC-Seal")] + arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")] + arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")] + + // Set chain length if not already set + if arcResult.ChainLength == nil { + chainLength := len(arcSeal) + arcResult.ChainLength = &chainLength + } + + // Validate chain if not already validated + if arcResult.ChainValid == nil { + chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal) + arcResult.ChainValid = &chainValid + } +} + +// validateARCChain validates the ARC chain for completeness +// Each instance should have all three headers with matching instance numbers +func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool { + // All three header types should have the same count + if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) { + return false + } + + if len(arcSeal) == 0 { + return true // No ARC chain is technically valid + } + + // Extract instance numbers from each header type + sealInstances := a.extractARCInstances(arcSeal) + sigInstances := a.extractARCInstances(arcMessageSig) + authInstances := a.extractARCInstances(arcAuthResults) + + // Check that all instance numbers match and are sequential starting from 1 + if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) { + return false + } + + // Verify instances are sequential from 1 to N + for i := 1; i <= len(sealInstances); i++ { + if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) { + return false + } + } + + return true +} + +// extractARCInstances extracts instance numbers from ARC headers +func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { + var instances []int + re := regexp.MustCompile(`i=(\d+)`) + + for _, header := range headers { + if matches := re.FindStringSubmatch(header); len(matches) > 1 { + var instance int + fmt.Sscanf(matches[1], "%d", &instance) + instances = append(instances, instance) + } + } + + return instances +} + +// contains checks if a slice contains an integer +func contains(slice []int, val int) bool { + for _, item := range slice { + if item == val { + return true + } + } + return false +} + +// pluralize returns "y" or "ies" based on count +func pluralize(count int) string { + if count == 1 { + return "y" + } + return "ies" +} + // parseLegacySPF attempts to parse SPF from Received-SPF header func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { receivedSPF := email.Header.Get("Received-SPF") @@ -389,7 +561,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Status: api.CheckStatusWarn, Score: 0.0, Message: "No SPF authentication result found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), }) } @@ -407,7 +579,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Status: api.CheckStatusWarn, Score: 0.0, Message: "No DKIM signature found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), }) } @@ -423,7 +595,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Status: api.CheckStatusWarn, Score: 0.0, Message: "No DMARC authentication result found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Implement DMARC policy for your domain"), }) } @@ -434,6 +606,12 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe checks = append(checks, check) } + // ARC check (optional, for forwarded emails) + if results.Arc != nil { + check := a.generateARCCheck(results.Arc) + checks = append(checks, check) + } + return checks } @@ -448,31 +626,31 @@ func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "SPF validation passed" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your SPF record is properly configured") case api.AuthResultResultFail: check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = "SPF validation failed" - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") case api.AuthResultResultSoftfail: check.Status = api.CheckStatusWarn check.Score = 0.5 check.Message = "SPF validation softfail" - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Review your SPF record configuration") case api.AuthResultResultNeutral: check.Status = api.CheckStatusWarn check.Score = 0.5 check.Message = "SPF validation neutral" - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Advice = api.PtrTo("Consider tightening your SPF policy") default: check.Status = api.CheckStatusWarn check.Score = 0.0 check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Review your SPF record configuration") } @@ -495,19 +673,19 @@ func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index i check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "DKIM signature is valid" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your DKIM signature is properly configured") case api.AuthResultResultFail: check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = "DKIM signature validation failed" - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") default: check.Status = api.CheckStatusWarn check.Score = 0.0 check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") } @@ -537,19 +715,19 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "DMARC validation passed" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your DMARC policy is properly aligned") case api.AuthResultResultFail: check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = "DMARC validation failed" - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") default: check.Status = api.CheckStatusWarn check.Score = 0.0 check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Configure DMARC policy for your domain") } @@ -572,19 +750,19 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che check.Status = api.CheckStatusPass check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) check.Message = "BIMI validation passed" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI") case api.AuthResultResultFail: check.Status = api.CheckStatusInfo check.Score = 0.0 check.Message = "BIMI validation failed" - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record") default: check.Status = api.CheckStatusInfo check.Score = 0.0 check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result) - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients") } @@ -595,3 +773,50 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che return check } + +func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "ARC (Authenticated Received Chain)", + } + + switch arc.Result { + case api.ARCResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding) + check.Message = "ARC chain validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication") + case api.ARCResultResultFail: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = "ARC chain validation failed" + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries") + default: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "No ARC chain present" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries") + } + + // Build details + var detailsParts []string + if arc.ChainLength != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength)) + } + if arc.ChainValid != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid)) + } + if arc.Details != nil { + detailsParts = append(detailsParts, *arc.Details) + } + + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/content.go b/internal/analyzer/content.go index bad38c9..ac46259 100644 --- a/internal/analyzer/content.go +++ b/internal/analyzer/content.go @@ -507,7 +507,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api if !results.HTMLValid { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "HTML structure is invalid" if len(results.HTMLErrors) > 0 { details := strings.Join(results.HTMLErrors, "; ") @@ -517,7 +517,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api } else { check.Status = api.CheckStatusPass check.Score = 0.2 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "HTML structure is valid" check.Advice = api.PtrTo("Your HTML is well-formed") } @@ -552,7 +552,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec if brokenLinks > 0 { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks) check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability") details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks) @@ -560,7 +560,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec } else if warningLinks > 0 { check.Status = api.CheckStatusWarn check.Score = 0.3 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks) check.Advice = api.PtrTo("Review links that could not be verified") details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks) @@ -568,7 +568,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec } else { check.Status = api.CheckStatusPass check.Score = 0.4 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links)) check.Advice = api.PtrTo("Your links are working properly") } @@ -601,7 +601,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che if noAltCount == len(results.Images) { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "No images have alt attributes" check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability") details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images)) @@ -609,7 +609,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che } else if noAltCount > 0 { check.Status = api.CheckStatusWarn check.Score = 0.2 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount) check.Advice = api.PtrTo("Add alt text to all images for better accessibility") details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images)) @@ -617,7 +617,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che } else { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All images have alt attributes" check.Advice = api.PtrTo("Your images are properly tagged for accessibility") } @@ -636,13 +636,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api. if !results.HasUnsubscribe { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "No unsubscribe link found" check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)") } else { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks)) check.Advice = api.PtrTo("Your email includes an unsubscribe option") } @@ -662,7 +662,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) if consistency < 0.3 { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "Plain text and HTML versions differ significantly" check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content") details := fmt.Sprintf("Consistency: %.0f%%", consistency*100) @@ -670,7 +670,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) } else { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "Plain text and HTML versions are consistent" check.Advice = api.PtrTo("Your multipart email is well-structured") details := fmt.Sprintf("Consistency: %.0f%%", consistency*100) @@ -693,7 +693,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C if ratio > 10.0 { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "Email is excessively image-heavy" check.Advice = api.PtrTo("Reduce the number of images relative to text content") details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) @@ -701,7 +701,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C } else if ratio > 5.0 { check.Status = api.CheckStatusWarn check.Score = 0.2 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "Email has high image-to-text ratio" check.Advice = api.PtrTo("Consider adding more text content relative to images") details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) @@ -709,7 +709,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C } else { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "Image-to-text ratio is reasonable" check.Advice = api.PtrTo("Your content has a good balance of images and text") details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio) @@ -730,7 +730,7 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count) check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails") diff --git a/internal/analyzer/dns.go b/internal/analyzer/dns.go index b411386..9a6d26f 100644 --- a/internal/analyzer/dns.go +++ b/internal/analyzer/dns.go @@ -537,7 +537,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { check.Message = results.MXRecords[0].Error @@ -548,7 +548,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { } else { check.Status = api.CheckStatusPass check.Score = 1.0 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) // Add details about MX records @@ -577,14 +577,14 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = spf.Error - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability") } else { // If record exists but is invalid, it's a warning check.Status = api.CheckStatusWarn check.Score = 0.5 check.Message = "SPF record found but appears invalid" - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Review and fix your SPF record syntax") check.Details = &spf.Record } @@ -592,7 +592,7 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "Valid SPF record found" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &spf.Record check.Advice = api.PtrTo("Your SPF record is properly configured") } @@ -611,7 +611,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) check.Details = &details @@ -619,7 +619,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = "Valid DKIM record found" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) check.Details = &details check.Advice = api.PtrTo("Your DKIM record is properly published") @@ -639,13 +639,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { check.Status = api.CheckStatusFail check.Score = 0.0 check.Message = dmarc.Error - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") } else { check.Status = api.CheckStatusPass check.Score = 1.0 check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &dmarc.Record // Provide advice based on policy @@ -681,14 +681,14 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { check.Status = api.CheckStatusInfo check.Score = 0.0 check.Message = "No BIMI record found (optional)" - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)") } else { // If record exists but is invalid check.Status = api.CheckStatusWarn check.Score = 0.0 check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error) - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)") check.Details = &bimi.Record } @@ -696,7 +696,7 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { check.Status = api.CheckStatusPass check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) check.Message = "Valid BIMI record found" - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) // Build details with logo and VMC URLs var detailsParts []string diff --git a/internal/analyzer/rbl.go b/internal/analyzer/rbl.go index be7366c..fb01ae0 100644 --- a/internal/analyzer/rbl.go +++ b/internal/analyzer/rbl.go @@ -279,7 +279,7 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { Status: api.CheckStatusWarn, Score: 1.0, Message: "No public IP addresses found to check", - Severity: api.PtrTo(api.Low), + Severity: api.PtrTo(api.CheckSeverityLow), Advice: api.PtrTo("Unable to extract sender IP from email headers"), }) return checks @@ -316,22 +316,22 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { if listedCount == 0 { check.Status = api.CheckStatusPass check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs)) - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your sending IP has a good reputation") } else if listedCount == 1 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks) - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate") } else if listedCount <= 3 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action") } else { check.Status = api.CheckStatusFail check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks) - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL") } @@ -357,15 +357,15 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { // Determine severity based on which RBL if strings.Contains(rblCheck.RBL, "spamhaus") { - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting") check.Advice = &advice } else if strings.Contains(rblCheck.RBL, "spamcop") { - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting") check.Advice = &advice } else { - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL) check.Advice = &advice } diff --git a/internal/analyzer/rbl_test.go b/internal/analyzer/rbl_test.go index a75ef19..3a2fd44 100644 --- a/internal/analyzer/rbl_test.go +++ b/internal/analyzer/rbl_test.go @@ -419,7 +419,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.Critical, + expectedSeverity: api.CheckSeverityCritical, }, { name: "SpamCop listing", @@ -430,7 +430,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.High, + expectedSeverity: api.CheckSeverityHigh, }, { name: "Other RBL listing", @@ -441,7 +441,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.High, + expectedSeverity: api.CheckSeverityHigh, }, } diff --git a/internal/analyzer/scoring.go b/internal/analyzer/scoring.go index 07f6a34..115a497 100644 --- a/internal/analyzer/scoring.go +++ b/internal/analyzer/scoring.go @@ -351,13 +351,13 @@ func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) if len(missing) == 0 { check.Status = api.CheckStatusPass check.Score = 0.4 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All required headers are present" check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") } else { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) @@ -386,13 +386,13 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa if len(missing) == 0 { check.Status = api.CheckStatusPass check.Score = 0.3 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All recommended headers are present" check.Advice = api.PtrTo("Your email includes all recommended headers") } else if len(missing) < len(recommendedHeaders) { check.Status = api.CheckStatusWarn check.Score = 0.15 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) @@ -400,7 +400,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa } else { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "Missing all recommended headers" check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") } @@ -420,20 +420,20 @@ func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.C if messageID == "" { check.Status = api.CheckStatusFail check.Score = 0.0 - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Message = "Message-ID header is missing" check.Advice = api.PtrTo("Add a unique Message-ID header to your email") } else if !s.isValidMessageID(messageID) { check.Status = api.CheckStatusWarn check.Score = 0.05 - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "Message-ID format is invalid" check.Advice = api.PtrTo("Use proper Message-ID format: ") check.Details = &messageID } else { check.Status = api.CheckStatusPass check.Score = 0.1 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "Message-ID is properly formatted" check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") check.Details = &messageID @@ -452,13 +452,13 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a if len(email.Parts) == 0 { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.Low) + check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "No MIME parts detected" check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") } else { check.Status = api.CheckStatusPass check.Score = 0.2 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) check.Advice = api.PtrTo("Your email has proper MIME structure") diff --git a/internal/analyzer/spamassassin.go b/internal/analyzer/spamassassin.go index 78a6a72..474884e 100644 --- a/internal/analyzer/spamassassin.go +++ b/internal/analyzer/spamassassin.go @@ -217,7 +217,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe Status: api.CheckStatusWarn, Score: 0.0, Message: "No SpamAssassin headers found", - Severity: api.PtrTo(api.Medium), + Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), }) return checks @@ -260,27 +260,27 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) if score <= 0 { check.Status = api.CheckStatusPass check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices") } else if score < required { check.Status = api.CheckStatusPass check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Advice = api.PtrTo("Your email passes spam filters") } else if score < required*1.5 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below") } else if score < required*2 { check.Status = api.CheckStatusWarn check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests") } else { check.Status = api.CheckStatusFail check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required) - check.Severity = api.PtrTo(api.Critical) + check.Severity = api.PtrTo(api.CheckSeverityCritical) check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures") } @@ -307,10 +307,10 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec // Negative indicator (increases spam score) if detail.Score > 2.0 { check.Status = api.CheckStatusFail - check.Severity = api.PtrTo(api.High) + check.Severity = api.PtrTo(api.CheckSeverityHigh) } else { check.Status = api.CheckStatusWarn - check.Severity = api.PtrTo(api.Medium) + check.Severity = api.PtrTo(api.CheckSeverityMedium) } check.Score = 0.0 check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) @@ -320,7 +320,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec // Positive indicator (decreases spam score) check.Status = api.CheckStatusPass check.Score = 1.0 - check.Severity = api.PtrTo(api.Info) + check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score) advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score) check.Advice = &advice From 01569d1b218cb785e03fb7e9bcc04c10c75df097 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 18:39:21 +0700 Subject: [PATCH 3/5] Refactor authentication.go --- internal/analyzer/authentication.go | 315 -------- internal/analyzer/authentication_checks.go | 304 ++++++++ internal/analyzer/authentication_test.go | 846 +++++++++++++++++++++ internal/analyzer/scoring.go | 43 +- 4 files changed, 1191 insertions(+), 317 deletions(-) create mode 100644 internal/analyzer/authentication_checks.go create mode 100644 internal/analyzer/authentication_test.go diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go index a8c8df9..d6fd600 100644 --- a/internal/analyzer/authentication.go +++ b/internal/analyzer/authentication.go @@ -505,318 +505,3 @@ func textprotoCanonical(s string) string { } return strings.Join(words, "-") } - -// GetAuthenticationScore calculates the authentication score (0-3 points) -func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { - var score float32 = 0.0 - - // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 1.0 - case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: - score += 0.5 - } - } - - // DKIM: 1 point for at least one pass - if results.Dkim != nil && len(*results.Dkim) > 0 { - for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { - score += 1.0 - break - } - } - } - - // DMARC: 1 point for pass - if results.Dmarc != nil { - switch results.Dmarc.Result { - case api.AuthResultResultPass: - score += 1.0 - } - } - - // Cap at 3 points maximum - if score > 3.0 { - score = 3.0 - } - - return score -} - -// GenerateAuthenticationChecks generates check results for authentication -func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check { - var checks []api.Check - - // SPF check - if results.Spf != nil { - check := a.generateSPFCheck(results.Spf) - checks = append(checks, check) - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "SPF Record", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No SPF authentication result found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), - }) - } - - // DKIM check - if results.Dkim != nil && len(*results.Dkim) > 0 { - for i, dkim := range *results.Dkim { - check := a.generateDKIMCheck(&dkim, i) - checks = append(checks, check) - } - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "DKIM Signature", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No DKIM signature found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), - }) - } - - // DMARC check - if results.Dmarc != nil { - check := a.generateDMARCCheck(results.Dmarc) - checks = append(checks, check) - } else { - checks = append(checks, api.Check{ - Category: api.Authentication, - Name: "DMARC Policy", - Status: api.CheckStatusWarn, - Score: 0.0, - Message: "No DMARC authentication result found", - Severity: api.PtrTo(api.CheckSeverityMedium), - Advice: api.PtrTo("Implement DMARC policy for your domain"), - }) - } - - // BIMI check (optional, informational only) - if results.Bimi != nil { - check := a.generateBIMICheck(results.Bimi) - checks = append(checks, check) - } - - // ARC check (optional, for forwarded emails) - if results.Arc != nil { - check := a.generateARCCheck(results.Arc) - checks = append(checks, check) - } - - return checks -} - -func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "SPF Record", - } - - switch spf.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "SPF validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your SPF record is properly configured") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "SPF validation failed" - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") - case api.AuthResultResultSoftfail: - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF validation softfail" - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Review your SPF record configuration") - case api.AuthResultResultNeutral: - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Message = "SPF validation neutral" - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("Consider tightening your SPF policy") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Review your SPF record configuration") - } - - if spf.Domain != nil { - details := fmt.Sprintf("Domain: %s", *spf.Domain) - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: fmt.Sprintf("DKIM Signature #%d", index+1), - } - - switch dkim.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "DKIM signature is valid" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your DKIM signature is properly configured") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "DKIM signature validation failed" - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") - } - - var detailsParts []string - if dkim.Domain != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain)) - } - if dkim.Selector != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector)) - } - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, ", ") - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "DMARC Policy", - } - - switch dmarc.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Message = "DMARC validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your DMARC policy is properly aligned") - case api.AuthResultResultFail: - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Message = "DMARC validation failed" - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") - default: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("Configure DMARC policy for your domain") - } - - if dmarc.Domain != nil { - details := fmt.Sprintf("Domain: %s", *dmarc.Domain) - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "BIMI (Brand Indicators)", - } - - switch bimi.Result { - case api.AuthResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) - check.Message = "BIMI validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI") - case api.AuthResultResultFail: - check.Status = api.CheckStatusInfo - check.Score = 0.0 - check.Message = "BIMI validation failed" - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record") - default: - check.Status = api.CheckStatusInfo - check.Score = 0.0 - check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients") - } - - if bimi.Domain != nil { - details := fmt.Sprintf("Domain: %s", *bimi.Domain) - check.Details = &details - } - - return check -} - -func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check { - check := api.Check{ - Category: api.Authentication, - Name: "ARC (Authenticated Received Chain)", - } - - switch arc.Result { - case api.ARCResultResultPass: - check.Status = api.CheckStatusPass - check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding) - check.Message = "ARC chain validation passed" - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication") - case api.ARCResultResultFail: - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Message = "ARC chain validation failed" - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries") - default: - check.Status = api.CheckStatusInfo - check.Score = 0.0 - check.Message = "No ARC chain present" - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries") - } - - // Build details - var detailsParts []string - if arc.ChainLength != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength)) - } - if arc.ChainValid != nil { - detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid)) - } - if arc.Details != nil { - detailsParts = append(detailsParts, *arc.Details) - } - - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, ", ") - check.Details = &details - } - - return check -} diff --git a/internal/analyzer/authentication_checks.go b/internal/analyzer/authentication_checks.go new file mode 100644 index 0000000..01298a0 --- /dev/null +++ b/internal/analyzer/authentication_checks.go @@ -0,0 +1,304 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 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 ( + "fmt" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// GenerateAuthenticationChecks generates check results for authentication +func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check { + var checks []api.Check + + // SPF check + if results.Spf != nil { + check := a.generateSPFCheck(results.Spf) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "SPF Record", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No SPF authentication result found", + Severity: api.PtrTo(api.CheckSeverityMedium), + Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), + }) + } + + // DKIM check + if results.Dkim != nil && len(*results.Dkim) > 0 { + for i, dkim := range *results.Dkim { + check := a.generateDKIMCheck(&dkim, i) + checks = append(checks, check) + } + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DKIM Signature", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DKIM signature found", + Severity: api.PtrTo(api.CheckSeverityMedium), + Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), + }) + } + + // DMARC check + if results.Dmarc != nil { + check := a.generateDMARCCheck(results.Dmarc) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DMARC authentication result found", + Severity: api.PtrTo(api.CheckSeverityMedium), + Advice: api.PtrTo("Implement DMARC policy for your domain"), + }) + } + + // BIMI check (optional, informational only) + if results.Bimi != nil { + check := a.generateBIMICheck(results.Bimi) + checks = append(checks, check) + } + + // ARC check (optional, for forwarded emails) + if results.Arc != nil { + check := a.generateARCCheck(results.Arc) + checks = append(checks, check) + } + + return checks +} + +func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "SPF Record", + } + + switch spf.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "SPF validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your SPF record is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "SPF validation failed" + check.Severity = api.PtrTo(api.CheckSeverityCritical) + check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") + case api.AuthResultResultSoftfail: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation softfail" + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Review your SPF record configuration") + case api.AuthResultResultNeutral: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation neutral" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("Consider tightening your SPF policy") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Review your SPF record configuration") + } + + if spf.Domain != nil { + details := fmt.Sprintf("Domain: %s", *spf.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: fmt.Sprintf("DKIM Signature #%d", index+1), + } + + switch dkim.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DKIM signature is valid" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your DKIM signature is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DKIM signature validation failed" + check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") + } + + var detailsParts []string + if dkim.Domain != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain)) + } + if dkim.Selector != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector)) + } + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + } + + switch dmarc.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DMARC validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your DMARC policy is properly aligned") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DMARC validation failed" + check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("Configure DMARC policy for your domain") + } + + if dmarc.Domain != nil { + details := fmt.Sprintf("Domain: %s", *dmarc.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "BIMI (Brand Indicators)", + } + + switch bimi.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Message = "BIMI validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI") + case api.AuthResultResultFail: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "BIMI validation failed" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record") + default: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result) + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients") + } + + if bimi.Domain != nil { + details := fmt.Sprintf("Domain: %s", *bimi.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "ARC (Authenticated Received Chain)", + } + + switch arc.Result { + case api.ARCResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding) + check.Message = "ARC chain validation passed" + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication") + case api.ARCResultResultFail: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = "ARC chain validation failed" + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries") + default: + check.Status = api.CheckStatusInfo + check.Score = 0.0 + check.Message = "No ARC chain present" + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries") + } + + // Build details + var detailsParts []string + if arc.ChainLength != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength)) + } + if arc.ChainValid != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid)) + } + if arc.Details != nil { + detailsParts = append(detailsParts, *arc.Details) + } + + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/authentication_test.go b/internal/analyzer/authentication_test.go new file mode 100644 index 0000000..17ac24e --- /dev/null +++ b/internal/analyzer/authentication_test.go @@ -0,0 +1,846 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 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 ( + "strings" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestParseSPFResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "SPF pass with domain", + part: "spf=pass smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "SPF fail", + part: "spf=fail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + { + name: "SPF neutral", + part: "spf=neutral smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultNeutral, + expectedDomain: "example.com", + }, + { + name: "SPF softfail", + part: "spf=softfail smtp.mailfrom=sender@example.com", + expectedResult: api.AuthResultResultSoftfail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseSPFResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseDKIMResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.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, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "DKIM fail", + part: "dkim=fail header.d=example.com header.s=selector1", + expectedResult: api.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, + expectedDomain: "example.com", + expectedSelector: "default", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDKIMResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + }) + } +} + +func TestParseDMARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.AuthResultResult + expectedDomain string + }{ + { + name: "DMARC pass", + part: "dmarc=pass action=none header.from=example.com", + expectedResult: api.AuthResultResultPass, + expectedDomain: "example.com", + }, + { + name: "DMARC fail", + part: "dmarc=fail action=quarantine header.from=example.com", + expectedResult: api.AuthResultResultFail, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseDMARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + }) + } +} + +func TestParseBIMIResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.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, + expectedDomain: "example.com", + expectedSelector: "default", + }, + { + name: "BIMI fail", + part: "bimi=fail header.d=example.com header.selector=default", + expectedResult: api.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, + expectedDomain: "example.com", + expectedSelector: "v1", + }, + { + name: "BIMI none", + part: "bimi=none header.d=example.com", + expectedResult: api.AuthResultResultNone, + expectedDomain: "example.com", + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseBIMIResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + if result.Domain == nil || *result.Domain != tt.expectedDomain { + var gotDomain string + if result.Domain != nil { + gotDomain = *result.Domain + } + t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain) + } + if tt.expectedSelector != "" { + if result.Selector == nil || *result.Selector != tt.expectedSelector { + var gotSelector string + if result.Selector != nil { + gotSelector = *result.Selector + } + t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector) + } + } + }) + } +} + +func TestGenerateAuthSPFCheck(t *testing.T) { + tests := []struct { + name string + spf *api.AuthResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "SPF pass", + spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "SPF fail", + spf: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + { + name: "SPF softfail", + spf: &api.AuthResult{ + Result: api.AuthResultResultSoftfail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.5, + }, + { + name: "SPF neutral", + spf: &api.AuthResult{ + Result: api.AuthResultResultNeutral, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.5, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateSPFCheck(tt.spf) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if check.Name != "SPF Record" { + t.Errorf("Name = %q, want %q", check.Name, "SPF Record") + } + }) + } +} + +func TestGenerateAuthDKIMCheck(t *testing.T) { + tests := []struct { + name string + dkim *api.AuthResult + index int + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "DKIM pass", + dkim: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + Selector: api.PtrTo("default"), + }, + index: 0, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "DKIM fail", + dkim: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + Selector: api.PtrTo("default"), + }, + index: 0, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + { + name: "DKIM none", + dkim: &api.AuthResult{ + Result: api.AuthResultResultNone, + Domain: api.PtrTo("example.com"), + Selector: api.PtrTo("default"), + }, + index: 0, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateDKIMCheck(tt.dkim, tt.index) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if !strings.Contains(check.Name, "DKIM Signature") { + t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name) + } + }) + } +} + +func TestGenerateAuthDMARCCheck(t *testing.T) { + tests := []struct { + name string + dmarc *api.AuthResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "DMARC pass", + dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 1.0, + }, + { + name: "DMARC fail", + dmarc: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateDMARCCheck(tt.dmarc) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if check.Name != "DMARC Policy" { + t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy") + } + }) + } +} + +func TestGenerateAuthBIMICheck(t *testing.T) { + tests := []struct { + name string + bimi *api.AuthResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "BIMI pass", + bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, // BIMI doesn't contribute to score + }, + { + name: "BIMI fail", + bimi: &api.AuthResult{ + Result: api.AuthResultResultFail, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + { + name: "BIMI none", + bimi: &api.AuthResult{ + Result: api.AuthResultResultNone, + Domain: api.PtrTo("example.com"), + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateBIMICheck(tt.bimi) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if check.Name != "BIMI (Brand Indicators)" { + t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)") + } + + // BIMI should always have score of 0.0 (branding feature) + if check.Score != 0.0 { + t.Error("BIMI should not contribute to deliverability score") + } + }) + } +} + +func TestGetAuthenticationScore(t *testing.T) { + tests := []struct { + name string + results *api.AuthenticationResults + expectedScore float32 + }{ + { + name: "Perfect authentication (SPF + DKIM + DMARC)", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedScore: 3.0, + }, + { + name: "SPF and DKIM only", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + }, + expectedScore: 2.0, + }, + { + name: "SPF fail, DKIM pass", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultFail, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + }, + expectedScore: 1.0, + }, + { + name: "SPF softfail", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultSoftfail, + }, + }, + expectedScore: 0.5, + }, + { + name: "No authentication", + results: &api.AuthenticationResults{}, + expectedScore: 0.0, + }, + { + name: "BIMI doesn't affect score", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedScore: 1.0, // Only SPF counted, not BIMI + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := scorer.GetAuthenticationScore(tt.results) + + if score != tt.expectedScore { + t.Errorf("Score = %v, want %v", score, tt.expectedScore) + } + }) + } +} + +func TestGenerateAuthenticationChecks(t *testing.T) { + tests := []struct { + name string + results *api.AuthenticationResults + expectedChecks int + }{ + { + name: "All authentication methods present", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Bimi: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedChecks: 4, // SPF, DKIM, DMARC, BIMI + }, + { + name: "Without BIMI", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + }, + expectedChecks: 3, // SPF, DKIM, DMARC + }, + { + name: "No authentication results", + results: &api.AuthenticationResults{}, + expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing + }, + { + name: "With ARC", + results: &api.AuthenticationResults{ + Spf: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{ + Result: api.AuthResultResultPass, + }, + Arc: &api.ARCResult{ + Result: api.ARCResultResultPass, + ChainLength: api.PtrTo(2), + ChainValid: api.PtrTo(true), + }, + }, + expectedChecks: 4, // SPF, DKIM, DMARC, ARC + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.GenerateAuthenticationChecks(tt.results) + + if len(checks) != tt.expectedChecks { + t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks) + } + + // Verify all checks have the Authentication category + for _, check := range checks { + if check.Category != api.Authentication { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication) + } + } + }) + } +} + +func TestParseARCResult(t *testing.T) { + tests := []struct { + name string + part string + expectedResult api.ARCResultResult + }{ + { + name: "ARC pass", + part: "arc=pass", + expectedResult: api.ARCResultResultPass, + }, + { + name: "ARC fail", + part: "arc=fail", + expectedResult: api.ARCResultResultFail, + }, + { + name: "ARC none", + part: "arc=none", + expectedResult: api.ARCResultResultNone, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseARCResult(tt.part) + + if result.Result != tt.expectedResult { + t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult) + } + }) + } +} + +func TestValidateARCChain(t *testing.T) { + tests := []struct { + name string + arcAuthResults []string + arcMessageSig []string + arcSeal []string + expectedValid bool + }{ + { + name: "Empty chain is valid", + arcAuthResults: []string{}, + arcMessageSig: []string{}, + arcSeal: []string{}, + expectedValid: true, + }, + { + name: "Valid chain with single hop", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + }, + expectedValid: true, + }, + { + name: "Valid chain with two hops", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=2; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=2; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=2; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: true, + }, + { + name: "Invalid chain - missing one header type", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + }, + arcSeal: []string{}, + expectedValid: false, + }, + { + name: "Invalid chain - non-sequential instances", + arcAuthResults: []string{ + "i=1; example.com; spf=pass", + "i=3; relay.com; arc=pass", + }, + arcMessageSig: []string{ + "i=1; a=rsa-sha256; d=example.com", + "i=3; a=rsa-sha256; d=relay.com", + }, + arcSeal: []string{ + "i=1; a=rsa-sha256; s=arc; d=example.com", + "i=3; a=rsa-sha256; s=arc; d=relay.com", + }, + expectedValid: false, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal) + + if valid != tt.expectedValid { + t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid) + } + }) + } +} + +func TestGenerateARCCheck(t *testing.T) { + tests := []struct { + name string + arc *api.ARCResult + expectedStatus api.CheckStatus + expectedScore float32 + }{ + { + name: "ARC pass", + arc: &api.ARCResult{ + Result: api.ARCResultResultPass, + ChainLength: api.PtrTo(2), + ChainValid: api.PtrTo(true), + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 0.0, // ARC doesn't contribute to score + }, + { + name: "ARC fail", + arc: &api.ARCResult{ + Result: api.ARCResultResultFail, + ChainLength: api.PtrTo(1), + ChainValid: api.PtrTo(false), + }, + expectedStatus: api.CheckStatusWarn, + expectedScore: 0.0, + }, + { + name: "ARC none", + arc: &api.ARCResult{ + Result: api.ARCResultResultNone, + ChainLength: api.PtrTo(0), + ChainValid: api.PtrTo(true), + }, + expectedStatus: api.CheckStatusInfo, + expectedScore: 0.0, + }, + } + + analyzer := NewAuthenticationAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateARCCheck(tt.arc) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Score != tt.expectedScore { + t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) + } + if check.Category != api.Authentication { + t.Errorf("Category = %v, want %v", check.Category, api.Authentication) + } + if !strings.Contains(check.Name, "ARC") { + t.Errorf("Name should contain 'ARC', got %q", check.Name) + } + }) + } +} diff --git a/internal/analyzer/scoring.go b/internal/analyzer/scoring.go index 115a497..03ab870 100644 --- a/internal/analyzer/scoring.go +++ b/internal/analyzer/scoring.go @@ -72,8 +72,7 @@ func (s *DeliverabilityScorer) CalculateScore( } // Calculate individual scores - authAnalyzer := NewAuthenticationAnalyzer() - result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults) + result.AuthScore = s.GetAuthenticationScore(authResults) spamAnalyzer := NewSpamAssassinAnalyzer() result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) @@ -504,3 +503,43 @@ func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { return summary.String() } + +// GetAuthenticationScore calculates the authentication score (0-3 points) +func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { + var score float32 = 0.0 + + // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + score += 1.0 + case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: + score += 0.5 + } + } + + // DKIM: 1 point for at least one pass + if results.Dkim != nil && len(*results.Dkim) > 0 { + for _, dkim := range *results.Dkim { + if dkim.Result == api.AuthResultResultPass { + score += 1.0 + break + } + } + } + + // DMARC: 1 point for pass + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + score += 1.0 + } + } + + // Cap at 3 points maximum + if score > 3.0 { + score = 3.0 + } + + return score +} From fedb80f7d4fe422e741119371d8bb0a9d8066e67 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 07:40:52 +0700 Subject: [PATCH 4/5] Expose analyzer --- internal/app/cli_analyzer.go | 2 +- internal/receiver/receiver.go | 2 +- {internal => pkg}/analyzer/analyzer.go | 0 {internal => pkg}/analyzer/authentication.go | 0 {internal => pkg}/analyzer/authentication_checks.go | 0 {internal => pkg}/analyzer/authentication_test.go | 0 {internal => pkg}/analyzer/content.go | 0 {internal => pkg}/analyzer/content_test.go | 0 {internal => pkg}/analyzer/dns.go | 0 {internal => pkg}/analyzer/dns_test.go | 0 {internal => pkg}/analyzer/parser.go | 0 {internal => pkg}/analyzer/parser_test.go | 0 {internal => pkg}/analyzer/rbl.go | 0 {internal => pkg}/analyzer/rbl_test.go | 0 {internal => pkg}/analyzer/report.go | 0 {internal => pkg}/analyzer/report_test.go | 0 {internal => pkg}/analyzer/scoring.go | 0 {internal => pkg}/analyzer/scoring_test.go | 0 {internal => pkg}/analyzer/spamassassin.go | 0 {internal => pkg}/analyzer/spamassassin_test.go | 0 20 files changed, 2 insertions(+), 2 deletions(-) rename {internal => pkg}/analyzer/analyzer.go (100%) rename {internal => pkg}/analyzer/authentication.go (100%) rename {internal => pkg}/analyzer/authentication_checks.go (100%) rename {internal => pkg}/analyzer/authentication_test.go (100%) rename {internal => pkg}/analyzer/content.go (100%) rename {internal => pkg}/analyzer/content_test.go (100%) rename {internal => pkg}/analyzer/dns.go (100%) rename {internal => pkg}/analyzer/dns_test.go (100%) rename {internal => pkg}/analyzer/parser.go (100%) rename {internal => pkg}/analyzer/parser_test.go (100%) rename {internal => pkg}/analyzer/rbl.go (100%) rename {internal => pkg}/analyzer/rbl_test.go (100%) rename {internal => pkg}/analyzer/report.go (100%) rename {internal => pkg}/analyzer/report_test.go (100%) rename {internal => pkg}/analyzer/scoring.go (100%) rename {internal => pkg}/analyzer/scoring_test.go (100%) rename {internal => pkg}/analyzer/spamassassin.go (100%) rename {internal => pkg}/analyzer/spamassassin_test.go (100%) diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 87a4e0a..2cccf1b 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -31,9 +31,9 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/analyzer" "git.happydns.org/happyDeliver/internal/api" "git.happydns.org/happyDeliver/internal/config" + "git.happydns.org/happyDeliver/pkg/analyzer" ) // RunAnalyzer runs the standalone email analyzer (from stdin) diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index db1c2ea..1132b54 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -31,9 +31,9 @@ import ( "github.com/google/uuid" - "git.happydns.org/happyDeliver/internal/analyzer" "git.happydns.org/happyDeliver/internal/config" "git.happydns.org/happyDeliver/internal/storage" + "git.happydns.org/happyDeliver/pkg/analyzer" ) // EmailReceiver handles incoming emails from the MTA diff --git a/internal/analyzer/analyzer.go b/pkg/analyzer/analyzer.go similarity index 100% rename from internal/analyzer/analyzer.go rename to pkg/analyzer/analyzer.go diff --git a/internal/analyzer/authentication.go b/pkg/analyzer/authentication.go similarity index 100% rename from internal/analyzer/authentication.go rename to pkg/analyzer/authentication.go diff --git a/internal/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go similarity index 100% rename from internal/analyzer/authentication_checks.go rename to pkg/analyzer/authentication_checks.go diff --git a/internal/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go similarity index 100% rename from internal/analyzer/authentication_test.go rename to pkg/analyzer/authentication_test.go diff --git a/internal/analyzer/content.go b/pkg/analyzer/content.go similarity index 100% rename from internal/analyzer/content.go rename to pkg/analyzer/content.go diff --git a/internal/analyzer/content_test.go b/pkg/analyzer/content_test.go similarity index 100% rename from internal/analyzer/content_test.go rename to pkg/analyzer/content_test.go diff --git a/internal/analyzer/dns.go b/pkg/analyzer/dns.go similarity index 100% rename from internal/analyzer/dns.go rename to pkg/analyzer/dns.go diff --git a/internal/analyzer/dns_test.go b/pkg/analyzer/dns_test.go similarity index 100% rename from internal/analyzer/dns_test.go rename to pkg/analyzer/dns_test.go diff --git a/internal/analyzer/parser.go b/pkg/analyzer/parser.go similarity index 100% rename from internal/analyzer/parser.go rename to pkg/analyzer/parser.go diff --git a/internal/analyzer/parser_test.go b/pkg/analyzer/parser_test.go similarity index 100% rename from internal/analyzer/parser_test.go rename to pkg/analyzer/parser_test.go diff --git a/internal/analyzer/rbl.go b/pkg/analyzer/rbl.go similarity index 100% rename from internal/analyzer/rbl.go rename to pkg/analyzer/rbl.go diff --git a/internal/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go similarity index 100% rename from internal/analyzer/rbl_test.go rename to pkg/analyzer/rbl_test.go diff --git a/internal/analyzer/report.go b/pkg/analyzer/report.go similarity index 100% rename from internal/analyzer/report.go rename to pkg/analyzer/report.go diff --git a/internal/analyzer/report_test.go b/pkg/analyzer/report_test.go similarity index 100% rename from internal/analyzer/report_test.go rename to pkg/analyzer/report_test.go diff --git a/internal/analyzer/scoring.go b/pkg/analyzer/scoring.go similarity index 100% rename from internal/analyzer/scoring.go rename to pkg/analyzer/scoring.go diff --git a/internal/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go similarity index 100% rename from internal/analyzer/scoring_test.go rename to pkg/analyzer/scoring_test.go diff --git a/internal/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go similarity index 100% rename from internal/analyzer/spamassassin.go rename to pkg/analyzer/spamassassin.go diff --git a/internal/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go similarity index 100% rename from internal/analyzer/spamassassin_test.go rename to pkg/analyzer/spamassassin_test.go From f1b9ac1e27f78b8067b40a3f87aa439d0caca0cf Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 20 Oct 2025 09:27:42 +0700 Subject: [PATCH 5/5] Fix spamassassin report details --- pkg/analyzer/spamassassin.go | 23 ++-- pkg/analyzer/spamassassin_test.go | 171 ++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 9 deletions(-) diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 474884e..00cab21 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -86,7 +86,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss // Parse X-Spam-Report header for detailed test results if reportHeader, ok := headers["X-Spam-Report"]; ok { - result.RawReport = reportHeader + result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1) a.parseSpamReport(reportHeader, result) } @@ -140,20 +140,25 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass // Format varies, but typically: // * 1.5 TEST_NAME Description of test // * 0.0 TEST_NAME2 Description +// Note: mail.Header.Get() joins continuation lines, so newlines are removed. +// We split on '*' to separate individual tests. func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) { - // Split by lines - lines := strings.Split(report, "\n") + // The report header has been joined by mail.Header.Get(), so we split on '*' + // Each segment starting with '*' is either a test line or continuation + segments := strings.Split(report, "*") - // Regex to match test lines: * score TEST_NAME Description - testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) + // Regex to match test lines: score TEST_NAME Description + // Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description" + testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { continue } - matches := testRe.FindStringSubmatch(line) + // Try to match as a test line + matches := testRe.FindStringSubmatch(segment) if len(matches) > 3 { testName := matches[2] score, _ := strconv.ParseFloat(matches[1], 64) diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index 4682ed3..e7491db 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -22,6 +22,7 @@ package analyzer import ( + "bytes" "net/mail" "strings" "testing" @@ -480,6 +481,176 @@ func TestGenerateTestCheck(t *testing.T) { } } +const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec +X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID, + DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED, + RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED, + SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1 +X-Spam-Level: +X-Spam-Report: + * 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query + * to Validity was blocked. See + * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for + * more information. + * [80.67.179.207 listed in sa-accredit.habeas.com] + * 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query + * to Validity was blocked. See + * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for + * more information. + * [80.67.179.207 listed in bl.score.senderscore.com] + * 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The + * query to Validity was blocked. See + * https://knowledge.validity.com/hc/en-us/articles/20961730681243 for + * more information. + * [80.67.179.207 listed in sa-trusted.bondedsender.org] + * -0.0 SPF_PASS SPF: sender matches SPF record + * 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record + * -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature + * 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily + * valid + * -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's + * domain +Date: Sun, 19 Oct 2025 08:37:30 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Disposition: inline +Content-Transfer-Encoding: 8bit + +BODY` + +// TestAnalyzeRealEmailExample tests the analyzer with the real example email file +func TestAnalyzeRealEmailExample(t *testing.T) { + // Parse the email using the standard net/mail package + email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + // Create analyzer and analyze SpamAssassin headers + analyzer := NewSpamAssassinAnalyzer() + result := analyzer.AnalyzeSpamAssassin(email) + + // Validate that we got a result + if result == nil { + t.Fatal("Expected SpamAssassin result, got nil") + } + + // Validate IsSpam flag (should be false for this email) + if result.IsSpam { + t.Error("IsSpam should be false for real_example.eml") + } + + // Validate score (should be -0.1) + expectedScore := -0.1 + if result.Score != expectedScore { + t.Errorf("Score = %v, want %v", result.Score, expectedScore) + } + + // Validate required score (should be 5.0) + expectedRequired := 5.0 + if result.RequiredScore != expectedRequired { + t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired) + } + + // Validate version + if !strings.Contains(result.Version, "SpamAssassin") { + t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version) + } + + // Validate that tests were extracted + if len(result.Tests) == 0 { + t.Error("Expected tests to be extracted, got none") + } + + // Check for expected tests from the real email + expectedTests := map[string]bool{ + "DKIM_SIGNED": true, + "DKIM_VALID": true, + "DKIM_VALID_AU": true, + "SPF_PASS": true, + "SPF_HELO_NONE": true, + } + + for _, testName := range result.Tests { + if expectedTests[testName] { + t.Logf("Found expected test: %s", testName) + } + } + + // Validate that test details were parsed from X-Spam-Report + if len(result.TestDetails) == 0 { + t.Error("Expected test details to be parsed from X-Spam-Report, got none") + } + + // Log what we actually got for debugging + t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails)) + for name, detail := range result.TestDetails { + t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description) + } + + // Define expected test details with their scores + expectedTestDetails := map[string]float64{ + "SPF_PASS": -0.0, + "SPF_HELO_NONE": 0.0, + "DKIM_VALID": -0.1, + "DKIM_SIGNED": 0.1, + "DKIM_VALID_AU": -0.1, + "RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0, + "RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0, + "RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0, + } + + // Iterate over expected tests and verify they exist in TestDetails + for testName, expectedScore := range expectedTestDetails { + detail, ok := result.TestDetails[testName] + if !ok { + t.Errorf("Expected test %s not found in TestDetails", testName) + continue + } + if detail.Score != expectedScore { + t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore) + } + if detail.Description == "" { + t.Errorf("Test %s should have a description", testName) + } + } + + // Test GetSpamAssassinScore + score := analyzer.GetSpamAssassinScore(result) + if score != 2.0 { + t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score) + } + + // Test GenerateSpamAssassinChecks + checks := analyzer.GenerateSpamAssassinChecks(result) + if len(checks) < 1 { + t.Fatal("Expected at least 1 check, got none") + } + + // Main check should be PASS with excellent score + mainCheck := checks[0] + if mainCheck.Status != api.CheckStatusPass { + t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass) + } + if mainCheck.Category != api.Spam { + t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam) + } + if !strings.Contains(mainCheck.Message, "spam score") { + t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message) + } + if mainCheck.Score != 2.0 { + t.Errorf("Main check score = %v, want 2.0", mainCheck.Score) + } + + // Log all checks for debugging + t.Logf("Generated %d checks:", len(checks)) + for i, check := range checks { + t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)", + i+1, check.Name, check.Message, check.Score, check.Status) + } +} + // Helper function to compare string slices func stringSliceEqual(a, b []string) bool { if len(a) != len(b) {