diff --git a/README.md b/README.md index a509d8c..c76e248 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, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, 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,8 +194,6 @@ 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 83151de..467f62c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -353,10 +353,6 @@ components: $ref: '#/components/schemas/AuthResult' dmarc: $ref: '#/components/schemas/AuthResult' - bimi: - $ref: '#/components/schemas/AuthResult' - arc: - $ref: '#/components/schemas/ARCResult' AuthResult: type: object @@ -380,29 +376,6 @@ 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: @@ -447,7 +420,7 @@ components: example: "example.com" record_type: type: string - enum: [MX, SPF, DKIM, DMARC, BIMI] + enum: [MX, SPF, DKIM, DMARC] description: DNS record type example: "SPF" status: diff --git a/pkg/analyzer/analyzer.go b/internal/analyzer/analyzer.go similarity index 100% rename from pkg/analyzer/analyzer.go rename to internal/analyzer/analyzer.go diff --git a/pkg/analyzer/authentication.go b/internal/analyzer/authentication.go similarity index 57% rename from pkg/analyzer/authentication.go rename to internal/analyzer/authentication.go index d6fd600..45df0a3 100644 --- a/pkg/analyzer/authentication.go +++ b/internal/analyzer/authentication.go @@ -59,14 +59,6 @@ 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 } @@ -112,20 +104,6 @@ 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) - } - } - - // Parse ARC - if strings.HasPrefix(part, "arc=") { - if results.Arc == nil { - results.Arc = a.parseARCResult(part) - } - } } } @@ -236,201 +214,6 @@ 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 -} - -// 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") @@ -505,3 +288,224 @@ 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.Medium), + 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.Medium), + 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.Medium), + Advice: api.PtrTo("Implement DMARC policy for your domain"), + }) + } + + 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.Info) + 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.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.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.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.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.Info) + 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.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.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.Info) + 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.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.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 +} diff --git a/pkg/analyzer/content.go b/internal/analyzer/content.go similarity index 96% rename from pkg/analyzer/content.go rename to internal/analyzer/content.go index ac46259..bad38c9 100644 --- a/pkg/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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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.CheckSeverityLow) + check.Severity = api.PtrTo(api.Low) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) 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.CheckSeverityLow) + check.Severity = api.PtrTo(api.Low) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityLow) + check.Severity = api.PtrTo(api.Low) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityLow) + check.Severity = api.PtrTo(api.Low) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) 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.CheckSeverityLow) + check.Severity = api.PtrTo(api.Low) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) 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/pkg/analyzer/content_test.go b/internal/analyzer/content_test.go similarity index 100% rename from pkg/analyzer/content_test.go rename to internal/analyzer/content_test.go diff --git a/pkg/analyzer/dns.go b/internal/analyzer/dns.go similarity index 73% rename from pkg/analyzer/dns.go rename to internal/analyzer/dns.go index 9a6d26f..07c0346 100644 --- a/pkg/analyzer/dns.go +++ b/internal/analyzer/dns.go @@ -58,7 +58,6 @@ type DNSResults struct { SPFRecord *SPFRecord DKIMRecords []DKIMRecord DMARCRecord *DMARCRecord - BIMIRecord *BIMIRecord Errors []string } @@ -94,17 +93,6 @@ 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 @@ -140,9 +128,6 @@ 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 } @@ -410,89 +395,6 @@ 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 @@ -519,11 +421,6 @@ 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 } @@ -537,7 +434,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.CheckSeverityCritical) + check.Severity = api.PtrTo(api.Critical) if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { check.Message = results.MXRecords[0].Error @@ -548,7 +445,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { } else { check.Status = api.CheckStatusPass check.Score = 1.0 - check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) // Add details about MX records @@ -577,14 +474,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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) check.Advice = api.PtrTo("Review and fix your SPF record syntax") check.Details = &spf.Record } @@ -592,7 +489,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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) check.Details = &spf.Record check.Advice = api.PtrTo("Your SPF record is properly configured") } @@ -611,7 +508,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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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 +516,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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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 +536,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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) check.Details = &dmarc.Record // Provide advice based on policy @@ -667,53 +564,3 @@ 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.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.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 - } - } 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.CheckSeverityInfo) - - // 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/pkg/analyzer/dns_test.go b/internal/analyzer/dns_test.go similarity index 75% rename from pkg/analyzer/dns_test.go rename to internal/analyzer/dns_test.go index 12a6bd0..fe501d5 100644 --- a/pkg/analyzer/dns_test.go +++ b/internal/analyzer/dns_test.go @@ -631,190 +631,3 @@ 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/pkg/analyzer/parser.go b/internal/analyzer/parser.go similarity index 100% rename from pkg/analyzer/parser.go rename to internal/analyzer/parser.go diff --git a/pkg/analyzer/parser_test.go b/internal/analyzer/parser_test.go similarity index 100% rename from pkg/analyzer/parser_test.go rename to internal/analyzer/parser_test.go diff --git a/pkg/analyzer/rbl.go b/internal/analyzer/rbl.go similarity index 96% rename from pkg/analyzer/rbl.go rename to internal/analyzer/rbl.go index fb01ae0..be7366c 100644 --- a/pkg/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.CheckSeverityLow), + Severity: api.PtrTo(api.Low), 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) 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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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.CheckSeverityCritical) + check.Severity = api.PtrTo(api.Critical) 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.CheckSeverityCritical) + check.Severity = api.PtrTo(api.Critical) 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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL) check.Advice = &advice } diff --git a/pkg/analyzer/rbl_test.go b/internal/analyzer/rbl_test.go similarity index 99% rename from pkg/analyzer/rbl_test.go rename to internal/analyzer/rbl_test.go index 3a2fd44..a75ef19 100644 --- a/pkg/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.CheckSeverityCritical, + expectedSeverity: api.Critical, }, { name: "SpamCop listing", @@ -430,7 +430,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityHigh, + expectedSeverity: api.High, }, { name: "Other RBL listing", @@ -441,7 +441,7 @@ func TestGenerateListingCheck(t *testing.T) { Response: "127.0.0.2", }, expectedStatus: api.CheckStatusFail, - expectedSeverity: api.CheckSeverityHigh, + expectedSeverity: api.High, }, } diff --git a/pkg/analyzer/report.go b/internal/analyzer/report.go similarity index 100% rename from pkg/analyzer/report.go rename to internal/analyzer/report.go diff --git a/pkg/analyzer/report_test.go b/internal/analyzer/report_test.go similarity index 100% rename from pkg/analyzer/report_test.go rename to internal/analyzer/report_test.go diff --git a/pkg/analyzer/scoring.go b/internal/analyzer/scoring.go similarity index 91% rename from pkg/analyzer/scoring.go rename to internal/analyzer/scoring.go index 03ab870..07f6a34 100644 --- a/pkg/analyzer/scoring.go +++ b/internal/analyzer/scoring.go @@ -72,7 +72,8 @@ func (s *DeliverabilityScorer) CalculateScore( } // Calculate individual scores - result.AuthScore = s.GetAuthenticationScore(authResults) + authAnalyzer := NewAuthenticationAnalyzer() + result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults) spamAnalyzer := NewSpamAssassinAnalyzer() result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) @@ -350,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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityCritical) + check.Severity = api.PtrTo(api.Critical) 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, ", ")) @@ -385,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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityLow) + check.Severity = api.PtrTo(api.Low) 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, ", ")) @@ -399,7 +400,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa } else { check.Status = api.CheckStatusWarn check.Score = 0.0 - check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) check.Message = "Missing all recommended headers" check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") } @@ -419,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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) check.Message = "Message-ID is properly formatted" check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") check.Details = &messageID @@ -451,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.CheckSeverityLow) + check.Severity = api.PtrTo(api.Low) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) check.Advice = api.PtrTo("Your email has proper MIME structure") @@ -503,43 +504,3 @@ 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 -} diff --git a/pkg/analyzer/scoring_test.go b/internal/analyzer/scoring_test.go similarity index 100% rename from pkg/analyzer/scoring_test.go rename to internal/analyzer/scoring_test.go diff --git a/pkg/analyzer/spamassassin.go b/internal/analyzer/spamassassin.go similarity index 89% rename from pkg/analyzer/spamassassin.go rename to internal/analyzer/spamassassin.go index 00cab21..78a6a72 100644 --- a/pkg/analyzer/spamassassin.go +++ b/internal/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 = strings.Replace(reportHeader, " * ", "\n * ", -1) + result.RawReport = reportHeader a.parseSpamReport(reportHeader, result) } @@ -140,25 +140,20 @@ 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) { - // 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, "*") + // Split by lines + lines := strings.Split(report, "\n") - // 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+(.*)$`) + // Regex to match test lines: * score TEST_NAME Description + testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`) - for _, segment := range segments { - segment = strings.TrimSpace(segment) - if segment == "" { + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { continue } - // Try to match as a test line - matches := testRe.FindStringSubmatch(segment) + matches := testRe.FindStringSubmatch(line) if len(matches) > 3 { testName := matches[2] score, _ := strconv.ParseFloat(matches[1], 64) @@ -222,7 +217,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe Status: api.CheckStatusWarn, Score: 0.0, Message: "No SpamAssassin headers found", - Severity: api.PtrTo(api.CheckSeverityMedium), + Severity: api.PtrTo(api.Medium), Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), }) return checks @@ -265,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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) 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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) 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.CheckSeverityCritical) + check.Severity = api.PtrTo(api.Critical) check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures") } @@ -312,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.CheckSeverityHigh) + check.Severity = api.PtrTo(api.High) } else { check.Status = api.CheckStatusWarn - check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Severity = api.PtrTo(api.Medium) } check.Score = 0.0 check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) @@ -325,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.CheckSeverityInfo) + check.Severity = api.PtrTo(api.Info) 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 diff --git a/pkg/analyzer/spamassassin_test.go b/internal/analyzer/spamassassin_test.go similarity index 68% rename from pkg/analyzer/spamassassin_test.go rename to internal/analyzer/spamassassin_test.go index e7491db..4682ed3 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/internal/analyzer/spamassassin_test.go @@ -22,7 +22,6 @@ package analyzer import ( - "bytes" "net/mail" "strings" "testing" @@ -481,176 +480,6 @@ 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) { diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 2cccf1b..87a4e0a 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 1132b54..db1c2ea 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/pkg/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go deleted file mode 100644 index 01298a0..0000000 --- a/pkg/analyzer/authentication_checks.go +++ /dev/null @@ -1,304 +0,0 @@ -// 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/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go deleted file mode 100644 index 17ac24e..0000000 --- a/pkg/analyzer/authentication_test.go +++ /dev/null @@ -1,846 +0,0 @@ -// 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/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 8da8dc2..f0709a1 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -26,19 +26,13 @@ icon: "bi-shield-check", title: "Authentication", description: - "SPF, DKIM, DMARC, and BIMI validation with detailed results and recommendations.", + "SPF, DKIM, and DMARC 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, DMARC, and BIMI records are properly configured.", + description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.", variant: "success" as const, }, {