From 433bfd9ee32bfb690c17eac04dfcca3378a68218 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Oct 2025 18:26:37 +0700 Subject: [PATCH] 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