From 900c951f8f0baecfc4f0c25cd62e1d039577e75f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 15 Oct 2025 15:49:53 +0700 Subject: [PATCH] Implement deliverability scoring algorithm with weighted factors --- internal/analyzer/scoring.go | 506 ++++++++++++++++++++ internal/analyzer/scoring_test.go | 762 ++++++++++++++++++++++++++++++ 2 files changed, 1268 insertions(+) create mode 100644 internal/analyzer/scoring.go create mode 100644 internal/analyzer/scoring_test.go diff --git a/internal/analyzer/scoring.go b/internal/analyzer/scoring.go new file mode 100644 index 0000000..07f6a34 --- /dev/null +++ b/internal/analyzer/scoring.go @@ -0,0 +1,506 @@ +// 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" + "time" + + "git.happydns.org/happyDeliver/internal/api" +) + +// DeliverabilityScorer aggregates all analysis results and computes overall score +type DeliverabilityScorer struct{} + +// NewDeliverabilityScorer creates a new deliverability scorer +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 + authAnalyzer := NewAuthenticationAnalyzer() + result.AuthScore = authAnalyzer.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 10) + 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 < 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: 3.0, + Percentage: (result.AuthScore / 3.0) * 100, + Status: s.getCategoryStatus(result.AuthScore, 3.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), + } + + result.CategoryBreakdown["Blacklists"] = CategoryScore{ + Score: result.BlacklistScore, + MaxScore: 2.0, + Percentage: (result.BlacklistScore / 2.0) * 100, + Status: s.getCategoryStatus(result.BlacklistScore, 2.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), + } + + result.CategoryBreakdown["Email Structure"] = CategoryScore{ + Score: result.HeaderScore, + MaxScore: 1.0, + Percentage: (result.HeaderScore / 1.0) * 100, + Status: s.getCategoryStatus(result.HeaderScore, 1.0), + } + + // Generate recommendations + result.Recommendations = s.generateRecommendations(result) + + return result +} + +// calculateHeaderScore evaluates email structural quality (0-1 point) +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 (0.4 points) + if presentHeaders == requiredHeaders { + score += 0.4 + } else { + score += 0.4 * (float32(presentHeaders) / float32(requiredHeaders)) + } + + // Check recommended headers (0.3 points) + recommendedHeaders := []string{"Subject", "To", "Reply-To"} + recommendedPresent := 0 + for _, header := range recommendedHeaders { + if email.HasHeader(header) && email.GetHeaderValue(header) != "" { + recommendedPresent++ + } + } + score += 0.3 * (float32(recommendedPresent) / float32(len(recommendedHeaders))) + + // Check for proper MIME structure (0.2 points) + if len(email.Parts) > 0 { + score += 0.2 + } + + // Check Message-ID format (0.1 points) + if messageID := email.GetHeaderValue("Message-ID"); messageID != "" { + if s.isValidMessageID(messageID) { + score += 0.1 + } + } + + // Ensure score doesn't exceed 1.0 + if score > 1.0 { + score = 1.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 +func (s *DeliverabilityScorer) determineRating(score float32) string { + switch { + case score >= 9.0: + return "Excellent" + case score >= 7.0: + return "Good" + case score >= 5.0: + return "Fair" + case score >= 3.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 + if result.AuthScore < 2.0 { + recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records") + } else if result.AuthScore < 3.0 { + recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability") + } + + // Spam recommendations + if result.SpamScore < 1.0 { + recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns") + } else if result.SpamScore < 1.5 { + recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues") + } + + // Blacklist recommendations + if result.BlacklistScore < 1.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 { + recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices") + } + + // Content recommendations + if result.ContentScore < 1.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 { + recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency") + } + + // Header recommendations + if result.HeaderScore < 0.5 { + recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)") + } else if result.HeaderScore < 1.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 = 0.4 + check.Severity = api.PtrTo(api.Info) + check.Message = "All required headers are present" + check.Advice = api.PtrTo("Your email has proper RFC 5322 headers") + } else { + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Severity = api.PtrTo(api.Critical) + check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Add all required headers to ensure email deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + 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 = 0.3 + check.Severity = api.PtrTo(api.Info) + check.Message = "All recommended headers are present" + check.Advice = api.PtrTo("Your email includes all recommended headers") + } else if len(missing) < len(recommendedHeaders) { + check.Status = api.CheckStatusWarn + check.Score = 0.15 + check.Severity = api.PtrTo(api.Low) + check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", ")) + check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability") + details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", ")) + check.Details = &details + } else { + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Severity = api.PtrTo(api.Medium) + check.Message = "Missing all recommended headers" + check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation") + } + + 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.Severity = api.PtrTo(api.High) + check.Message = "Message-ID header is missing" + check.Advice = api.PtrTo("Add a unique Message-ID header to your email") + } else if !s.isValidMessageID(messageID) { + check.Status = api.CheckStatusWarn + check.Score = 0.05 + check.Severity = api.PtrTo(api.Medium) + check.Message = "Message-ID format is invalid" + check.Advice = api.PtrTo("Use proper Message-ID format: ") + check.Details = &messageID + } else { + check.Status = api.CheckStatusPass + check.Score = 0.1 + check.Severity = api.PtrTo(api.Info) + 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.Severity = api.PtrTo(api.Low) + check.Message = "No MIME parts detected" + check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility") + } else { + check.Status = api.CheckStatusPass + check.Score = 0.2 + check.Severity = api.PtrTo(api.Info) + check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts)) + check.Advice = api.PtrTo("Your email has proper MIME structure") + + // 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/10 (%s)\n\n", result.OverallScore, result.Rating)) + summary.WriteString("Category Breakdown:\n") + summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/3.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", + result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status)) + summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/2.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", + result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status)) + summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/1.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() +} diff --git a/internal/analyzer/scoring_test.go b/internal/analyzer/scoring_test.go new file mode 100644 index 0000000..b28182d --- /dev/null +++ b/internal/analyzer/scoring_test.go @@ -0,0 +1,762 @@ +// 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" + "strings" + "testing" + + "git.happydns.org/happyDeliver/internal/api" +) + +func TestNewDeliverabilityScorer(t *testing.T) { + scorer := NewDeliverabilityScorer() + if scorer == nil { + t.Fatal("Expected scorer, got nil") + } +} + +func TestIsValidMessageID(t *testing.T) { + tests := []struct { + name string + messageID string + expected bool + }{ + { + name: "Valid Message-ID", + messageID: "", + expected: true, + }, + { + name: "Valid with UUID", + messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>", + expected: true, + }, + { + name: "Missing angle brackets", + messageID: "abc123@example.com", + expected: false, + }, + { + name: "Missing @ symbol", + messageID: "", + expected: false, + }, + { + name: "Multiple @ symbols", + messageID: "", + expected: false, + }, + { + name: "Empty local part", + messageID: "<@example.com>", + expected: false, + }, + { + name: "Empty domain part", + messageID: "", + expected: false, + }, + { + name: "Empty", + messageID: "", + expected: false, + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scorer.isValidMessageID(tt.messageID) + if result != tt.expected { + t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected) + } + }) + } +} + +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: 0.7, + maxScore: 1.0, + }, + { + name: "Missing required headers", + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "Subject": "Test", + }), + }, + minScore: 0.0, + maxScore: 0.4, + }, + { + 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: 0.4, + maxScore: 0.8, + }, + { + 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: 0.7, + maxScore: 1.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: 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: "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 + authResults *api.AuthenticationResults + spamResult *SpamAssassinResult + rblResults *RBLResults + contentResults *ContentResults + email *EmailMessage + minScore float32 + maxScore float32 + expectedRating string + }{ + { + name: "Perfect email", + authResults: &api.AuthenticationResults{ + Spf: &api.AuthResult{Result: api.AuthResultResultPass}, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: &api.AuthResult{Result: api.AuthResultResultPass}, + }, + spamResult: &SpamAssassinResult{ + Score: -1.0, + RequiredScore: 5.0, + }, + rblResults: &RBLResults{ + Checks: []RBLCheck{ + {IP: "192.0.2.1", Listed: false}, + }, + }, + contentResults: &ContentResults{ + HTMLValid: true, + Links: []LinkCheck{{Valid: true, Status: 200}}, + Images: []ImageCheck{{HasAlt: true}}, + HasUnsubscribe: true, + TextPlainRatio: 0.8, + ImageTextRatio: 3.0, + }, + 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: "", + Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, + }, + minScore: 9.0, + maxScore: 10.0, + expectedRating: "Excellent", + }, + { + name: "Poor email - auth issues", + authResults: &api.AuthenticationResults{ + Spf: &api.AuthResult{Result: api.AuthResultResultFail}, + Dkim: &[]api.AuthResult{}, + Dmarc: nil, + }, + spamResult: &SpamAssassinResult{ + Score: 8.0, + RequiredScore: 5.0, + }, + rblResults: &RBLResults{ + Checks: []RBLCheck{ + { + IP: "192.0.2.1", + RBL: "zen.spamhaus.org", + Listed: true, + }, + }, + ListedCount: 1, + }, + contentResults: &ContentResults{ + HTMLValid: false, + Links: []LinkCheck{{Valid: true, Status: 404}}, + HasUnsubscribe: false, + }, + email: &EmailMessage{ + Header: createHeaderWithFields(map[string]string{ + "From": "sender@example.com", + }), + }, + minScore: 0.0, + maxScore: 5.0, + expectedRating: "Poor", + }, + { + name: "Average email", + authResults: &api.AuthenticationResults{ + Spf: &api.AuthResult{Result: api.AuthResultResultPass}, + Dkim: &[]api.AuthResult{ + {Result: api.AuthResultResultPass}, + }, + Dmarc: nil, + }, + spamResult: &SpamAssassinResult{ + Score: 4.0, + RequiredScore: 5.0, + }, + rblResults: &RBLResults{ + Checks: []RBLCheck{ + {IP: "192.0.2.1", Listed: false}, + }, + }, + contentResults: &ContentResults{ + HTMLValid: true, + Links: []LinkCheck{{Valid: true, Status: 200}}, + HasUnsubscribe: false, + }, + 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: 6.0, + maxScore: 9.0, + expectedRating: "Good", + }, + } + + scorer := NewDeliverabilityScorer() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scorer.CalculateScore( + tt.authResults, + tt.spamResult, + tt.rblResults, + tt.contentResults, + tt.email, + ) + + if result == nil { + t.Fatal("Expected result, got nil") + } + + // Check overall score + if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore { + t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore) + } + + // Check rating + if result.Rating != tt.expectedRating { + t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating) + } + + // 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) + } + + // Verify category breakdown exists + if len(result.CategoryBreakdown) != 5 { + t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown)) + } + + // Verify recommendations exist + if len(result.Recommendations) == 0 && result.Rating != "Excellent" { + 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)", + 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: 0.4, + }, + { + 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 +}