diff --git a/api/openapi.yaml b/api/openapi.yaml index e7ca45c..6762439 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -252,8 +252,7 @@ components: pattern: '^[a-z0-9-]+$' description: Associated test ID (base32-encoded with hyphens) score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Overall deliverability score as percentage (0-100) @@ -298,36 +297,31 @@ components: - header_score properties: authentication_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: SPF/DKIM/DMARC score (in percentage) example: 28 spam_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: SpamAssassin score (in percentage) example: 15 blacklist_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Blacklist check score (in percentage) example: 20 content_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Content quality score (in percentage) example: 18 header_score: - type: number - format: float + type: integer minimum: 0 maximum: 100 description: Header quality score (in percentage) @@ -358,8 +352,7 @@ components: description: Check result status example: "pass" score: - type: number - format: float + type: integer description: Points contributed to total score example: 10 grade: diff --git a/internal/app/cli_analyzer.go b/internal/app/cli_analyzer.go index 2cccf1b..03a1720 100644 --- a/internal/app/cli_analyzer.go +++ b/internal/app/cli_analyzer.go @@ -92,10 +92,6 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT") fmt.Fprintln(writer, strings.Repeat("=", 70)) - // Score summary - summary := emailAnalyzer.GetScoreSummaryText(result) - fmt.Fprintln(writer, summary) - // Detailed checks fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70)) fmt.Fprintln(writer, "DETAILED CHECK RESULTS") diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index dd082a5..80fa7f2 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -79,14 +79,6 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A }, nil } -// GetScoreSummaryText returns a human-readable score summary -func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string { - if result == nil || result.Results == nil { - return "" - } - return a.generator.GetScoreSummaryText(result.Results) -} - // APIAdapter adapts the EmailAnalyzer to work with the API package // This adapter implements the interface expected by the API handler type APIAdapter struct { diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index d6fd600..eef44b1 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -24,6 +24,7 @@ package analyzer import ( "fmt" "regexp" + "slices" "strings" "git.happydns.org/happyDeliver/internal/api" @@ -190,14 +191,7 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { 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 - } - } + result.Details = &part return result } @@ -221,17 +215,7 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { result.Domain = &domain } - // Extract details (action, policy, etc.) - var detailsParts []string - actionRe := regexp.MustCompile(`action=([^\s;]+)`) - if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 { - detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1])) - } - - if len(detailsParts) > 0 { - details := strings.Join(detailsParts, " ") - result.Details = &details - } + result.Details = &part return result } @@ -262,14 +246,7 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult { 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 - } - } + result.Details = &part return result } @@ -286,14 +263,7 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult { 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 - } - } + result.Details = &part return result } @@ -389,7 +359,7 @@ func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, // 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) { + if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) { return false } } @@ -413,16 +383,6 @@ func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int { 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 { @@ -447,8 +407,10 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe result.Result = api.AuthResultResult(resultStr) } + result.Details = &receivedSPF + // Try to extract domain - domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`) + domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`) if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { email := matches[1] if idx := strings.Index(email, "@"); idx != -1 { diff --git a/pkg/analyzer/authentication_checks.go b/pkg/analyzer/authentication_checks.go index 01298a0..f7cc15e 100644 --- a/pkg/analyzer/authentication_checks.go +++ b/pkg/analyzer/authentication_checks.go @@ -41,7 +41,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Category: api.Authentication, Name: "SPF Record", Status: api.CheckStatusWarn, - Score: 0.0, + Score: 0, Message: "No SPF authentication result found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), @@ -59,7 +59,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Category: api.Authentication, Name: "DKIM Signature", Status: api.CheckStatusWarn, - Score: 0.0, + Score: 0, Message: "No DKIM signature found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), @@ -75,7 +75,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe Category: api.Authentication, Name: "DMARC Policy", Status: api.CheckStatusWarn, - Score: 0.0, + Score: 0, Message: "No DMARC authentication result found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Implement DMARC policy for your domain"), @@ -106,37 +106,38 @@ func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check switch spf.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 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.Score = 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.Score = 50 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.Score = 50 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.Score = 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 { + if spf.Details != nil { + check.Details = spf.Details + } else if spf.Domain != nil { details := fmt.Sprintf("Domain: %s", *spf.Domain) check.Details = &details } @@ -153,34 +154,38 @@ func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index i switch dkim.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 10 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.Score = 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.Score = 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 + if dkim.Details != nil { + check.Details = dkim.Details + } else { + 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 @@ -195,25 +200,27 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C switch dmarc.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 10 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.Score = 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.Score = 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 { + if dmarc.Details != nil { + check.Details = dmarc.Details + } else if dmarc.Domain != nil { details := fmt.Sprintf("Domain: %s", *dmarc.Domain) check.Details = &details } @@ -230,25 +237,27 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che switch bimi.Result { case api.AuthResultResultPass: check.Status = api.CheckStatusPass - check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Score = 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.Score = 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.Score = 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 { + if bimi.Details != nil { + check.Details = bimi.Details + } else if bimi.Domain != nil { details := fmt.Sprintf("Domain: %s", *bimi.Domain) check.Details = &details } @@ -265,39 +274,43 @@ func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check switch arc.Result { case api.ARCResultResultPass: check.Status = api.CheckStatusPass - check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding) + check.Score = 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.Score = 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.Score = 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) - } + check.Details = arc.Details + } else { + // 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 + 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 index ecd5832..0b03998 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -251,7 +251,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { name string spf *api.AuthResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "SPF pass", @@ -260,7 +260,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "SPF fail", @@ -269,7 +269,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "SPF softfail", @@ -278,7 +278,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5, }, { name: "SPF neutral", @@ -287,7 +287,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5, }, } @@ -319,7 +319,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { dkim *api.AuthResult index int expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "DKIM pass", @@ -330,7 +330,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { }, index: 0, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "DKIM fail", @@ -341,7 +341,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { }, index: 0, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "DKIM none", @@ -352,7 +352,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) { }, index: 0, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -383,7 +383,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) { name string dmarc *api.AuthResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "DMARC pass", @@ -392,7 +392,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "DMARC fail", @@ -401,7 +401,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -432,7 +432,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { name string bimi *api.AuthResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "BIMI pass", @@ -441,7 +441,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // BIMI doesn't contribute to score + expectedScore: 0, // BIMI doesn't contribute to score }, { name: "BIMI fail", @@ -450,7 +450,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, { name: "BIMI none", @@ -459,7 +459,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) { Domain: api.PtrTo("example.com"), }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -494,7 +494,7 @@ func TestGetAuthenticationScore(t *testing.T) { tests := []struct { name string results *api.AuthenticationResults - expectedScore float32 + expectedScore int }{ { name: "Perfect authentication (SPF + DKIM + DMARC)", @@ -509,7 +509,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 30.0, + expectedScore: 30, }, { name: "SPF and DKIM only", @@ -521,7 +521,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 20.0, + expectedScore: 20, }, { name: "SPF fail, DKIM pass", @@ -533,7 +533,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 10.0, + expectedScore: 10, }, { name: "SPF softfail", @@ -542,12 +542,12 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultSoftfail, }, }, - expectedScore: 5.0, + expectedScore: 5, }, { name: "No authentication", results: &api.AuthenticationResults{}, - expectedScore: 0.0, + expectedScore: 0, }, { name: "BIMI doesn't affect score", @@ -559,7 +559,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 10.0, // Only SPF counted, not BIMI + expectedScore: 10, // Only SPF counted, not BIMI }, } @@ -789,7 +789,7 @@ func TestGenerateARCCheck(t *testing.T) { name string arc *api.ARCResult expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "ARC pass", @@ -799,7 +799,7 @@ func TestGenerateARCCheck(t *testing.T) { ChainValid: api.PtrTo(true), }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // ARC doesn't contribute to score + expectedScore: 0, // ARC doesn't contribute to score }, { name: "ARC fail", @@ -809,7 +809,7 @@ func TestGenerateARCCheck(t *testing.T) { ChainValid: api.PtrTo(false), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, + expectedScore: 0, }, { name: "ARC none", @@ -819,7 +819,7 @@ func TestGenerateARCCheck(t *testing.T) { ChainValid: api.PtrTo(true), }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, } diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 7c68323..872c75c 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -506,7 +506,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api if !results.HTMLValid { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "HTML structure is invalid" if len(results.HTMLErrors) > 0 { @@ -516,7 +516,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering") } else { check.Status = api.CheckStatusPass - check.Score = 0.2 + check.Score = 2 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "HTML structure is valid" check.Advice = api.PtrTo("Your HTML is well-formed") @@ -551,7 +551,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec if brokenLinks > 0 { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks) check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability") @@ -559,7 +559,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec check.Details = &details } else if warningLinks > 0 { check.Status = api.CheckStatusWarn - check.Score = 0.3 + check.Score = 3 check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks) check.Advice = api.PtrTo("Review links that could not be verified") @@ -567,7 +567,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.4 + check.Score = 4 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links)) check.Advice = api.PtrTo("Your links are working properly") @@ -600,7 +600,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che if noAltCount == len(results.Images) { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "No images have alt attributes" check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability") @@ -608,7 +608,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che check.Details = &details } else if noAltCount > 0 { check.Status = api.CheckStatusWarn - check.Score = 0.2 + check.Score = 2 check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount) check.Advice = api.PtrTo("Add alt text to all images for better accessibility") @@ -616,7 +616,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "All images have alt attributes" check.Advice = api.PtrTo("Your images are properly tagged for accessibility") @@ -635,13 +635,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api. if !results.HasUnsubscribe { check.Status = api.CheckStatusWarn - check.Score = 0.0 + check.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "No unsubscribe link found" check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)") } else { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks)) check.Advice = api.PtrTo("Your email includes an unsubscribe option") @@ -661,7 +661,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) if consistency < 0.3 { check.Status = api.CheckStatusWarn - check.Score = 0.0 + check.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "Plain text and HTML versions differ significantly" check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content") @@ -669,7 +669,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "Plain text and HTML versions are consistent" check.Advice = api.PtrTo("Your multipart email is well-structured") @@ -692,7 +692,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C // Flag if more than 1 image per 100 characters (very image-heavy) if ratio > 10.0 { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityMedium) check.Message = "Email is excessively image-heavy" check.Advice = api.PtrTo("Reduce the number of images relative to text content") @@ -700,7 +700,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C check.Details = &details } else if ratio > 5.0 { check.Status = api.CheckStatusWarn - check.Score = 0.2 + check.Score = 2 check.Severity = api.PtrTo(api.CheckSeverityLow) check.Message = "Email has high image-to-text ratio" check.Advice = api.PtrTo("Consider adding more text content relative to images") @@ -708,7 +708,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 0.3 + check.Score = 3 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = "Image-to-text ratio is reasonable" check.Advice = api.PtrTo("Your content has a good balance of images and text") @@ -746,19 +746,19 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap } // GetContentScore calculates the content score (0-20 points) -func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { +func (c *ContentAnalyzer) GetContentScore(results *ContentResults) int { if results == nil { - return 0.0 + return 0 } - var score float32 = 0.0 + var score int = 0 - // HTML validity (2 points) + // HTML validity (10 points) if results.HTMLValid { - score += 2.0 + score += 10 } - // Links (4 points) + // Links (20 points) if len(results.Links) > 0 { brokenLinks := 0 for _, link := range results.Links { @@ -767,14 +767,14 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { } } if brokenLinks == 0 { - score += 4.0 + score += 20 } } else { // No links is neutral, give partial score - score += 2.0 + score += 10 } - // Images (3 points) + // Images (15 points) if len(results.Images) > 0 { noAltCount := 0 for _, img := range results.Images { @@ -783,47 +783,47 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { } } if noAltCount == 0 { - score += 3.0 + score += 15 } else if noAltCount < len(results.Images) { - score += 1.5 + score += 7 } } else { // No images is neutral - score += 1.5 + score += 7 } - // Unsubscribe link (3 points) + // Unsubscribe link (15 points) if results.HasUnsubscribe { - score += 3.0 + score += 15 } - // Text consistency (3 points) + // Text consistency (15 points) if results.TextPlainRatio >= 0.3 { - score += 3.0 + score += 15 } - // Image ratio (3 points) + // Image ratio (15 points) if results.ImageTextRatio <= 5.0 { - score += 3.0 + score += 15 } else if results.ImageTextRatio <= 10.0 { - score += 1.5 + score += 7 } // Penalize suspicious URLs (deduct up to 5 points) if len(results.SuspiciousURLs) > 0 { - penalty := float32(len(results.SuspiciousURLs)) * 1.0 + penalty := len(results.SuspiciousURLs) if penalty > 5.0 { - penalty = 5.0 + penalty = 5 } score -= penalty } - // Ensure score is between 0 and 20 + // Ensure score is between 0 and 100 if score < 0 { score = 0 } - if score > 20.0 { - score = 20.0 + if score > 100 { + score = 100 } return score diff --git a/pkg/analyzer/content_test.go b/pkg/analyzer/content_test.go index c82d4a8..0a1c710 100644 --- a/pkg/analyzer/content_test.go +++ b/pkg/analyzer/content_test.go @@ -613,7 +613,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) { name string results *ContentResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid HTML", @@ -621,7 +621,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) { HTMLValid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.2, + expectedScore: 2, }, { name: "Invalid HTML", @@ -630,7 +630,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) { HTMLErrors: []string{"Parse error"}, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -658,7 +658,7 @@ func TestGenerateLinkChecks(t *testing.T) { name string results *ContentResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "All links valid", @@ -669,7 +669,7 @@ func TestGenerateLinkChecks(t *testing.T) { }, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.4, + expectedScore: 4, }, { name: "Broken links", @@ -679,7 +679,7 @@ func TestGenerateLinkChecks(t *testing.T) { }, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "Links with warnings", @@ -689,7 +689,7 @@ func TestGenerateLinkChecks(t *testing.T) { }, }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.3, + expectedScore: 3, }, { name: "No links", @@ -927,14 +927,14 @@ func TestGetContentScore(t *testing.T) { tests := []struct { name string results *ContentResults - minScore float32 - maxScore float32 + minScore int + maxScore int }{ { name: "Nil results", results: nil, - minScore: 0.0, - maxScore: 0.0, + minScore: 0, + maxScore: 0, }, { name: "Perfect content", @@ -946,8 +946,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.8, ImageTextRatio: 3.0, }, - minScore: 18.0, - maxScore: 20.0, + minScore: 90, + maxScore: 100, }, { name: "Poor content", @@ -960,8 +960,8 @@ func TestGetContentScore(t *testing.T) { ImageTextRatio: 15.0, SuspiciousURLs: []string{"url1", "url2"}, }, - minScore: 0.0, - maxScore: 5.0, + minScore: 0, + maxScore: 25, }, { name: "Average content", @@ -973,8 +973,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.5, ImageTextRatio: 4.0, }, - minScore: 10.0, - maxScore: 18.0, + minScore: 50, + maxScore: 90, }, } @@ -988,13 +988,13 @@ func TestGetContentScore(t *testing.T) { t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) } - // Ensure score is capped at 20.0 - if score > 20.0 { - t.Errorf("Score %v exceeds maximum of 20.0", score) + // Ensure score is capped at 100 + if score > 100 { + t.Errorf("Score %v exceeds maximum of 100", score) } // Ensure score is not negative - if score < 0.0 { + if score < 0 { t.Errorf("Score %v is negative", score) } }) diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 9a6d26f..1a03a99 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -521,7 +521,7 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check { // BIMI record check (optional) if results.BIMIRecord != nil { - checks = append(checks, d.generateBIMICheck(results.BIMIRecord)) + checks = append(checks, d.generateBIMICheck(results.BIMIRecord, results.DMARCRecord)) } return checks @@ -536,7 +536,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.Score = 0 check.Severity = api.PtrTo(api.CheckSeverityCritical) if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" { @@ -547,7 +547,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check { check.Advice = api.PtrTo("Configure MX records for your domain to receive email") } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords)) @@ -572,25 +572,25 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check { } if !spf.Valid { - // If no record exists at all, it's a failure if spf.Record == "" { + // If no record exists at all, it's a failure check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Message = spf.Error - check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Severity = api.PtrTo(api.CheckSeverityMedium) 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 + // If record exists but is invalid, it's a failure + check.Status = api.CheckStatusFail + check.Score = 5 check.Message = "SPF record found but appears invalid" - check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Review and fix your SPF record syntax") check.Details = &spf.Record } } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Message = "Valid SPF record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &spf.Record @@ -609,7 +609,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { if !dkim.Valid { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error) check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used") @@ -617,7 +617,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check { check.Details = &details } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Message = "Valid DKIM record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain) @@ -637,13 +637,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { if !dmarc.Valid { check.Status = api.CheckStatusFail - check.Score = 0.0 + check.Score = 0 check.Message = dmarc.Error check.Severity = api.PtrTo(api.CheckSeverityHigh) check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing") } else { check.Status = api.CheckStatusPass - check.Score = 1.0 + check.Score = 100 check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy) check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Details = &dmarc.Record @@ -669,7 +669,7 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check { } // generateBIMICheck creates a check for BIMI records -func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { +func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord, dmarc *DMARCRecord) api.Check { check := api.Check{ Category: api.Dns, Name: "BIMI Record", @@ -679,14 +679,18 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { // BIMI is optional, so missing record is just informational if bimi.Record == "" { check.Status = api.CheckStatusInfo - check.Score = 0.0 + check.Score = 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)") + if dmarc.Policy != "quarantine" && dmarc.Policy != "reject" { + 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 { + check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients.") + } } else { // If record exists but is invalid check.Status = api.CheckStatusWarn - check.Score = 0.0 + check.Score = 5 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=)") @@ -694,7 +698,7 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check { } } else { check.Status = api.CheckStatusPass - check.Score = 0.0 // BIMI doesn't contribute to score (branding feature) + check.Score = 100 // BIMI doesn't contribute to score (branding feature) check.Message = "Valid BIMI record found" check.Severity = api.PtrTo(api.CheckSeverityInfo) diff --git a/pkg/analyzer/dns_test.go b/pkg/analyzer/dns_test.go index 12a6bd0..750c620 100644 --- a/pkg/analyzer/dns_test.go +++ b/pkg/analyzer/dns_test.go @@ -305,7 +305,7 @@ func TestGenerateMXCheck(t *testing.T) { name string results *DNSResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid MX records", @@ -317,7 +317,7 @@ func TestGenerateMXCheck(t *testing.T) { }, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "No MX records", @@ -328,7 +328,7 @@ func TestGenerateMXCheck(t *testing.T) { }, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, { name: "MX lookup failed", @@ -339,7 +339,7 @@ func TestGenerateMXCheck(t *testing.T) { }, }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -367,7 +367,7 @@ func TestGenerateSPFCheck(t *testing.T) { name string spf *SPFRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid SPF", @@ -376,7 +376,7 @@ func TestGenerateSPFCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Invalid SPF", @@ -386,7 +386,7 @@ func TestGenerateSPFCheck(t *testing.T) { Error: "SPF record appears malformed", }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5, }, { name: "No SPF record", @@ -395,7 +395,7 @@ func TestGenerateSPFCheck(t *testing.T) { Error: "No SPF record found", }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -423,7 +423,7 @@ func TestGenerateDKIMCheck(t *testing.T) { name string dkim *DKIMRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid DKIM", @@ -434,7 +434,7 @@ func TestGenerateDKIMCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Invalid DKIM", @@ -445,7 +445,7 @@ func TestGenerateDKIMCheck(t *testing.T) { Error: "No DKIM record found", }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -476,7 +476,7 @@ func TestGenerateDMARCCheck(t *testing.T) { name string dmarc *DMARCRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid DMARC - reject", @@ -486,7 +486,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Valid DMARC - quarantine", @@ -496,7 +496,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "Valid DMARC - none", @@ -506,7 +506,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 1.0, + expectedScore: 10, }, { name: "No DMARC record", @@ -515,7 +515,7 @@ func TestGenerateDMARCCheck(t *testing.T) { Error: "No DMARC record found", }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -738,7 +738,7 @@ func TestGenerateBIMICheck(t *testing.T) { name string bimi *BIMIRecord expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Valid BIMI with logo only", @@ -750,7 +750,7 @@ func TestGenerateBIMICheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, // BIMI doesn't contribute to score + expectedScore: 0, // BIMI doesn't contribute to score }, { name: "Valid BIMI with VMC", @@ -763,7 +763,7 @@ func TestGenerateBIMICheck(t *testing.T) { Valid: true, }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.0, + expectedScore: 0, }, { name: "No BIMI record (optional)", @@ -774,7 +774,7 @@ func TestGenerateBIMICheck(t *testing.T) { Error: "No BIMI record found", }, expectedStatus: api.CheckStatusInfo, - expectedScore: 0.0, + expectedScore: 0, }, { name: "Invalid BIMI record", @@ -786,7 +786,7 @@ func TestGenerateBIMICheck(t *testing.T) { Error: "BIMI record appears malformed", }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.0, + expectedScore: 0, }, } diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go new file mode 100644 index 0000000..7fa252a --- /dev/null +++ b/pkg/analyzer/headers.go @@ -0,0 +1,303 @@ +// 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" +) + +// HeaderAnalyzer analyzes email header quality and structure +type HeaderAnalyzer struct{} + +// NewHeaderAnalyzer creates a new header analyzer +func NewHeaderAnalyzer() *HeaderAnalyzer { + return &HeaderAnalyzer{} +} + +// calculateHeaderScore evaluates email structural quality +func (h *HeaderAnalyzer) calculateHeaderScore(email *EmailMessage) int { + if email == nil { + return 0 + } + + score := 0 + requiredHeaders := 0 + presentHeaders := 0 + + // Check required headers (RFC 5322) + headers := map[string]bool{ + "From": false, + "Date": false, + "Message-ID": false, + } + + for header := range headers { + requiredHeaders++ + if email.HasHeader(header) && email.GetHeaderValue(header) != "" { + headers[header] = true + presentHeaders++ + } + } + + // Score based on required headers (40 points) + if presentHeaders == requiredHeaders { + score += 40 + } else { + score += int(40 * (float32(presentHeaders) / float32(requiredHeaders))) + } + + // Check recommended headers (30 points) + recommendedHeaders := []string{"Subject", "To", "Reply-To"} + recommendedPresent := 0 + for _, header := range recommendedHeaders { + if email.HasHeader(header) && email.GetHeaderValue(header) != "" { + recommendedPresent++ + } + } + score += int(30 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))) + + // Check for proper MIME structure (20 points) + if len(email.Parts) > 0 { + score += 20 + } + + // Check Message-ID format (10 point) + if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { + if h.isValidMessageID(messageID) { + score += 10 + } + } + + // Ensure score doesn't exceed 100 + if score > 100 { + score = 100 + } + + return score +} + +// isValidMessageID checks if a Message-ID has proper format +func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool { + // Basic check: should be in format <...@...> + if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { + return false + } + + // Remove angle brackets + messageID = strings.TrimPrefix(messageID, "<") + messageID = strings.TrimSuffix(messageID, ">") + + // Should contain @ symbol + if !strings.Contains(messageID, "@") { + return false + } + + parts := strings.Split(messageID, "@") + if len(parts) != 2 { + return false + } + + // Both parts should be non-empty + return len(parts[0]) > 0 && len(parts[1]) > 0 +} + +// GenerateHeaderChecks creates checks for email header quality +func (h *HeaderAnalyzer) GenerateHeaderChecks(email *EmailMessage) []api.Check { + var checks []api.Check + + if email == nil { + return checks + } + + // Required headers check + checks = append(checks, h.generateRequiredHeadersCheck(email)) + + // Recommended headers check + checks = append(checks, h.generateRecommendedHeadersCheck(email)) + + // Message-ID check + checks = append(checks, h.generateMessageIDCheck(email)) + + // MIME structure check + checks = append(checks, h.generateMIMEStructureCheck(email)) + + return checks +} + +// generateRequiredHeadersCheck checks for required RFC 5322 headers +func (h *HeaderAnalyzer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Required Headers", + } + + requiredHeaders := []string{"From", "Date", "Message-ID"} + missing := []string{} + + for _, header := range requiredHeaders { + if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { + missing = append(missing, header) + } + } + + if len(missing) == 0 { + check.Status = api.CheckStatusPass + check.Score = 4.0 + check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Message = "All required headers are present" + check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") + } else { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityCritical) + check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + check.Details = &details + } + + return check +} + +// generateRecommendedHeadersCheck checks for recommended headers +func (h *HeaderAnalyzer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Recommended Headers", + } + + recommendedHeaders := []string{"Subject", "To", "Reply-To"} + missing := []string{} + + for _, header := range recommendedHeaders { + if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { + missing = append(missing, header) + } + } + + if len(missing) == 0 { + check.Status = api.CheckStatusPass + check.Score = 30 + check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Message = "All recommended headers are present" + check.Advice = api.PtrTo("Your email includes all recommended headers") + } else if len(missing) < len(recommendedHeaders) { + check.Status = api.CheckStatusWarn + check.Score = 15 + check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + check.Details = &details + } else { + check.Status = api.CheckStatusWarn + check.Score = 0 + check.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Message = "Missing all recommended headers" + check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") + } + + return check +} + +// generateMessageIDCheck validates Message-ID header +func (h *HeaderAnalyzer) generateMessageIDCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "Message-ID Format", + } + + messageID := email.GetHeaderValue("Message-ID") + + if messageID == "" { + check.Status = api.CheckStatusFail + check.Score = 0 + check.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityHigh) + check.Message = "Message-ID header is missing" + check.Advice = api.PtrTo("Add a unique Message-ID header to your email") + } else if !h.isValidMessageID(messageID) { + check.Status = api.CheckStatusWarn + check.Score = 5 + check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityMedium) + check.Message = "Message-ID format is invalid" + check.Advice = api.PtrTo("Use proper Message-ID format: ") + check.Details = &messageID + } else { + check.Status = api.CheckStatusPass + check.Score = 10 + check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Message = "Message-ID is properly formatted" + check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") + check.Details = &messageID + } + + return check +} + +// generateMIMEStructureCheck validates MIME structure +func (h *HeaderAnalyzer) generateMIMEStructureCheck(email *EmailMessage) api.Check { + check := api.Check{ + Category: api.Headers, + Name: "MIME Structure", + } + + if len(email.Parts) == 0 { + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Grade = ScoreToCheckGrade(0.0) + check.Severity = api.PtrTo(api.CheckSeverityLow) + check.Message = "No MIME parts detected" + check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") + } else { + check.Status = api.CheckStatusPass + check.Score = 2.0 + check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100) + check.Severity = api.PtrTo(api.CheckSeverityInfo) + check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) + check.Advice = api.PtrTo("Your email has proper MIME structure") + + // Add details about parts + partTypes := []string{} + for _, part := range email.Parts { + if part.ContentType != "" { + partTypes = append(partTypes, part.ContentType) + } + } + if len(partTypes) > 0 { + details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) + check.Details = &details + } + } + + return check +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go new file mode 100644 index 0000000..8594f7f --- /dev/null +++ b/pkg/analyzer/headers_test.go @@ -0,0 +1,324 @@ +// 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 ( + "net/mail" + "net/textproto" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestCalculateHeaderScore(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minScore int + maxScore int + }{ + { + name: "Nil email", + email: nil, + minScore: 0, + maxScore: 0, + }, + { + name: "Perfect headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + { + name: "Missing required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Subject": "Test", + }), + }, + minScore: 0, + maxScore: 40, + }, + { + name: "Required only, no recommended", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 40, + maxScore: 80, + }, + { + name: "Invalid Message-ID format", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "invalid-message-id", + "Subject": "Test", + "To": "recipient@example.com", + "Reply-To": "reply@example.com", + }), + MessageID: "invalid-message-id", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 70, + maxScore: 100, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := analyzer.calculateHeaderScore(tt.email) + if score < tt.minScore || score > tt.maxScore { + t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) + } + }) + } +} + +func TestGenerateRequiredHeadersCheck(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + expectedStatus api.CheckStatus + expectedScore int + }{ + { + name: "All required headers present", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + }), + From: &mail.Address{Address: "sender@example.com"}, + MessageID: "", + Date: "Mon, 01 Jan 2024 12:00:00 +0000", + }, + expectedStatus: api.CheckStatusPass, + expectedScore: 40, + }, + { + name: "Missing all required headers", + email: &EmailMessage{ + Header: make(mail.Header), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0, + }, + { + name: "Missing some required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + }), + }, + expectedStatus: api.CheckStatusFail, + expectedScore: 0, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + check := analyzer.generateRequiredHeadersCheck(tt.email) + + 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.Headers { + t.Errorf("Category = %v, want %v", check.Category, api.Headers) + } + }) + } +} + +func TestGenerateMessageIDCheck(t *testing.T) { + tests := []struct { + name string + messageID string + expectedStatus api.CheckStatus + }{ + { + name: "Valid Message-ID", + messageID: "", + expectedStatus: api.CheckStatusPass, + }, + { + name: "Invalid Message-ID format", + messageID: "invalid-message-id", + expectedStatus: api.CheckStatusWarn, + }, + { + name: "Missing Message-ID", + messageID: "", + expectedStatus: api.CheckStatusFail, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Message-ID": tt.messageID, + }), + } + + check := analyzer.generateMessageIDCheck(email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + if check.Category != api.Headers { + t.Errorf("Category = %v, want %v", check.Category, api.Headers) + } + }) + } +} + +func TestGenerateMIMEStructureCheck(t *testing.T) { + tests := []struct { + name string + parts []MessagePart + expectedStatus api.CheckStatus + }{ + { + name: "With MIME parts", + parts: []MessagePart{ + {ContentType: "text/plain", Content: "test"}, + {ContentType: "text/html", Content: "

