diff --git a/api/openapi.yaml b/api/openapi.yaml index 4f9a39d..a7da36f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -297,11 +297,17 @@ components: type: object required: - dns_score + - dns_grade - authentication_score + - authentication_grade - spam_score + - spam_grade - blacklist_score - - content_score + - blacklist_grade - header_score + - header_grade + - content_score + - content_grade properties: dns_score: type: integer @@ -309,36 +315,66 @@ components: maximum: 100 description: DNS records score (in percentage) example: 42 + dns_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" authentication_score: type: integer minimum: 0 maximum: 100 description: SPF/DKIM/DMARC score (in percentage) example: 28 + authentication_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" spam_score: type: integer minimum: 0 maximum: 100 description: SpamAssassin score (in percentage) example: 15 + spam_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" blacklist_score: type: integer minimum: 0 maximum: 100 description: Blacklist check score (in percentage) example: 20 - content_score: - type: integer - minimum: 0 - maximum: 100 - description: Content quality score (in percentage) - example: 18 + blacklist_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" header_score: type: integer minimum: 0 maximum: 100 description: Header quality score (in percentage) example: 9 + header_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" + content_score: + type: integer + minimum: 0 + maximum: 100 + description: Content quality score (in percentage) + example: 18 + content_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" ContentAnalysis: type: object diff --git a/pkg/analyzer/authentication.go b/pkg/analyzer/authentication.go index 14333bd..ef0c400 100644 --- a/pkg/analyzer/authentication.go +++ b/pkg/analyzer/authentication.go @@ -463,9 +463,9 @@ func textprotoCanonical(s string) string { // CalculateAuthenticationScore calculates the authentication score from auth results // Returns a score from 0-100 where higher is better -func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) int { +func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) (int, string) { if results == nil { - return 0 + return 0, "" } score := 0 @@ -530,5 +530,5 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe score = 100 } - return score + return score, ScoreToGrade(score) } diff --git a/pkg/analyzer/authentication_test.go b/pkg/analyzer/authentication_test.go index e7f1e06..d9cb1a4 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -322,7 +322,7 @@ func TestGetAuthenticationScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := scorer.CalculateAuthenticationScore(tt.results) + score, _ := scorer.CalculateAuthenticationScore(tt.results) if score != tt.expectedScore { t.Errorf("Score = %v, want %v", score, tt.expectedScore) diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 74f6b2a..27eea4b 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -726,9 +726,9 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api. } // CalculateContentScore calculates the content score (0-20 points) -func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) int { +func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) (int, string) { if results == nil { - return 0 + return 0, "" } var score int = 10 @@ -819,5 +819,5 @@ func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) int { score = 100 } - return score + return score, ScoreToGrade(score) } diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 42ce6b4..2a7828c 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -444,9 +444,9 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool { // CalculateDNSScore calculates the DNS score from records results // Returns a score from 0-100 where higher is better -func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) int { +func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) (int, string) { if results == nil { - return 0 + return 0, "" } score := 0 @@ -525,7 +525,7 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) int { // BIMI is optional but indicates advanced email branding if results.BimiRecord != nil && results.BimiRecord.Valid { if score >= 100 { - return 100 + return 100, "A+" } } @@ -539,5 +539,5 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) int { score = 0 } - return score + return score, ScoreToGrade(score) } diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 1dd0302..1fc18dd 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -37,12 +37,13 @@ func NewHeaderAnalyzer() *HeaderAnalyzer { } // CalculateHeaderScore evaluates email structural quality from header analysis -func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int { +func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) (int, rune) { if analysis == nil || analysis.Headers == nil { - return 0 + return 0, ' ' } score := 0 + maxGrade := 6 headers := *analysis.Headers // Check required headers (RFC 5322) - 40 points @@ -60,6 +61,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int score += 40 } else { score += int(40 * (float32(presentRequired) / float32(requiredCount))) + maxGrade = 1 } // Check recommended headers (30 points) @@ -80,9 +82,15 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int } score += presentRecommended * 30 / recommendedCount + if presentRecommended < recommendedCount { + maxGrade -= 1 + } + // Check for proper MIME structure (20 points) if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure { score += 20 + } else { + maxGrade -= 1 } // Check Message-ID format (10 points) @@ -90,15 +98,20 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int // If Valid is set and true, award points if check.Valid != nil && *check.Valid { score += 10 + } else { + maxGrade -= 1 } + } else { + maxGrade -= 1 } // Ensure score doesn't exceed 100 if score > 100 { score = 100 } + grade := 'A' + max(6-maxGrade, 0) - return score + return score, rune(grade) } // isValidMessageID checks if a Message-ID has proper format diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 418b553..6840b0f 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -109,7 +109,7 @@ func TestCalculateHeaderScore(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Generate header analysis first analysis := analyzer.GenerateHeaderAnalysis(tt.email) - score := analyzer.CalculateHeaderScore(analysis) + score, _ := analyzer.CalculateHeaderScore(analysis) if score < tt.minScore || score > tt.maxScore { t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) } diff --git a/pkg/analyzer/rbl.go b/pkg/analyzer/rbl.go index aa35281..832c61c 100644 --- a/pkg/analyzer/rbl.go +++ b/pkg/analyzer/rbl.go @@ -240,13 +240,14 @@ func (r *RBLChecker) reverseIP(ipStr string) string { } // CalculateRBLScore calculates the blacklist contribution to deliverability -func (r *RBLChecker) CalculateRBLScore(results *RBLResults) int { +func (r *RBLChecker) CalculateRBLScore(results *RBLResults) (int, string) { if results == nil || len(results.IPsChecked) == 0 { // No IPs to check, give benefit of doubt - return 100 + return 100, "" } - return 100 - results.ListedCount*100/len(r.RBLs) + percentage := 100 - results.ListedCount*100/len(r.RBLs) + return percentage, ScoreToGrade(percentage) } // GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL diff --git a/pkg/analyzer/rbl_test.go b/pkg/analyzer/rbl_test.go index 33c4ed5..89608cc 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -326,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - score := checker.CalculateRBLScore(tt.results) + score, _ := checker.CalculateRBLScore(tt.results) if score != tt.expectedScore { t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) } diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index b5a8f16..b5bab60 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -96,42 +96,54 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu // Calculate scores directly from analyzers (no more checks array) dnsScore := 0 + var dnsGrade string if results.DNS != nil { - dnsScore = r.dnsAnalyzer.CalculateDNSScore(results.DNS) + dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS) } authScore := 0 + var authGrade string if results.Authentication != nil { - authScore = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication) + authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication) } contentScore := 0 + var contentGrade string if results.Content != nil { - contentScore = r.contentAnalyzer.CalculateContentScore(results.Content) + contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content) } headerScore := 0 + var headerGrade rune if results.Headers != nil { - headerScore = r.headerAnalyzer.CalculateHeaderScore(results.Headers) + headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers) } blacklistScore := 0 + var blacklistGrade string if results.RBL != nil { - blacklistScore = r.rblChecker.CalculateRBLScore(results.RBL) + blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL) } spamScore := 0 + var spamGrade string if results.SpamAssassin != nil { - spamScore = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) + spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) } report.Summary = &api.ScoreSummary{ DnsScore: dnsScore, + DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade), AuthenticationScore: authScore, + AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade), BlacklistScore: blacklistScore, + BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade), ContentScore: contentScore, + ContentGrade: api.ScoreSummaryContentGrade(contentGrade), HeaderScore: headerScore, + HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade), SpamScore: spamScore, + SpamGrade: api.ScoreSummarySpamGrade(spamGrade), } // Add authentication results @@ -187,6 +199,41 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu } report.Grade = ScoreToReportGrade(report.Score) + categoryGrades := []string{ + string(report.Summary.DnsGrade), + string(report.Summary.AuthenticationGrade), + string(report.Summary.BlacklistGrade), + string(report.Summary.ContentGrade), + string(report.Summary.HeaderGrade), + string(report.Summary.SpamGrade), + } + if report.Score >= 100 { + hasLessThanA := false + + for _, grade := range categoryGrades { + if len(grade) < 1 || grade[0] != 'A' { + hasLessThanA = true + } + } + + if !hasLessThanA { + report.Grade = "A+" + } + } else { + var minusGrade byte = 0 + for _, grade := range categoryGrades { + if len(grade) == 0 { + minusGrade = 255 + break + } else if grade[0]-'A' > minusGrade { + minusGrade = grade[0] - 'A' + } + } + + if minusGrade < 255 { + report.Grade = api.ReportGrade(string([]byte{'A' + minusGrade})) + } + } return report } diff --git a/pkg/analyzer/scoring.go b/pkg/analyzer/scoring.go index 4686cc4..84756a7 100644 --- a/pkg/analyzer/scoring.go +++ b/pkg/analyzer/scoring.go @@ -28,9 +28,9 @@ import ( // ScoreToGrade converts a percentage score (0-100) to a letter grade func ScoreToGrade(score int) string { switch { - case score >= 97: + case score > 100: return "A+" - case score >= 93: + case score > 95: return "A" case score >= 85: return "B" diff --git a/pkg/analyzer/spamassassin.go b/pkg/analyzer/spamassassin.go index 5c8d187..978c7a6 100644 --- a/pkg/analyzer/spamassassin.go +++ b/pkg/analyzer/spamassassin.go @@ -193,9 +193,9 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs } // CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability -func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) int { +func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssassinResult) (int, string) { if result == nil { - return 100 // No spam scan results, assume good + return 100, "" // No spam scan results, assume good } // SpamAssassin score typically ranges from -10 to +20 @@ -206,12 +206,15 @@ func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssass score := result.Score // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better) - if score <= 0 { - return 100 // Perfect score for ham + if score < 0 { + return 100, "A+" // Perfect score for ham + } else if score == 0 { + return 100, "A" // Perfect score for ham } else if score >= result.RequiredScore { - return 0 // Failed spam test + return 0, "F" // Failed spam test } else { // Linear scale between 0 and required threshold - return 100 - int(math.Round(float64(score*100/result.RequiredScore))) + percentage := 100 - int(math.Round(float64(score*100/result.RequiredScore))) + return percentage, ScoreToGrade(percentage - 15) } } diff --git a/web/src/lib/components/AuthenticationCard.svelte b/web/src/lib/components/AuthenticationCard.svelte index 0ac750a..13ae525 100644 --- a/web/src/lib/components/AuthenticationCard.svelte +++ b/web/src/lib/components/AuthenticationCard.svelte @@ -1,13 +1,16 @@