diff --git a/api/openapi.yaml b/api/openapi.yaml index c569664..8852c42 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -204,6 +204,7 @@ components: - id - test_id - score + - grade - checks - created_at properties: @@ -219,9 +220,14 @@ components: type: number format: float minimum: 0 - maximum: 10 - description: Overall deliverability score (0-10) - example: 8.5 + maximum: 100 + description: Overall deliverability score as percentage (0-100) + example: 85 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" summary: $ref: '#/components/schemas/ScoreSummary' checks: @@ -260,37 +266,37 @@ components: type: number format: float minimum: 0 - maximum: 3 - description: SPF/DKIM/DMARC score (max 3 pts) - example: 2.8 + maximum: 100 + description: SPF/DKIM/DMARC score (in percentage) + example: 28 spam_score: type: number format: float minimum: 0 - maximum: 2 - description: SpamAssassin score (max 2 pts) - example: 1.5 + maximum: 100 + description: SpamAssassin score (in percentage) + example: 15 blacklist_score: type: number format: float minimum: 0 - maximum: 2 - description: Blacklist check score (max 2 pts) - example: 2.0 + maximum: 100 + description: Blacklist check score (in percentage) + example: 20 content_score: type: number format: float minimum: 0 - maximum: 2 - description: Content quality score (max 2 pts) - example: 1.8 + maximum: 100 + description: Content quality score (in percentage) + example: 18 header_score: type: number format: float minimum: 0 - maximum: 1 - description: Header quality score (max 1 pt) - example: 0.9 + maximum: 100 + description: Header quality score (in percentage) + example: 9 Check: type: object @@ -299,6 +305,7 @@ components: - name - status - score + - grade - message properties: category: @@ -319,7 +326,12 @@ components: type: number format: float description: Points contributed to total score - example: 1.0 + example: 10 + grade: + type: string + enum: [A+, A, B, C, D, E, F] + description: Letter grade representation of the score (A+ is best, F is worst) + example: "A" message: type: string description: Human-readable result message diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index 8328270..ecd5832 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -509,7 +509,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 3.0, + expectedScore: 30.0, }, { name: "SPF and DKIM only", @@ -521,7 +521,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "SPF fail, DKIM pass", @@ -533,7 +533,7 @@ func TestGetAuthenticationScore(t *testing.T) { {Result: api.AuthResultResultPass}, }, }, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "SPF softfail", @@ -542,7 +542,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultSoftfail, }, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "No authentication", @@ -559,7 +559,7 @@ func TestGetAuthenticationScore(t *testing.T) { Result: api.AuthResultResultPass, }, }, - expectedScore: 1.0, // Only SPF counted, not BIMI + expectedScore: 10.0, // Only SPF counted, not BIMI }, } diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index ac46259..7c68323 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -745,7 +745,7 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap return check } -// GetContentScore calculates the content score (0-2 points) +// GetContentScore calculates the content score (0-20 points) func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { if results == nil { return 0.0 @@ -753,12 +753,12 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 { var score float32 = 0.0 - // HTML validity (0.2 points) + // HTML validity (2 points) if results.HTMLValid { - score += 0.2 + score += 2.0 } - // Links (0.4 points) + // Links (4 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 += 0.4 + score += 4.0 } } else { // No links is neutral, give partial score - score += 0.2 + score += 2.0 } - // Images (0.3 points) + // Images (3 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 += 0.3 + score += 3.0 } else if noAltCount < len(results.Images) { - score += 0.15 + score += 1.5 } } else { // No images is neutral - score += 0.15 + score += 1.5 } - // Unsubscribe link (0.3 points) + // Unsubscribe link (3 points) if results.HasUnsubscribe { - score += 0.3 + score += 3.0 } - // Text consistency (0.3 points) + // Text consistency (3 points) if results.TextPlainRatio >= 0.3 { - score += 0.3 + score += 3.0 } - // Image ratio (0.3 points) + // Image ratio (3 points) if results.ImageTextRatio <= 5.0 { - score += 0.3 + score += 3.0 } else if results.ImageTextRatio <= 10.0 { - score += 0.15 + score += 1.5 } - // Penalize suspicious URLs (deduct up to 0.5 points) + // Penalize suspicious URLs (deduct up to 5 points) if len(results.SuspiciousURLs) > 0 { - penalty := float32(len(results.SuspiciousURLs)) * 0.1 - if penalty > 0.5 { - penalty = 0.5 + penalty := float32(len(results.SuspiciousURLs)) * 1.0 + if penalty > 5.0 { + penalty = 5.0 } score -= penalty } - // Ensure score is between 0 and 2 + // Ensure score is between 0 and 20 if score < 0 { score = 0 } - if score > 2.0 { - score = 2.0 + if score > 20.0 { + score = 20.0 } return score diff --git a/pkg/analyzer/content_test.go b/pkg/analyzer/content_test.go index 342f3cb..c82d4a8 100644 --- a/pkg/analyzer/content_test.go +++ b/pkg/analyzer/content_test.go @@ -946,8 +946,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.8, ImageTextRatio: 3.0, }, - minScore: 1.8, - maxScore: 2.0, + minScore: 18.0, + maxScore: 20.0, }, { name: "Poor content", @@ -961,7 +961,7 @@ func TestGetContentScore(t *testing.T) { SuspiciousURLs: []string{"url1", "url2"}, }, minScore: 0.0, - maxScore: 0.5, + maxScore: 5.0, }, { name: "Average content", @@ -973,8 +973,8 @@ func TestGetContentScore(t *testing.T) { TextPlainRatio: 0.5, ImageTextRatio: 4.0, }, - minScore: 1.0, - maxScore: 1.8, + minScore: 10.0, + maxScore: 18.0, }, } @@ -988,9 +988,9 @@ func TestGetContentScore(t *testing.T) { t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) } - // Ensure score is capped at 2.0 - if score > 2.0 { - t.Errorf("Score %v exceeds maximum of 2.0", score) + // Ensure score is capped at 20.0 + if score > 20.0 { + t.Errorf("Score %v exceeds maximum of 20.0", score) } // Ensure score is not negative diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index fb01ae0..3904c6f 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -238,26 +238,26 @@ 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-2 points) +// GetBlacklistScore calculates the blacklist contribution to deliverability (0-20 points) // Scoring: -// - Not listed on any RBL: 2 points (excellent) -// - Listed on 1 RBL: 1 point (warning) -// - Listed on 2-3 RBLs: 0.5 points (poor) +// - 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 { if results == nil || len(results.IPsChecked) == 0 { // No IPs to check, give benefit of doubt - return 2.0 + return 20.0 } listedCount := results.ListedCount if listedCount == 0 { - return 2.0 + return 20.0 } else if listedCount == 1 { - return 1.0 + return 10.0 } else if listedCount <= 3 { - return 0.5 + return 5.0 } return 0.0 @@ -277,7 +277,8 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check { Category: api.Blacklist, Name: "RBL Check", Status: api.CheckStatusWarn, - Score: 1.0, + Score: 10.0, + Grade: ScoreToCheckGrade((10.0 / 20.0) * 100), Message: "No public IP addresses found to check", Severity: api.PtrTo(api.CheckSeverityLow), Advice: api.PtrTo("Unable to extract sender IP from email headers"), @@ -309,6 +310,7 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check { score := r.GetBlacklistScore(results) check.Score = score + check.Grade = ScoreToCheckGrade((score / 20.0) * 100) totalChecks := len(results.Checks) listedCount := results.ListedCount @@ -351,6 +353,7 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check { Name: fmt.Sprintf("RBL: %s", rblCheck.RBL), Status: api.CheckStatusFail, Score: 0.0, + Grade: ScoreToCheckGrade(0.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 3a2fd44..0bf8c0e 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -272,14 +272,14 @@ func TestGetBlacklistScore(t *testing.T) { { name: "Nil results", results: nil, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "No IPs checked", results: &RBLResults{ IPsChecked: []string{}, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Not listed on any RBL", @@ -287,7 +287,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Listed on 1 RBL", @@ -295,7 +295,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "Listed on 2 RBLs", @@ -303,7 +303,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Listed on 3 RBLs", @@ -311,7 +311,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Listed on 4+ RBLs", @@ -350,7 +350,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), // 6 default RBLs }, expectedStatus: api.CheckStatusPass, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Listed on 1 RBL", @@ -360,7 +360,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "Listed on 2 RBLs", @@ -370,7 +370,7 @@ func TestGenerateSummaryCheck(t *testing.T) { Checks: make([]RBLCheck, 6), }, expectedStatus: api.CheckStatusWarn, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Listed on 4+ RBLs", diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index d6a1e23..79799b9 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -100,6 +100,7 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu Id: utils.UUIDToBase32(reportID), TestId: utils.UUIDToBase32(testID), Score: results.Score.OverallScore, + Grade: ScoreToReportGrade(results.Score.OverallScore), CreatedAt: now, } diff --git a/pkg/analyzer/report_test.go b/pkg/analyzer/report_test.go index fce4a64..0dd7e8c 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -88,7 +88,7 @@ func TestAnalyzeEmail(t *testing.T) { } // Verify score is within bounds - if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 { + if results.Score.OverallScore < 0 || results.Score.OverallScore > 100 { t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore) } } @@ -117,7 +117,7 @@ func TestGenerateReport(t *testing.T) { t.Errorf("TestId = %s, want %s", report.TestId, expectedTestID) } - if report.Score < 0 || report.Score > 10 { + if report.Score < 0 || report.Score > 100 { t.Errorf("Score %v is out of bounds", report.Score) } @@ -137,13 +137,13 @@ func TestGenerateReport(t *testing.T) { if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 { t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) } - if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 { + if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 20 { t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) } - if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 { + if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 20 { t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) } - if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 { + if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 10 { t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) } } diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 03ab870..7d5184f 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -29,6 +29,36 @@ import ( "git.happydns.org/happyDeliver/internal/api" ) +// ScoreToGrade converts a percentage score (0-100) to a letter grade +func ScoreToGrade(score float32) string { + switch { + case score >= 97: + return "A+" + case score >= 93: + return "A" + case score >= 85: + return "B" + case score >= 75: + return "C" + case score >= 65: + return "D" + case score >= 50: + return "E" + default: + return "F" + } +} + +// ScoreToCheckGrade converts a percentage score to an api.CheckGrade +func ScoreToCheckGrade(score float32) api.CheckGrade { + return api.CheckGrade(ScoreToGrade(score)) +} + +// ScoreToReportGrade converts a percentage score to an api.ReportGrade +func ScoreToReportGrade(score float32) api.ReportGrade { + return api.ReportGrade(ScoreToGrade(score)) +} + // DeliverabilityScorer aggregates all analysis results and computes overall score type DeliverabilityScorer struct{} @@ -86,12 +116,12 @@ func (s *DeliverabilityScorer) CalculateScore( // Calculate header quality score result.HeaderScore = s.calculateHeaderScore(email) - // Calculate overall score (out of 10) + // 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 > 10.0 { - result.OverallScore = 10.0 + if result.OverallScore > 100.0 { + result.OverallScore = 100.0 } if result.OverallScore < 0.0 { result.OverallScore = 0.0 @@ -103,37 +133,37 @@ func (s *DeliverabilityScorer) CalculateScore( // Build category breakdown result.CategoryBreakdown["Authentication"] = CategoryScore{ Score: result.AuthScore, - MaxScore: 3.0, - Percentage: (result.AuthScore / 3.0) * 100, - Status: s.getCategoryStatus(result.AuthScore, 3.0), + MaxScore: 30.0, + Percentage: result.AuthScore, + Status: s.getCategoryStatus(result.AuthScore, 30.0), } result.CategoryBreakdown["Spam Filters"] = CategoryScore{ Score: result.SpamScore, - MaxScore: 2.0, - Percentage: (result.SpamScore / 2.0) * 100, - Status: s.getCategoryStatus(result.SpamScore, 2.0), + MaxScore: 20.0, + Percentage: result.SpamScore, + Status: s.getCategoryStatus(result.SpamScore, 20.0), } result.CategoryBreakdown["Blacklists"] = CategoryScore{ Score: result.BlacklistScore, - MaxScore: 2.0, - Percentage: (result.BlacklistScore / 2.0) * 100, - Status: s.getCategoryStatus(result.BlacklistScore, 2.0), + MaxScore: 20.0, + Percentage: result.BlacklistScore, + Status: s.getCategoryStatus(result.BlacklistScore, 20.0), } result.CategoryBreakdown["Content Quality"] = CategoryScore{ Score: result.ContentScore, - MaxScore: 2.0, - Percentage: (result.ContentScore / 2.0) * 100, - Status: s.getCategoryStatus(result.ContentScore, 2.0), + MaxScore: 20.0, + Percentage: result.ContentScore, + Status: s.getCategoryStatus(result.ContentScore, 20.0), } result.CategoryBreakdown["Email Structure"] = CategoryScore{ Score: result.HeaderScore, - MaxScore: 1.0, - Percentage: (result.HeaderScore / 1.0) * 100, - Status: s.getCategoryStatus(result.HeaderScore, 1.0), + MaxScore: 10.0, + Percentage: result.HeaderScore, + Status: s.getCategoryStatus(result.HeaderScore, 10.0), } // Generate recommendations @@ -142,7 +172,7 @@ func (s *DeliverabilityScorer) CalculateScore( return result } -// calculateHeaderScore evaluates email structural quality (0-1 point) +// calculateHeaderScore evaluates email structural quality (0-10 points) func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 { if email == nil { return 0.0 @@ -167,14 +197,14 @@ func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 } } - // Score based on required headers (0.4 points) + // Score based on required headers (4 points) if presentHeaders == requiredHeaders { - score += 0.4 + score += 4.0 } else { - score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders)) + score += 4.0 * (float32(presentHeaders) / float32(requiredHeaders)) } - // Check recommended headers (0.3 points) + // Check recommended headers (3 points) recommendedHeaders := []string{"Subject", "To", "Reply-To"} recommendedPresent := 0 for _, header := range recommendedHeaders { @@ -182,23 +212,23 @@ func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 recommendedPresent++ } } - score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) + score += 3.0 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) - // Check for proper MIME structure (0.2 points) + // Check for proper MIME structure (2 points) if len(email.Parts) > 0 { - score += 0.2 + score += 2.0 } - // Check Message-ID format (0.1 points) + // Check Message-ID format (1 point) if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { if s.isValidMessageID(messageID) { - score += 0.1 + score += 1.0 } } - // Ensure score doesn't exceed 1.0 - if score > 1.0 { - score = 1.0 + // Ensure score doesn't exceed 10.0 + if score > 10.0 { + score = 10.0 } return score @@ -229,16 +259,16 @@ func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool { return len(parts[0]) > 0 && len(parts[1]) > 0 } -// determineRating determines the rating based on overall score +// determineRating determines the rating based on overall score (0-100) func (s *DeliverabilityScorer) determineRating(score float32) string { switch { - case score >= 9.0: + case score >= 90.0: return "Excellent" - case score >= 7.0: + case score >= 70.0: return "Good" - case score >= 5.0: + case score >= 50.0: return "Fair" - case score >= 3.0: + case score >= 30.0: return "Poor" default: return "Critical" @@ -263,38 +293,38 @@ func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string { var recommendations []string - // Authentication recommendations - if result.AuthScore < 2.0 { + // 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 < 3.0 { + } else if result.AuthScore < 30.0 { recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") } - // Spam recommendations - if result.SpamScore < 1.0 { + // 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 < 1.5 { + } else if result.SpamScore < 15.0 { recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") } - // Blacklist recommendations - if result.BlacklistScore < 1.0 { + // 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 < 2.0 { + } else if result.BlacklistScore < 20.0 { recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") } - // Content recommendations - if result.ContentScore < 1.0 { + // 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 < 1.5 { + } else if result.ContentScore < 15.0 { recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") } - // Header recommendations - if result.HeaderScore < 0.5 { + // 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 < 1.0 { + } else if result.HeaderScore < 10.0 { recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present") } @@ -349,13 +379,15 @@ func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) if len(missing) == 0 { check.Status = api.CheckStatusPass - check.Score = 0.4 + 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") @@ -384,13 +416,15 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa if len(missing) == 0 { check.Status = api.CheckStatusPass - check.Score = 0.3 + 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 = 0.15 + 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") @@ -399,6 +433,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa } 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") @@ -419,19 +454,22 @@ func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.C 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.05 + 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 = 0.1 + 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") @@ -451,12 +489,14 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a 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 = 0.2 + 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") @@ -481,17 +521,17 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { var summary strings.Builder - summary.WriteString(fmt.Sprintf("Overall Score: %.1f/10 (%s)\n\n", result.OverallScore, result.Rating)) + 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/3.0 (%.0f%%) - %s\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/2.0 (%.0f%%) - %s\n", + 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/2.0 (%.0f%%) - %s\n", + 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/2.0 (%.0f%%) - %s\n", + 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/1.0 (%.0f%%) - %s\n", + 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 { @@ -504,41 +544,41 @@ func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string { return summary.String() } -// GetAuthenticationScore calculates the authentication score (0-3 points) +// GetAuthenticationScore calculates the authentication score (0-30 points) func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { var score float32 = 0.0 - // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail + // SPF: 10 points for pass, 5 for neutral/softfail, 0 for fail if results.Spf != nil { switch results.Spf.Result { case api.AuthResultResultPass: - score += 1.0 + score += 10.0 case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: - score += 0.5 + score += 5.0 } } - // DKIM: 1 point for at least one pass + // 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 += 1.0 + score += 10.0 break } } } - // DMARC: 1 point for pass + // DMARC: 10 points for pass if results.Dmarc != nil { switch results.Dmarc.Result { case api.AuthResultResultPass: - score += 1.0 + score += 10.0 } } - // Cap at 3 points maximum - if score > 3.0 { - score = 3.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 b28182d..b4c756a 100644 --- a/pkg/analyzer/scoring_test.go +++ b/pkg/analyzer/scoring_test.go @@ -125,8 +125,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 0.7, - maxScore: 1.0, + minScore: 7.0, + maxScore: 10.0, }, { name: "Missing required headers", @@ -136,7 +136,7 @@ func TestCalculateHeaderScore(t *testing.T) { }), }, minScore: 0.0, - maxScore: 0.4, + maxScore: 4.0, }, { name: "Required only, no recommended", @@ -150,8 +150,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 0.4, - maxScore: 0.8, + minScore: 4.0, + maxScore: 8.0, }, { name: "Invalid Message-ID format", @@ -168,8 +168,8 @@ func TestCalculateHeaderScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 0.7, - maxScore: 1.0, + minScore: 7.0, + maxScore: 10.0, }, } @@ -191,16 +191,16 @@ func TestDetermineRating(t *testing.T) { score float32 expected string }{ - {name: "Excellent - 10.0", score: 10.0, expected: "Excellent"}, - {name: "Excellent - 9.5", score: 9.5, expected: "Excellent"}, - {name: "Excellent - 9.0", score: 9.0, expected: "Excellent"}, - {name: "Good - 8.5", score: 8.5, expected: "Good"}, - {name: "Good - 7.0", score: 7.0, expected: "Good"}, - {name: "Fair - 6.5", score: 6.5, expected: "Fair"}, - {name: "Fair - 5.0", score: 5.0, expected: "Fair"}, - {name: "Poor - 4.5", score: 4.5, expected: "Poor"}, - {name: "Poor - 3.0", score: 3.0, expected: "Poor"}, - {name: "Critical - 2.5", score: 2.5, expected: "Critical"}, + {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"}, } @@ -294,8 +294,8 @@ func TestCalculateScore(t *testing.T) { MessageID: "", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 9.0, - maxScore: 10.0, + minScore: 90.0, + maxScore: 100.0, expectedRating: "Excellent", }, { @@ -330,7 +330,7 @@ func TestCalculateScore(t *testing.T) { }), }, minScore: 0.0, - maxScore: 5.0, + maxScore: 50.0, expectedRating: "Poor", }, { @@ -366,8 +366,8 @@ func TestCalculateScore(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, - minScore: 6.0, - maxScore: 9.0, + minScore: 60.0, + maxScore: 90.0, expectedRating: "Good", }, } @@ -399,8 +399,8 @@ func TestCalculateScore(t *testing.T) { } // Verify score is within bounds - if result.OverallScore < 0.0 || result.OverallScore > 10.0 { - t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore) + if result.OverallScore < 0.0 || result.OverallScore > 100.0 { + t.Errorf("OverallScore %v is out of bounds [0.0, 100.0]", result.OverallScore) } // Verify category breakdown exists @@ -535,7 +535,7 @@ func TestGenerateRequiredHeadersCheck(t *testing.T) { Date: "Mon, 01 Jan 2024 12:00:00 +0000", }, expectedStatus: api.CheckStatusPass, - expectedScore: 0.4, + expectedScore: 4.0, }, { name: "Missing all required headers", diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index b1b0e4e..2a3ff60 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -174,12 +174,12 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass } } -// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points) +// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-20 points) // Scoring: -// - Score <= 0: 2 points (excellent) -// - Score < required: 1.5 points (good) -// - Score slightly above required (< 2x): 1 point (borderline) -// - Score moderately high (< 3x required): 0.5 points (poor) +// - 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 { if result == nil { @@ -194,17 +194,17 @@ func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) // Calculate deliverability score if score <= 0 { - return 2.0 + return 20.0 } else if score < required { - // Linear scaling from 1.5 to 2.0 based on how negative/low the score is + // Linear scaling from 15 to 20 based on how negative/low the score is ratio := score / required - return 1.5 + (0.5 * (1.0 - float32(ratio))) + return 15.0 + (5.0 * (1.0 - float32(ratio))) } else if score < required*2 { // Slightly above threshold - return 1.0 + return 10.0 } else if score < required*3 { // Moderately high - return 0.5 + return 5.0 } // Very high spam score @@ -221,6 +221,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe Name: "SpamAssassin Analysis", Status: api.CheckStatusWarn, Score: 0.0, + Grade: ScoreToCheckGrade(0.0), Message: "No SpamAssassin headers found", Severity: api.PtrTo(api.CheckSeverityMedium), Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"), @@ -260,6 +261,7 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) delivScore := a.GetSpamAssassinScore(result) check.Score = delivScore + check.Grade = ScoreToCheckGrade((delivScore / 20.0) * 100) // Determine status and message based on score if score <= 0 { @@ -318,6 +320,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.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 @@ -325,6 +328,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec // Positive indicator (decreases spam score) check.Status = api.CheckStatusPass check.Score = 1.0 + check.Grade = ScoreToCheckGrade((1.0 / 20.0) * 100) check.Severity = api.PtrTo(api.CheckSeverityInfo) check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score) advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score) diff --git a/pkg/analyzer/spamassassin_test.go b/pkg/analyzer/spamassassin_test.go index e7491db..deed1c7 100644 --- a/pkg/analyzer/spamassassin_test.go +++ b/pkg/analyzer/spamassassin_test.go @@ -169,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: -2.5, RequiredScore: 5.0, }, - expectedScore: 2.0, + expectedScore: 20.0, }, { name: "Good score (below threshold)", @@ -177,8 +177,8 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 2.0, RequiredScore: 5.0, }, - minScore: 1.5, - maxScore: 2.0, + minScore: 15.0, + maxScore: 20.0, }, { name: "Borderline (just above threshold)", @@ -186,7 +186,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 6.0, RequiredScore: 5.0, }, - expectedScore: 1.0, + expectedScore: 10.0, }, { name: "High spam score", @@ -194,7 +194,7 @@ func TestGetSpamAssassinScore(t *testing.T) { Score: 12.0, RequiredScore: 5.0, }, - expectedScore: 0.5, + expectedScore: 5.0, }, { name: "Very high spam score", @@ -618,7 +618,7 @@ func TestAnalyzeRealEmailExample(t *testing.T) { // Test GetSpamAssassinScore score := analyzer.GetSpamAssassinScore(result) - if score != 2.0 { + if score != 20.0 { t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score) } @@ -639,8 +639,8 @@ 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 != 2.0 { - t.Errorf("Main check score = %v, want 2.0", mainCheck.Score) + if mainCheck.Score != 20.0 { + t.Errorf("Main check score = %v, want 20.0", mainCheck.Score) } // Log all checks for debugging diff --git a/web/src/lib/components/CheckCard.svelte b/web/src/lib/components/CheckCard.svelte index bc5741c..abd200f 100644 --- a/web/src/lib/components/CheckCard.svelte +++ b/web/src/lib/components/CheckCard.svelte @@ -31,10 +31,7 @@
-
-
{check.name}
- {check.category} -
+
{check.name}
{check.score.toFixed(1)} pts
diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 47edccc..7672fa8 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -14,6 +14,20 @@ let nextfetch = $state(23); let nbfetch = $state(0); + // Group checks by category + let groupedChecks = $derived(() => { + if (!report) return {}; + + const groups: Record = {}; + for (const check of report.checks) { + if (!groups[check.category]) { + groups[check.category] = []; + } + groups[check.category].push(check); + } + return groups; + }); + async function fetchTest() { if (nbfetch > 0) { nextfetch = Math.max(nextfetch, Math.floor(3 + nbfetch * 0.5)); @@ -70,6 +84,56 @@ onDestroy(() => { stopPolling(); }); + + function getCategoryIcon(category: string): string { + switch (category) { + case "authentication": + return "bi-shield-check"; + case "dns": + return "bi-diagram-3"; + case "content": + return "bi-file-text"; + case "blacklist": + return "bi-shield-exclamation"; + case "headers": + return "bi-list-ul"; + case "spam": + return "bi-filter"; + default: + return "bi-question-circle"; + } + } + + function getCategoryScore(checks: typeof report.checks): number { + return checks.reduce((sum, check) => sum + check.score, 0); + } + + 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; + if (percentage >= 80) return "text-success"; + if (percentage >= 50) return "text-warning"; + return "text-danger"; + } @@ -114,8 +178,23 @@

Detailed Checks

- {#each report.checks as check} - + {#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 + +

+ {#each checks as check} + + {/each} +
{/each}
@@ -157,4 +236,21 @@ transform: translateY(0); } } + + .category-section { + margin-bottom: 2rem; + } + + .category-title { + font-size: 1.25rem; + font-weight: 600; + color: #495057; + padding-bottom: 0.5rem; + border-bottom: 2px solid #e9ecef; + } + + .category-score { + font-size: 1rem; + font-weight: 700; + }