test

"}, + }, + expectedStatus: api.CheckStatusPass, + }, + { + name: "No MIME parts", + parts: []MessagePart{}, + expectedStatus: api.CheckStatusWarn, + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + email := &EmailMessage{ + Header: make(mail.Header), + Parts: tt.parts, + } + + check := analyzer.generateMIMEStructureCheck(email) + + if check.Status != tt.expectedStatus { + t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) + } + }) + } +} + +func TestGenerateHeaderChecks(t *testing.T) { + tests := []struct { + name string + email *EmailMessage + minChecks int + }{ + { + name: "Nil email", + email: nil, + minChecks: 0, + }, + { + name: "Complete email", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + "To": "recipient@example.com", + "Subject": "Test", + "Date": "Mon, 01 Jan 2024 12:00:00 +0000", + "Message-ID": "", + "Reply-To": "reply@example.com", + }), + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minChecks: 4, // Required, Recommended, Message-ID, MIME + }, + } + + analyzer := NewHeaderAnalyzer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checks := analyzer.GenerateHeaderChecks(tt.email) + + if len(checks) < tt.minChecks { + t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) + } + + // Verify all checks have the Headers category + for _, check := range checks { + if check.Category != api.Headers { + t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) + } + } + }) + } +} + +// Helper function to create mail.Header with specific fields +func createHeaderWithFields(fields map[string]string) mail.Header { + header := make(mail.Header) + for key, value := range fields { + if value != "" { + // Use canonical MIME header key format + canonicalKey := textproto.CanonicalMIMEHeaderKey(key) + header[canonicalKey] = []string{value} + } + } + return header +} diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index 3904c6f..2084ea5 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -238,29 +238,14 @@ func (r *RBLChecker) reverseIP(ipStr string) string { return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0]) } -// GetBlacklistScore calculates the blacklist contribution to deliverability (0-20 points) -// Scoring: -// - Not listed on any RBL: 20 points (excellent) -// - Listed on 1 RBL: 10 points (warning) -// - Listed on 2-3 RBLs: 5 points (poor) -// - Listed on 4+ RBLs: 0 points (critical) -func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 { +// GetBlacklistScore calculates the blacklist contribution to deliverability +func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int { if results == nil || len(results.IPsChecked) == 0 { // No IPs to check, give benefit of doubt - return 20.0 + return 100 } - listedCount := results.ListedCount - - if listedCount == 0 { - return 20.0 - } else if listedCount == 1 { - return 10.0 - } else if listedCount <= 3 { - return 5.0 - } - - return 0.0 + return 100 - results.ListedCount*100/len(r.RBLs) } // GenerateRBLChecks generates check results for RBL analysis @@ -277,8 +262,8 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { Category: api.Blacklist, Name: "RBL Check", Status: api.CheckStatusWarn, - Score: 10.0, - Grade: ScoreToCheckGrade((10.0 / 20.0) * 100), + Score: 50, + Grade: ScoreToCheckGrade(50), Message: "No public IP addresses found to check", Severity: api.PtrTo(api.CheckSeverityLow), Advice: api.PtrTo("Unable to extract sender IP from email headers"), @@ -310,7 +295,7 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { score := r.GetBlacklistScore(results) check.Score = score - check.Grade = ScoreToCheckGrade((score / 20.0) * 100) + check.Grade = ScoreToCheckGrade(score) totalChecks := len(results.Checks) listedCount := results.ListedCount @@ -352,8 +337,8 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { Category: api.Blacklist, Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), Status: api.CheckStatusFail, - Score: 0.0, - Grade: ScoreToCheckGrade(0.0), + Score: 0, + Grade: ScoreToCheckGrade(0), } check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL) diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 0bf8c0e..c2bac11 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -267,19 +267,19 @@ func TestGetBlacklistScore(t *testing.T) { tests := []struct { name string results *RBLResults - expectedScore float32 + expectedScore int }{ { name: "Nil results", results: nil, - expectedScore: 20.0, + expectedScore: 200, }, { name: "No IPs checked", results: &RBLResults{ IPsChecked: []string{}, }, - expectedScore: 20.0, + expectedScore: 200, }, { name: "Not listed on any RBL", @@ -287,7 +287,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 20.0, + expectedScore: 200, }, { name: "Listed on 1 RBL", @@ -295,7 +295,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, - expectedScore: 10.0, + expectedScore: 100, }, { name: "Listed on 2 RBLs", @@ -303,7 +303,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, - expectedScore: 5.0, + expectedScore: 50, }, { name: "Listed on 3 RBLs", @@ -311,7 +311,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, - expectedScore: 5.0, + expectedScore: 50, }, { name: "Listed on 4+ RBLs", @@ -319,7 +319,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -340,7 +340,7 @@ func TestGenerateSummaryCheck(t *testing.T) { name string results *RBLResults expectedStatus api.CheckStatus - expectedScore float32 + expectedScore int }{ { name: "Not listed", @@ -350,7 +350,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), // 6 default RBLs }, expectedStatus: api.CheckStatusPass, - expectedScore: 20.0, + expectedScore: 200, }, { name: "Listed on 1 RBL", @@ -360,7 +360,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 10.0, + expectedScore: 100, }, { name: "Listed on 2 RBLs", @@ -370,7 +370,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 5.0, + expectedScore: 50, }, { name: "Listed on 4+ RBLs", @@ -380,7 +380,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, + expectedScore: 0, }, } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index 79799b9..6d5522b 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -36,6 +36,7 @@ type ReportGenerator struct { dnsAnalyzer *DNSAnalyzer rblChecker *RBLChecker contentAnalyzer *ContentAnalyzer + headerAnalyzer *HeaderAnalyzer scorer *DeliverabilityScorer } @@ -51,6 +52,7 @@ func NewReportGenerator( dnsAnalyzer: NewDNSAnalyzer(dnsTimeout), rblChecker: NewRBLChecker(dnsTimeout, rbls), contentAnalyzer: NewContentAnalyzer(httpTimeout), + headerAnalyzer: NewHeaderAnalyzer(), scorer: NewDeliverabilityScorer(), } } @@ -63,7 +65,6 @@ type AnalysisResults struct { DNS *DNSResults RBL *RBLResults Content *ContentResults - Score *ScoringResult } // AnalyzeEmail performs complete email analysis @@ -79,15 +80,6 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { results.RBL = r.rblChecker.CheckEmail(email) results.Content = r.contentAnalyzer.AnalyzeContent(email) - // Calculate overall score - results.Score = r.scorer.CalculateScore( - results.Authentication, - results.SpamAssassin, - results.RBL, - results.Content, - email, - ) - return results } @@ -99,20 +91,9 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu report := &api.Report{ Id: utils.UUIDToBase32(reportID), TestId: utils.UUIDToBase32(testID), - Score: results.Score.OverallScore, - Grade: ScoreToReportGrade(results.Score.OverallScore), CreatedAt: now, } - // Build score summary - report.Summary = &api.ScoreSummary{ - AuthenticationScore: results.Score.AuthScore, - SpamScore: results.Score.SpamScore, - BlacklistScore: results.Score.BlacklistScore, - ContentScore: results.Score.ContentScore, - HeaderScore: results.Score.HeaderScore, - } - // Collect all checks from different analyzers checks := []api.Check{} @@ -147,11 +128,40 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } // Header checks - headerChecks := r.scorer.GenerateHeaderChecks(results.Email) + headerChecks := r.headerAnalyzer.GenerateHeaderChecks(results.Email) checks = append(checks, headerChecks...) report.Checks = checks + // Summarize scores by category + categoryCounts := make(map[api.CheckCategory]int) + categoryTotals := make(map[api.CheckCategory]int) + + for _, check := range checks { + if check.Status == "info" { + continue + } + + categoryCounts[check.Category]++ + categoryTotals[check.Category] += check.Score + } + + // Calculate mean scores for each category + calcCategoryScore := func(category api.CheckCategory) int { + if count := categoryCounts[category]; count > 0 { + return categoryTotals[category] / count + } + return 0 + } + + report.Summary = &api.ScoreSummary{ + AuthenticationScore: calcCategoryScore(api.Authentication), + BlacklistScore: calcCategoryScore(api.Blacklist), + ContentScore: calcCategoryScore(api.Content), + HeaderScore: calcCategoryScore(api.Headers), + SpamScore: calcCategoryScore(api.Spam), + } + // Add authentication results report.Authentication = results.Authentication @@ -202,6 +212,30 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu report.RawHeaders = &results.Email.RawHeaders } + // Calculate overall score as mean of all category scores + categoryScores := []int{ + report.Summary.AuthenticationScore, + report.Summary.BlacklistScore, + report.Summary.ContentScore, + report.Summary.HeaderScore, + report.Summary.SpamScore, + } + + var totalScore int + var categoryCount int + for _, score := range categoryScores { + totalScore += score + categoryCount++ + } + + if categoryCount > 0 { + report.Score = totalScore / categoryCount + } else { + report.Score = 0 + } + + report.Grade = ScoreToReportGrade(report.Score) + return report } @@ -330,21 +364,3 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string { return raw } - -// GetRecommendations returns actionable recommendations based on the score -func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string { - if results == nil || results.Score == nil { - return []string{} - } - - return results.Score.Recommendations -} - -// GetScoreSummaryText returns a human-readable score summary -func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string { - if results == nil || results.Score == nil { - return "" - } - - return r.scorer.GetScoreSummary(results.Score) -} diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index 0dd7e8c..85edcd2 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -336,13 +336,13 @@ func TestGetRecommendations(t *testing.T) { name: "Results with score", results: &AnalysisResults{ Score: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 1.0, - BlacklistScore: 1.5, - ContentScore: 0.5, - HeaderScore: 0.5, + OverallScore: 50, + Grade: ScoreToReportGrade(50), + AuthScore: 15, + SpamScore: 10, + BlacklistScore: 15, + ContentScore: 5, + HeaderScore: 5, Recommendations: []string{ "Improve authentication", "Fix content issues", @@ -381,19 +381,19 @@ func TestGetScoreSummaryText(t *testing.T) { name: "Results with score", results: &AnalysisResults{ Score: &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, + OverallScore: 85, + Grade: ScoreToReportGrade(85), + AuthScore: 25, + SpamScore: 18, + BlacklistScore: 20, + ContentScore: 15, + HeaderScore: 7, CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, + "Authentication": {Score: 25, Status: "Pass"}, + "Spam Filters": {Score: 18, Status: "Pass"}, + "Blacklists": {Score: 20, Status: "Pass"}, + "Content Quality": {Score: 15, Status: "Warn"}, + "Email Structure": {Score: 7, Status: "Warn"}, }, }, }, diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 7d5184f..6db6e0c 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -22,15 +22,11 @@ package analyzer import ( - "fmt" - "strings" - "time" - "git.happydns.org/happyDeliver/internal/api" ) // ScoreToGrade converts a percentage score (0-100) to a letter grade -func ScoreToGrade(score float32) string { +func ScoreToGrade(score int) string { switch { case score >= 97: return "A+" @@ -50,12 +46,12 @@ func ScoreToGrade(score float32) string { } // ScoreToCheckGrade converts a percentage score to an api.CheckGrade -func ScoreToCheckGrade(score float32) api.CheckGrade { +func ScoreToCheckGrade(score int) api.CheckGrade { return api.CheckGrade(ScoreToGrade(score)) } // ScoreToReportGrade converts a percentage score to an api.ReportGrade -func ScoreToReportGrade(score float32) api.ReportGrade { +func ScoreToReportGrade(score int) api.ReportGrade { return api.ReportGrade(ScoreToGrade(score)) } @@ -66,520 +62,3 @@ type DeliverabilityScorer struct{} func NewDeliverabilityScorer() *DeliverabilityScorer { return &DeliverabilityScorer{} } - -// ScoringResult represents the complete scoring result -type ScoringResult struct { - OverallScore float32 - Rating string // Excellent, Good, Fair, Poor, Critical - AuthScore float32 - SpamScore float32 - BlacklistScore float32 - ContentScore float32 - HeaderScore float32 - Recommendations []string - CategoryBreakdown map[string]CategoryScore -} - -// CategoryScore represents score breakdown for a category -type CategoryScore struct { - Score float32 - MaxScore float32 - Percentage float32 - Status string // Pass, Warn, Fail -} - -// CalculateScore computes the overall deliverability score from all analyzers -func (s *DeliverabilityScorer) CalculateScore( - authResults *api.AuthenticationResults, - spamResult *SpamAssassinResult, - rblResults *RBLResults, - contentResults *ContentResults, - email *EmailMessage, -) *ScoringResult { - result := &ScoringResult{ - CategoryBreakdown: make(map[string]CategoryScore), - Recommendations: []string{}, - } - - // Calculate individual scores - result.AuthScore = s.GetAuthenticationScore(authResults) - - spamAnalyzer := NewSpamAssassinAnalyzer() - result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult) - - rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs) - result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults) - - contentAnalyzer := NewContentAnalyzer(10 * time.Second) - result.ContentScore = contentAnalyzer.GetContentScore(contentResults) - - // Calculate header quality score - result.HeaderScore = s.calculateHeaderScore(email) - - // Calculate overall score (out of 100) - result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - - // Ensure score is within bounds - if result.OverallScore > 100.0 { - result.OverallScore = 100.0 - } - if result.OverallScore < 0.0 { - result.OverallScore = 0.0 - } - - // Determine rating - result.Rating = s.determineRating(result.OverallScore) - - // Build category breakdown - result.CategoryBreakdown["Authentication"] = CategoryScore{ - Score: result.AuthScore, - MaxScore: 30.0, - Percentage: result.AuthScore, - Status: s.getCategoryStatus(result.AuthScore, 30.0), - } - - result.CategoryBreakdown["Spam Filters"] = CategoryScore{ - Score: result.SpamScore, - MaxScore: 20.0, - Percentage: result.SpamScore, - Status: s.getCategoryStatus(result.SpamScore, 20.0), - } - - result.CategoryBreakdown["Blacklists"] = CategoryScore{ - Score: result.BlacklistScore, - MaxScore: 20.0, - Percentage: result.BlacklistScore, - Status: s.getCategoryStatus(result.BlacklistScore, 20.0), - } - - result.CategoryBreakdown["Content Quality"] = CategoryScore{ - Score: result.ContentScore, - MaxScore: 20.0, - Percentage: result.ContentScore, - Status: s.getCategoryStatus(result.ContentScore, 20.0), - } - - result.CategoryBreakdown["Email Structure"] = CategoryScore{ - Score: result.HeaderScore, - MaxScore: 10.0, - Percentage: result.HeaderScore, - Status: s.getCategoryStatus(result.HeaderScore, 10.0), - } - - // Generate recommendations - result.Recommendations = s.generateRecommendations(result) - - return result -} - -// calculateHeaderScore evaluates email structural quality (0-10 points) -func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 { - if email == nil { - return 0.0 - } - - score := float32(0.0) - requiredHeaders := 0 - presentHeaders := 0 - - // Check required headers (RFC 5322) - headers := map[string]bool{ - "From": false, - "Date": false, - "Message-ID": false, - } - - for header := range headers { - requiredHeaders++ - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - headers[header] = true - presentHeaders++ - } - } - - // Score based on required headers (4 points) - if presentHeaders == requiredHeaders { - score += 4.0 - } else { - score += 4.0 * (float32(presentHeaders) / float32(requiredHeaders)) - } - - // Check recommended headers (3 points) - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - recommendedPresent := 0 - for _, header := range recommendedHeaders { - if email.HasHeader(header) && email.GetHeaderValue(header) != "" { - recommendedPresent++ - } - } - score += 3.0 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) - - // Check for proper MIME structure (2 points) - if len(email.Parts) > 0 { - score += 2.0 - } - - // Check Message-ID format (1 point) - if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { - if s.isValidMessageID(messageID) { - score += 1.0 - } - } - - // Ensure score doesn't exceed 10.0 - if score > 10.0 { - score = 10.0 - } - - return score -} - -// isValidMessageID checks if a Message-ID has proper format -func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool { - // Basic check: should be in format <...@...> - if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") { - return false - } - - // Remove angle brackets - messageID = strings.TrimPrefix(messageID, "<") - messageID = strings.TrimSuffix(messageID, ">") - - // Should contain @ symbol - if !strings.Contains(messageID, "@") { - return false - } - - parts := strings.Split(messageID, "@") - if len(parts) != 2 { - return false - } - - // Both parts should be non-empty - return len(parts[0]) > 0 && len(parts[1]) > 0 -} - -// determineRating determines the rating based on overall score (0-100) -func (s *DeliverabilityScorer) determineRating(score float32) string { - switch { - case score >= 90.0: - return "Excellent" - case score >= 70.0: - return "Good" - case score >= 50.0: - return "Fair" - case score >= 30.0: - return "Poor" - default: - return "Critical" - } -} - -// getCategoryStatus determines status for a category -func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string { - percentage := (score / maxScore) * 100 - - switch { - case percentage >= 80.0: - return "Pass" - case percentage >= 50.0: - return "Warn" - default: - return "Fail" - } -} - -// generateRecommendations creates actionable recommendations based on scores -func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string { - var recommendations []string - - // Authentication recommendations (0-30 points) - if result.AuthScore < 20.0 { - recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records") - } else if result.AuthScore < 30.0 { - recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") - } - - // Spam recommendations (0-20 points) - if result.SpamScore < 10.0 { - recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns") - } else if result.SpamScore < 15.0 { - recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") - } - - // Blacklist recommendations (0-20 points) - if result.BlacklistScore < 10.0 { - recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation") - } else if result.BlacklistScore < 20.0 { - recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") - } - - // Content recommendations (0-20 points) - if result.ContentScore < 10.0 { - recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure") - } else if result.ContentScore < 15.0 { - recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") - } - - // Header recommendations (0-10 points) - if result.HeaderScore < 5.0 { - recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)") - } else if result.HeaderScore < 10.0 { - recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present") - } - - // Overall recommendations based on rating - if result.Rating == "Excellent" { - recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices") - } else if result.Rating == "Critical" { - recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam") - } - - return recommendations -} - -// GenerateHeaderChecks creates checks for email header quality -func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check { - var checks []api.Check - - if email == nil { - return checks - } - - // Required headers check - checks = append(checks, s.generateRequiredHeadersCheck(email)) - - // Recommended headers check - checks = append(checks, s.generateRecommendedHeadersCheck(email)) - - // Message-ID check - checks = append(checks, s.generateMessageIDCheck(email)) - - // MIME structure check - checks = append(checks, s.generateMIMEStructureCheck(email)) - - return checks -} - -// generateRequiredHeadersCheck checks for required RFC 5322 headers -func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Required Headers", - } - - requiredHeaders := []string{"From", "Date", "Message-ID"} - missing := []string{} - - for _, header := range requiredHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 4.0 - check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "All required headers are present" - check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") - } else { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityCritical) - check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } - - return check -} - -// generateRecommendedHeadersCheck checks for recommended headers -func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Recommended Headers", - } - - recommendedHeaders := []string{"Subject", "To", "Reply-To"} - missing := []string{} - - for _, header := range recommendedHeaders { - if !email.HasHeader(header) || email.GetHeaderValue(header) == "" { - missing = append(missing, header) - } - } - - if len(missing) == 0 { - check.Status = api.CheckStatusPass - check.Score = 3.0 - check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "All recommended headers are present" - check.Advice = api.PtrTo("Your email includes all recommended headers") - } else if len(missing) < len(recommendedHeaders) { - check.Status = api.CheckStatusWarn - check.Score = 1.5 - check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) - check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") - details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) - check.Details = &details - } else { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Message = "Missing all recommended headers" - check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") - } - - return check -} - -// generateMessageIDCheck validates Message-ID header -func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "Message-ID Format", - } - - messageID := email.GetHeaderValue("Message-ID") - - if messageID == "" { - check.Status = api.CheckStatusFail - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityHigh) - check.Message = "Message-ID header is missing" - check.Advice = api.PtrTo("Add a unique Message-ID header to your email") - } else if !s.isValidMessageID(messageID) { - check.Status = api.CheckStatusWarn - check.Score = 0.5 - check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityMedium) - check.Message = "Message-ID format is invalid" - check.Advice = api.PtrTo("Use proper Message-ID format: ") - check.Details = &messageID - } else { - check.Status = api.CheckStatusPass - check.Score = 1.0 - check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = "Message-ID is properly formatted" - check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards") - check.Details = &messageID - } - - return check -} - -// generateMIMEStructureCheck validates MIME structure -func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check { - check := api.Check{ - Category: api.Headers, - Name: "MIME Structure", - } - - if len(email.Parts) == 0 { - check.Status = api.CheckStatusWarn - check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) - check.Severity = api.PtrTo(api.CheckSeverityLow) - check.Message = "No MIME parts detected" - check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") - } else { - check.Status = api.CheckStatusPass - check.Score = 2.0 - check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100) - check.Severity = api.PtrTo(api.CheckSeverityInfo) - check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) - check.Advice = api.PtrTo("Your email has proper MIME structure") - - // Add details about parts - partTypes := []string{} - for _, part := range email.Parts { - if part.ContentType != "" { - partTypes = append(partTypes, part.ContentType) - } - } - if len(partTypes) > 0 { - details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", ")) - check.Details = &details - } - } - - return check -} - -// GetScoreSummary generates a human-readable summary of the score -func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { - var summary strings.Builder - - summary.WriteString(fmt.Sprintf("Overall Score: %.1f/100 (%s) - Grade: %s\n\n", result.OverallScore, result.Rating, ScoreToGrade(result.OverallScore))) - summary.WriteString("Category Breakdown:\n") - summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/30.0 (%.0f%%) - %s\n", - result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status)) - summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/20.0 (%.0f%%) - %s\n", - result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status)) - summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/20.0 (%.0f%%) - %s\n", - result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status)) - summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/20.0 (%.0f%%) - %s\n", - result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status)) - summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/10.0 (%.0f%%) - %s\n", - result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status)) - - if len(result.Recommendations) > 0 { - summary.WriteString("\nRecommendations:\n") - for _, rec := range result.Recommendations { - summary.WriteString(fmt.Sprintf(" %s\n", rec)) - } - } - - return summary.String() -} - -// GetAuthenticationScore calculates the authentication score (0-30 points) -func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { - var score float32 = 0.0 - - // SPF: 10 points for pass, 5 for neutral/softfail, 0 for fail - if results.Spf != nil { - switch results.Spf.Result { - case api.AuthResultResultPass: - score += 10.0 - case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: - score += 5.0 - } - } - - // DKIM: 10 points for at least one pass - if results.Dkim != nil && len(*results.Dkim) > 0 { - for _, dkim := range *results.Dkim { - if dkim.Result == api.AuthResultResultPass { - score += 10.0 - break - } - } - } - - // DMARC: 10 points for pass - if results.Dmarc != nil { - switch results.Dmarc.Result { - case api.AuthResultResultPass: - score += 10.0 - } - } - - // Cap at 30 points maximum - if score > 30.0 { - score = 30.0 - } - - return score -} diff --git a/pkg/analyzer/scoring_test.go b/pkg/analyzer/scoring_test.go index b4c756a..e464432 100644 --- a/pkg/analyzer/scoring_test.go +++ b/pkg/analyzer/scoring_test.go @@ -22,9 +22,6 @@ package analyzer import ( - "net/mail" - "net/textproto" - "strings" "testing" "git.happydns.org/happyDeliver/internal/api" @@ -97,153 +94,6 @@ func TestIsValidMessageID(t *testing.T) { } } -func TestCalculateHeaderScore(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minScore float32 - maxScore float32 - }{ - { - name: "Nil email", - email: nil, - minScore: 0.0, - maxScore: 0.0, - }, - { - name: "Perfect headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 7.0, - maxScore: 10.0, - }, - { - name: "Missing required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Subject": "Test", - }), - }, - minScore: 0.0, - maxScore: 4.0, - }, - { - name: "Required only, no recommended", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 4.0, - maxScore: 8.0, - }, - { - name: "Invalid Message-ID format", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "invalid-message-id", - "Subject": "Test", - "To": "recipient@example.com", - "Reply-To": "reply@example.com", - }), - MessageID: "invalid-message-id", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minScore: 7.0, - maxScore: 10.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - score := scorer.calculateHeaderScore(tt.email) - if score < tt.minScore || score > tt.maxScore { - t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) - } - }) - } -} - -func TestDetermineRating(t *testing.T) { - tests := []struct { - name string - score float32 - expected string - }{ - {name: "Excellent - 10.0", score: 100.0, expected: "Excellent"}, - {name: "Excellent - 9.5", score: 95.0, expected: "Excellent"}, - {name: "Excellent - 9.0", score: 90.0, expected: "Excellent"}, - {name: "Good - 8.5", score: 85.0, expected: "Good"}, - {name: "Good - 7.0", score: 70.0, expected: "Good"}, - {name: "Fair - 6.5", score: 65.0, expected: "Fair"}, - {name: "Fair - 5.0", score: 50.0, expected: "Fair"}, - {name: "Poor - 4.5", score: 45.0, expected: "Poor"}, - {name: "Poor - 3.0", score: 30.0, expected: "Poor"}, - {name: "Critical - 2.5", score: 25.0, expected: "Critical"}, - {name: "Critical - 0.0", score: 0.0, expected: "Critical"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.determineRating(tt.score) - if result != tt.expected { - t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected) - } - }) - } -} - -func TestGetCategoryStatus(t *testing.T) { - tests := []struct { - name string - score float32 - maxScore float32 - expected string - }{ - {name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"}, - {name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"}, - {name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"}, - {name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"}, - {name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"}, - {name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"}, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := scorer.getCategoryStatus(tt.score, tt.maxScore) - if result != tt.expected { - t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected) - } - }) - } -} - func TestCalculateScore(t *testing.T) { tests := []struct { name string @@ -252,9 +102,9 @@ func TestCalculateScore(t *testing.T) { rblResults *RBLResults contentResults *ContentResults email *EmailMessage - minScore float32 - maxScore float32 - expectedRating string + minScore int + maxScore int + expectedGrade string }{ { name: "Perfect email", @@ -294,9 +144,9 @@ func TestCalculateScore(t *testing.T) { MessageID: "", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 90.0, - maxScore: 100.0, - expectedRating: "Excellent", + minScore: 90.0, + maxScore: 100.0, + expectedGrade: "A+", }, { name: "Poor email - auth issues", @@ -329,9 +179,9 @@ func TestCalculateScore(t *testing.T) { "From": "sender@example.com", }), }, - minScore: 0.0, - maxScore: 50.0, - expectedRating: "Poor", + minScore: 0.0, + maxScore: 50.0, + expectedGrade: "C", }, { name: "Average email", @@ -366,9 +216,9 @@ func TestCalculateScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 60.0, - maxScore: 90.0, - expectedRating: "Good", + minScore: 60.0, + maxScore: 90.0, + expectedGrade: "A", }, } @@ -394,8 +244,8 @@ func TestCalculateScore(t *testing.T) { } // Check rating - if result.Rating != tt.expectedRating { - t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating) + if result.Grade != api.ReportGrade(tt.expectedGrade) { + t.Errorf("Grade = %q, want %q", result.Grade, tt.expectedGrade) } // Verify score is within bounds @@ -409,354 +259,16 @@ func TestCalculateScore(t *testing.T) { } // Verify recommendations exist - if len(result.Recommendations) == 0 && result.Rating != "Excellent" { + if len(result.Recommendations) == 0 && result.Grade != "A+" { t.Error("Expected recommendations for non-excellent rating") } // Verify category scores add up to overall score totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore - if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 { - t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)", + if totalCategoryScore != result.OverallScore { + t.Errorf("Category scores sum (%d) doesn't match overall score (%d)", totalCategoryScore, result.OverallScore) } }) } } - -func TestGenerateRecommendations(t *testing.T) { - tests := []struct { - name string - result *ScoringResult - expectedMinCount int - shouldContainKeyword string - }{ - { - name: "Excellent - minimal recommendations", - result: &ScoringResult{ - OverallScore: 9.5, - Rating: "Excellent", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 2.0, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "Excellent", - }, - { - name: "Critical - many recommendations", - result: &ScoringResult{ - OverallScore: 1.0, - Rating: "Critical", - AuthScore: 0.5, - SpamScore: 0.0, - BlacklistScore: 0.0, - ContentScore: 0.3, - HeaderScore: 0.2, - }, - expectedMinCount: 5, - shouldContainKeyword: "Critical", - }, - { - name: "Poor authentication", - result: &ScoringResult{ - OverallScore: 5.0, - Rating: "Fair", - AuthScore: 1.5, - SpamScore: 2.0, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "authentication", - }, - { - name: "Blacklist issues", - result: &ScoringResult{ - OverallScore: 4.0, - Rating: "Poor", - AuthScore: 3.0, - SpamScore: 2.0, - BlacklistScore: 0.5, - ContentScore: 1.5, - HeaderScore: 1.0, - }, - expectedMinCount: 1, - shouldContainKeyword: "blacklist", - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recommendations := scorer.generateRecommendations(tt.result) - - if len(recommendations) < tt.expectedMinCount { - t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount) - } - - // Check if expected keyword appears in any recommendation - found := false - for _, rec := range recommendations { - if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) { - found = true - break - } - } - - if !found { - t.Errorf("No recommendation contains keyword %q. Recommendations: %v", - tt.shouldContainKeyword, recommendations) - } - }) - } -} - -func TestGenerateRequiredHeadersCheck(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - expectedStatus api.CheckStatus - expectedScore float32 - }{ - { - name: "All required headers present", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - }), - From: &mail.Address{Address: "sender@example.com"}, - MessageID: "", - Date: "Mon, 01 Jan 2024 12:00:00 +0000", - }, - expectedStatus: api.CheckStatusPass, - expectedScore: 4.0, - }, - { - name: "Missing all required headers", - email: &EmailMessage{ - Header: make(mail.Header), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - { - name: "Missing some required headers", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - }), - }, - expectedStatus: api.CheckStatusFail, - expectedScore: 0.0, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - check := scorer.generateRequiredHeadersCheck(tt.email) - - 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.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMessageIDCheck(t *testing.T) { - tests := []struct { - name string - messageID string - expectedStatus api.CheckStatus - }{ - { - name: "Valid Message-ID", - messageID: "", - expectedStatus: api.CheckStatusPass, - }, - { - name: "Invalid Message-ID format", - messageID: "invalid-message-id", - expectedStatus: api.CheckStatusWarn, - }, - { - name: "Missing Message-ID", - messageID: "", - expectedStatus: api.CheckStatusFail, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "Message-ID": tt.messageID, - }), - } - - check := scorer.generateMessageIDCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - if check.Category != api.Headers { - t.Errorf("Category = %v, want %v", check.Category, api.Headers) - } - }) - } -} - -func TestGenerateMIMEStructureCheck(t *testing.T) { - tests := []struct { - name string - parts []MessagePart - expectedStatus api.CheckStatus - }{ - { - name: "With MIME parts", - parts: []MessagePart{ - {ContentType: "text/plain", Content: "test"}, - {ContentType: "text/html", Content: "

