Use grade instead of numeral notation
This commit is contained in:
parent
e3d89dc953
commit
0ac51ac06d
14 changed files with 355 additions and 202 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue