Use grade instead of numeral notation

This commit is contained in:
nemunaire 2025-10-20 19:25:48 +07:00
commit 0ac51ac06d
14 changed files with 355 additions and 202 deletions

View file

@ -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

View file

@ -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
},
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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,
}

View file

@ -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)
}
}

View file

@ -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: <unique-id@domain.com>")
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

View file

@ -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: "<abc123@example.com>",
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",

View file

@ -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)

View file

@ -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

View file

@ -31,10 +31,7 @@
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="fw-bold mb-1">{check.name}</h5>
<span class="badge bg-secondary text-capitalize">{check.category}</span>
</div>
<h5 class="fw-bold mb-1">{check.name}</h5>
<span class="badge bg-light text-dark">{check.score.toFixed(1)} pts</span>
</div>

View file

@ -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<string, typeof report.checks> = {};
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";
}
</script>
<svelte:head>
@ -114,8 +178,23 @@
<div class="row mb-4">
<div class="col-12">
<h3 class="fw-bold mb-3">Detailed Checks</h3>
{#each report.checks as check}
<CheckCard {check} />
{#each Object.entries(groupedChecks()) as [category, checks]}
{@const categoryScore = getCategoryScore(checks)}
{@const maxScore = getCategoryMaxScore(category)}
<div class="category-section mb-4">
<h4 class="category-title text-capitalize mb-3 d-flex justify-content-between align-items-center">
<span>
<i class="bi {getCategoryIcon(category)} me-2"></i>
{category}
</span>
<span class="category-score {getScoreColorClass(categoryScore, maxScore)}">
{categoryScore.toFixed(1)}{#if maxScore > 0} / {maxScore}{/if} pts
</span>
</h4>
{#each checks as check}
<CheckCard {check} />
{/each}
</div>
{/each}
</div>
</div>
@ -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;
}
</style>