test

"}, - }, - expectedStatus: api.CheckStatusPass, - }, - { - name: "No MIME parts", - parts: []MessagePart{}, - expectedStatus: api.CheckStatusWarn, - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := &EmailMessage{ - Header: make(mail.Header), - Parts: tt.parts, - } - - check := scorer.generateMIMEStructureCheck(email) - - if check.Status != tt.expectedStatus { - t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) - } - }) - } -} - -func TestGenerateHeaderChecks(t *testing.T) { - tests := []struct { - name string - email *EmailMessage - minChecks int - }{ - { - name: "Nil email", - email: nil, - minChecks: 0, - }, - { - name: "Complete email", - email: &EmailMessage{ - Header: createHeaderWithFields(map[string]string{ - "From": "sender@example.com", - "To": "recipient@example.com", - "Subject": "Test", - "Date": "Mon, 01 Jan 2024 12:00:00 +0000", - "Message-ID": "", - "Reply-To": "reply@example.com", - }), - Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, - }, - minChecks: 4, // Required, Recommended, Message-ID, MIME - }, - } - - scorer := NewDeliverabilityScorer() - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checks := scorer.GenerateHeaderChecks(tt.email) - - if len(checks) < tt.minChecks { - t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) - } - - // Verify all checks have the Headers category - for _, check := range checks { - if check.Category != api.Headers { - t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) - } - } - }) - } -} - -func TestGetScoreSummary(t *testing.T) { - result := &ScoringResult{ - OverallScore: 8.5, - Rating: "Good", - AuthScore: 2.5, - SpamScore: 1.8, - BlacklistScore: 2.0, - ContentScore: 1.5, - HeaderScore: 0.7, - CategoryBreakdown: map[string]CategoryScore{ - "Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"}, - "Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"}, - "Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"}, - "Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"}, - "Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"}, - }, - Recommendations: []string{ - "Improve content quality", - "Add more headers", - }, - } - - scorer := NewDeliverabilityScorer() - summary := scorer.GetScoreSummary(result) - - // Check that summary contains key information - if !strings.Contains(summary, "8.5") { - t.Error("Summary should contain overall score") - } - if !strings.Contains(summary, "Good") { - t.Error("Summary should contain rating") - } - if !strings.Contains(summary, "Authentication") { - t.Error("Summary should contain category names") - } - if !strings.Contains(summary, "Recommendations") { - t.Error("Summary should contain recommendations section") - } -} - -// Helper function to create mail.Header with specific fields -func createHeaderWithFields(fields map[string]string) mail.Header { - header := make(mail.Header) - for key, value := range fields { - if value != "" { - // Use canonical MIME header key format - canonicalKey := textproto.CanonicalMIMEHeaderKey(key) - header[canonicalKey] = []string{value} - } - } - return header -} diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 2a3ff60..a3f175f 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -23,6 +23,7 @@ package analyzer import ( "fmt" + "math" "regexp" "strconv" "strings" @@ -174,41 +175,28 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass } } -// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-20 points) -// Scoring: -// - Score <= 0: 20 points (excellent) -// - Score < required: 15 points (good) -// - Score slightly above required (< 2x): 10 points (borderline) -// - Score moderately high (< 3x required): 5 points (poor) -// - Score very high: 0 points (spam) -func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 { +// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability +func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) int { if result == nil { - return 0.0 + return 0 } score := result.Score required := result.RequiredScore if required == 0 { - required = 5.0 // Default SpamAssassin threshold + required = 5 // Default SpamAssassin threshold } // Calculate deliverability score if score <= 0 { - return 20.0 - } else if score < required { - // Linear scaling from 15 to 20 based on how negative/low the score is - ratio := score / required - return 15.0 + (5.0 * (1.0 - float32(ratio))) - } else if score < required*2 { - // Slightly above threshold - return 10.0 - } else if score < required*3 { - // Moderately high - return 5.0 + return 100 + } + if score <= required*4 { + return 0 } - // Very high spam score - return 0.0 + // Linear scaling based on how negative/low the score is + return 100 - int(math.Round(25*score/required)) } // GenerateSpamAssassinChecks generates check results for SpamAssassin analysis @@ -259,9 +247,8 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) required = 5.0 } - delivScore := a.GetSpamAssassinScore(result) - check.Score = delivScore - check.Grade = ScoreToCheckGrade((delivScore / 20.0) * 100) + check.Score = a.GetSpamAssassinScore(result) + check.Grade = ScoreToCheckGrade(check.Score) // Determine status and message based on score if score <= 0 { @@ -320,7 +307,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec check.Severity = api.PtrTo(api.CheckSeverityMedium) } check.Score = 0.0 - check.Grade = ScoreToCheckGrade(0.0) + check.Grade = ScoreToCheckGrade(0) check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score) advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score) check.Advice = &advice @@ -339,11 +326,3 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec return check } - -// min returns the minimum of two integers -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index deed1c7..54b9c0c 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -154,14 +154,14 @@ func TestGetSpamAssassinScore(t *testing.T) { tests := []struct { name string result *SpamAssassinResult - expectedScore float32 - minScore float32 - maxScore float32 + expectedScore int + minScore int + maxScore int }{ { name: "Nil result", result: nil, - expectedScore: 0.0, + expectedScore: 0, }, { name: "Excellent score (negative)", @@ -169,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: -2.5, RequiredScore: 5.0, }, - expectedScore: 20.0, + expectedScore: 100, }, { name: "Good score (below threshold)", @@ -177,8 +177,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 2.0, RequiredScore: 5.0, }, - minScore: 15.0, - maxScore: 20.0, + minScore: 80, + maxScore: 100, }, { name: "Borderline (just above threshold)", @@ -186,7 +186,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 6.0, RequiredScore: 5.0, }, - expectedScore: 10.0, + minScore: 60, + maxScore: 80, }, { name: "High spam score", @@ -194,7 +195,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 12.0, RequiredScore: 5.0, }, - expectedScore: 5.0, + minScore: 20, + maxScore: 50, }, { name: "Very high spam score", @@ -202,7 +204,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 20.0, RequiredScore: 5.0, }, - expectedScore: 0.0, + expectedScore: 0, }, } @@ -618,8 +620,8 @@ func TestAnalyzeRealEmailExample(t *testing.T) { // Test GetSpamAssassinScore score := analyzer.GetSpamAssassinScore(result) - if score != 20.0 { - t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score) + if score != 100 { + t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score) } // Test GenerateSpamAssassinChecks @@ -639,14 +641,14 @@ func TestAnalyzeRealEmailExample(t *testing.T) { if !strings.Contains(mainCheck.Message, "spam score") { t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message) } - if mainCheck.Score != 20.0 { - t.Errorf("Main check score = %v, want 20.0", mainCheck.Score) + if mainCheck.Score != 100 { + t.Errorf("Main check score = %v, want 100", 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)", + t.Logf(" Check %d: %s - %s (score: %d, status: %s)", i+1, check.Name, check.Message, check.Score, check.Status) } } diff --git a/web/src/lib/components/CheckCard.svelte b/web/src/lib/components/CheckCard.svelte index abd200f..de84a70 100644 --- a/web/src/lib/components/CheckCard.svelte +++ b/web/src/lib/components/CheckCard.svelte @@ -32,7 +32,7 @@
{check.name}
- {check.score.toFixed(1)} pts + {check.score}%

{check.message}

@@ -48,7 +48,7 @@ {#if check.details}
Technical Details -
{check.details}
+
{check.details}
{/if}
diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index c520c79..0b74a38 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -2,25 +2,26 @@ import type { ScoreSummary } from "$lib/api/types.gen"; interface Props { + grade: string; score: number; summary?: ScoreSummary; } - let { score, summary }: Props = $props(); + let { grade, score, summary }: Props = $props(); function getScoreClass(score: number): string { - if (score >= 9) return "score-excellent"; - if (score >= 7) return "score-good"; - if (score >= 5) return "score-warning"; - if (score >= 3) return "score-poor"; + if (score >= 90) return "score-excellent"; + if (score >= 70) return "score-good"; + if (score >= 50) return "score-warning"; + if (score >= 30) return "score-poor"; return "score-bad"; } function getScoreLabel(score: number): string { - if (score >= 9) return "Excellent"; - if (score >= 7) return "Good"; - if (score >= 5) return "Fair"; - if (score >= 3) return "Poor"; + if (score >= 90) return "Excellent"; + if (score >= 70) return "Good"; + if (score >= 50) return "Fair"; + if (score >= 30) return "Poor"; return "Critical"; } @@ -28,7 +29,7 @@

- {score.toFixed(1)}/10 + {grade}

{getScoreLabel(score)}

Overall Deliverability Score

@@ -39,12 +40,12 @@
= 3} - class:text-warning={summary.authentication_score < 3 && - summary.authentication_score >= 1.5} - class:text-danger={summary.authentication_score < 1.5} + class:text-success={summary.authentication_score >= 100} + class:text-warning={summary.authentication_score < 100 && + summary.authentication_score >= 50} + class:text-danger={summary.authentication_score < 50} > - {summary.authentication_score.toFixed(1)}/3 + {summary.authentication_score}% Authentication
@@ -53,11 +54,11 @@
= 2} - class:text-warning={summary.spam_score < 2 && summary.spam_score >= 1} - class:text-danger={summary.spam_score < 1} + class:text-success={summary.spam_score >= 100} + class:text-warning={summary.spam_score < 100 && summary.spam_score >= 50} + class:text-danger={summary.spam_score < 50} > - {summary.spam_score.toFixed(1)}/2 + {summary.spam_score}% Spam Score
@@ -66,12 +67,12 @@
= 2} - class:text-warning={summary.blacklist_score < 2 && - summary.blacklist_score >= 1} - class:text-danger={summary.blacklist_score < 1} + class:text-success={summary.blacklist_score >= 100} + class:text-warning={summary.blacklist_score < 100 && + summary.blacklist_score >= 50} + class:text-danger={summary.blacklist_score < 50} > - {summary.blacklist_score.toFixed(1)}/2 + {summary.blacklist_score}% Blacklists
@@ -80,12 +81,12 @@
= 2} - class:text-warning={summary.content_score < 2 && - summary.content_score >= 1} - class:text-danger={summary.content_score < 1} + class:text-success={summary.content_score >= 100} + class:text-warning={summary.content_score < 100 && + summary.content_score >= 50} + class:text-danger={summary.content_score < 50} > - {summary.content_score.toFixed(1)}/2 + {summary.content_score}% Content
@@ -94,12 +95,12 @@
= 1} - class:text-warning={summary.header_score < 1 && - summary.header_score >= 0.5} - class:text-danger={summary.header_score < 0.5} + class:text-success={summary.header_score >= 100} + class:text-warning={summary.header_score < 100 && + summary.header_score >= 50} + class:text-danger={summary.header_score < 50} > - {summary.header_score.toFixed(1)}/1 + {summary.header_score}% Headers
diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index db3e447..fd36ce7 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -17,9 +17,9 @@ // Group checks by category let groupedChecks = $derived(() => { - if (!report) return {}; + if (!report) return { }; - const groups: Record = {}; + const groups: Record = { }; for (const check of report.checks) { if (!groups[check.category]) { groups[check.category] = []; @@ -106,31 +106,10 @@ } function getCategoryScore(checks: typeof report.checks): number { - return checks.reduce((sum, check) => sum + check.score, 0); + return Math.round(checks.reduce((sum, check) => sum + check.score, 0) / checks.filter((c) => c.status != "info").length); } - function getCategoryMaxScore(category: string): number { - switch (category) { - case "authentication": - return 3; - case "spam": - return 2; - case "blacklist": - return 2; - case "content": - return 2; - case "headers": - return 1; - case "dns": - return 0; // DNS checks contribute to other categories - default: - return 0; - } - } - - function getScoreColorClass(score: number, maxScore: number): string { - if (maxScore === 0) return "text-muted"; - const percentage = (score / maxScore) * 100; + function getScoreColorClass(percentage: number): string { if (percentage >= 80) return "text-success"; if (percentage >= 50) return "text-warning"; return "text-danger"; @@ -189,7 +168,7 @@
- +
@@ -199,15 +178,14 @@

Detailed Checks

{#each Object.entries(groupedChecks()) as [category, checks]} {@const categoryScore = getCategoryScore(checks)} - {@const maxScore = getCategoryMaxScore(category)}

{category} - - {categoryScore.toFixed(1)}{#if maxScore > 0} / {maxScore}{/if} pts + + {categoryScore}%

{#each checks as check}