From a64b866cfaee8a49341f3f6de04badba45cfc225 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 22 Oct 2025 15:39:40 +0700 Subject: [PATCH] Add grades --- README.md | 26 +++---- api/openapi.yaml | 50 +++++++++++-- internal/receiver/receiver.go | 2 +- pkg/analyzer/authentication.go | 6 +- pkg/analyzer/authentication_test.go | 68 ++++++++--------- pkg/analyzer/content.go | 6 +- pkg/analyzer/dns.go | 8 +- pkg/analyzer/headers.go | 19 ++++- pkg/analyzer/headers_test.go | 2 +- pkg/analyzer/rbl.go | 7 +- pkg/analyzer/rbl_test.go | 16 ++-- pkg/analyzer/report.go | 59 +++++++++++++-- pkg/analyzer/report_test.go | 15 ++-- pkg/analyzer/scoring.go | 4 +- pkg/analyzer/spamassassin.go | 15 ++-- .../lib/components/AuthenticationCard.svelte | 20 +++-- web/src/lib/components/BlacklistCard.svelte | 20 +++-- .../lib/components/ContentAnalysisCard.svelte | 20 +++-- web/src/lib/components/DnsRecordsCard.svelte | 20 +++-- web/src/lib/components/GradeDisplay.svelte | 61 +++++++++++++++ .../lib/components/HeaderAnalysisCard.svelte | 20 +++-- web/src/lib/components/ScoreCard.svelte | 74 +++---------------- .../lib/components/SpamAssassinCard.svelte | 20 +++-- web/src/lib/score.ts | 5 ++ web/src/routes/test/[test]/+page.svelte | 12 +-- 25 files changed, 365 insertions(+), 210 deletions(-) create mode 100644 web/src/lib/components/GradeDisplay.svelte create mode 100644 web/src/lib/score.ts diff --git a/README.md b/README.md index fe03381..e40a791 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ An open-source email deliverability testing platform that analyzes test emails a ## Features -- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more +- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, ARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more - **REST API**: Full-featured API for creating tests and retrieving reports - **LMTP Server**: Built-in LMTP server for seamless MTA integration -- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers +- **Scoring System**: Gives A to F grades and scoring with weighted factors across dns, authentication, spam, blacklists, content, and headers - **Database Storage**: SQLite or PostgreSQL support - **Configurable**: via environment or config file for all settings @@ -187,22 +187,14 @@ cat email.eml | ./happyDeliver analyze -recipient test-uuid@yourdomain.com ## Scoring System -The deliverability score is calculated from 0 to 10 based on: +The deliverability score is calculated from A to F based on: -- **Authentication (3 pts)**: SPF, DKIM, DMARC validation -- **Spam (2 pts)**: SpamAssassin score -- **Blacklist (2 pts)**: RBL/DNSBL checks -- **Content (2 pts)**: HTML quality, links, images, unsubscribe -- **Headers (1 pt)**: Required headers, MIME structure - -**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor. - -**Ratings:** -- 9-10: Excellent -- 7-8.9: Good -- 5-6.9: Fair -- 3-4.9: Poor -- 0-2.9: Critical +- **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records +- **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation +- **Blacklist**: RBL/DNSBL checks +- **Headers**: Required headers, MIME structure, Domain alignment +- **Spam**: SpamAssassin score +- **Content**: HTML quality, links, images, unsubscribe ## Funding diff --git a/api/openapi.yaml b/api/openapi.yaml index aed3de4..9e33d64 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/internal/receiver/receiver.go b/internal/receiver/receiver.go index fb8d36e..062a091 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -96,7 +96,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string return fmt.Errorf("failed to analyze email: %w", err) } - log.Printf("Analysis complete. Score: %.2f/10", result.Report.Score) + log.Printf("Analysis complete. Grade: %s. Score: %d/100", result.Report.Grade, result.Report.Score) // Marshal report to JSON reportJSON, err := json.Marshal(result.Report) 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 e7176db..d0c4f18 100644 --- a/pkg/analyzer/authentication_test.go +++ b/pkg/analyzer/authentication_test.go @@ -323,7 +323,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) @@ -370,16 +370,16 @@ func TestParseARCResult(t *testing.T) { func TestParseAuthenticationResultsHeader(t *testing.T) { tests := []struct { - name string - header string - expectedSPFResult *api.AuthResultResult - expectedSPFDomain *string - expectedDKIMCount int - expectedDKIMResult *api.AuthResultResult - expectedDMARCResult *api.AuthResultResult - expectedDMARCDomain *string - expectedBIMIResult *api.AuthResultResult - expectedARCResult *api.ARCResultResult + name string + header string + expectedSPFResult *api.AuthResultResult + expectedSPFDomain *string + expectedDKIMCount int + expectedDKIMResult *api.AuthResultResult + expectedDMARCResult *api.AuthResultResult + expectedDMARCDomain *string + expectedBIMIResult *api.AuthResultResult + expectedARCResult *api.ARCResultResult }{ { name: "Complete authentication results", @@ -441,12 +441,12 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { expectedDMARCDomain: api.PtrTo("example.com"), }, { - name: "BIMI pass", - header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), - expectedDKIMCount: 0, - expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), + name: "BIMI pass", + header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 0, + expectedBIMIResult: api.PtrTo(api.AuthResultResultPass), }, { name: "ARC pass", @@ -468,24 +468,24 @@ func TestParseAuthenticationResultsHeader(t *testing.T) { expectedARCResult: api.PtrTo(api.ARCResultResultPass), }, { - name: "Empty header (authserv-id only)", - header: "mx.google.com", + name: "Empty header (authserv-id only)", + header: "mx.google.com", + expectedSPFResult: nil, + expectedDKIMCount: 0, + }, + { + name: "Empty parts with semicolons", + header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", + expectedSPFResult: api.PtrTo(api.AuthResultResultPass), + expectedSPFDomain: api.PtrTo("example.com"), + expectedDKIMCount: 0, + }, + { + name: "DKIM with short form parameters", + header: "mail.example.com; dkim=pass d=example.com s=selector1", expectedSPFResult: nil, - expectedDKIMCount: 0, - }, - { - name: "Empty parts with semicolons", - header: "mx.google.com; ; ; spf=pass smtp.mailfrom=sender@example.com; ;", - expectedSPFResult: api.PtrTo(api.AuthResultResultPass), - expectedSPFDomain: api.PtrTo("example.com"), - expectedDKIMCount: 0, - }, - { - name: "DKIM with short form parameters", - header: "mail.example.com; dkim=pass d=example.com s=selector1", - expectedSPFResult: nil, - expectedDKIMCount: 1, - expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), + expectedDKIMCount: 1, + expectedDKIMResult: api.PtrTo(api.AuthResultResultPass), }, { name: "SPF neutral", 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..f18464a 100644 --- a/pkg/analyzer/rbl_test.go +++ b/pkg/analyzer/rbl_test.go @@ -271,14 +271,14 @@ func TestGetBlacklistScore(t *testing.T) { { name: "Nil results", results: nil, - expectedScore: 200, + expectedScore: 100, }, { name: "No IPs checked", results: &RBLResults{ IPsChecked: []string{}, }, - expectedScore: 200, + expectedScore: 100, }, { name: "Not listed on any RBL", @@ -286,7 +286,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 0, }, - expectedScore: 200, + expectedScore: 100, }, { name: "Listed on 1 RBL", @@ -294,7 +294,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 1, }, - expectedScore: 100, + expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16) }, { name: "Listed on 2 RBLs", @@ -302,7 +302,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 2, }, - expectedScore: 50, + expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33) }, { name: "Listed on 3 RBLs", @@ -310,7 +310,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 3, }, - expectedScore: 50, + expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50) }, { name: "Listed on 4+ RBLs", @@ -318,7 +318,7 @@ func TestGetBlacklistScore(t *testing.T) { IPsChecked: []string{"198.51.100.1"}, ListedCount: 4, }, - expectedScore: 0, + expectedScore: 34, // 100 - 4*100/6 = 34 (integer division: 400/6=66) }, } @@ -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/report_test.go b/pkg/analyzer/report_test.go index b3827bc..bf413ce 100644 --- a/pkg/analyzer/report_test.go +++ b/pkg/analyzer/report_test.go @@ -106,23 +106,26 @@ func TestGenerateReport(t *testing.T) { t.Error("Summary should not be nil") } - // Verify score summary + // Verify score summary (all scores are 0-100 percentages) if report.Summary != nil { - if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 { + if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 100 { t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore) } - if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 { + if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 100 { t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore) } - if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 20 { + if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 100 { t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore) } - if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 20 { + if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 100 { t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore) } - if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 10 { + if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 100 { t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore) } + if report.Summary.DnsScore < 0 || report.Summary.DnsScore > 100 { + t.Errorf("DnsScore %v is out of bounds", report.Summary.DnsScore) + } } } 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 5e6314b..6ab41cc 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 @@
@@ -16,11 +19,16 @@ Blacklist Checks - {#if blacklistScore !== undefined} - - {blacklistScore}% - - {/if} + + {#if blacklistScore !== undefined} + + {blacklistScore}% + + {/if} + {#if blacklistGrade !== undefined} + + {/if} +
diff --git a/web/src/lib/components/ContentAnalysisCard.svelte b/web/src/lib/components/ContentAnalysisCard.svelte index 6ee4cbb..3b7bc95 100644 --- a/web/src/lib/components/ContentAnalysisCard.svelte +++ b/web/src/lib/components/ContentAnalysisCard.svelte @@ -1,12 +1,15 @@
@@ -16,11 +19,16 @@ Content Analysis - {#if contentScore !== undefined} - - {contentScore}% - - {/if} + + {#if contentScore !== undefined} + + {contentScore}% + + {/if} + {#if contentGrade !== undefined} + + {/if} +
diff --git a/web/src/lib/components/DnsRecordsCard.svelte b/web/src/lib/components/DnsRecordsCard.svelte index ac5e68f..08f9c87 100644 --- a/web/src/lib/components/DnsRecordsCard.svelte +++ b/web/src/lib/components/DnsRecordsCard.svelte @@ -1,12 +1,15 @@
@@ -16,11 +19,16 @@ DNS Records - {#if dnsScore !== undefined} - - {dnsScore}% - - {/if} + + {#if dnsScore !== undefined} + + {dnsScore}% + + {/if} + {#if dnsGrade !== undefined} + + {/if} +
diff --git a/web/src/lib/components/GradeDisplay.svelte b/web/src/lib/components/GradeDisplay.svelte new file mode 100644 index 0000000..322259b --- /dev/null +++ b/web/src/lib/components/GradeDisplay.svelte @@ -0,0 +1,61 @@ + + + + {#if grade} + {grade} + {:else} + {score}% + {/if} + + + diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 12a74e1..8dd074f 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -1,12 +1,15 @@
@@ -16,11 +19,16 @@ Header Analysis - {#if headerScore !== undefined} - - {headerScore}% - - {/if} + + {#if headerScore !== undefined} + + {headerScore}% + + {/if} + {#if headerGrade !== undefined} + + {/if} +
diff --git a/web/src/lib/components/ScoreCard.svelte b/web/src/lib/components/ScoreCard.svelte index 7555b8c..d360c31 100644 --- a/web/src/lib/components/ScoreCard.svelte +++ b/web/src/lib/components/ScoreCard.svelte @@ -1,5 +1,6 @@
@@ -16,11 +19,16 @@ SpamAssassin Analysis - {#if spamScore !== undefined} - - {spamScore}% - - {/if} + + {#if spamScore !== undefined} + + {spamScore}% + + {/if} + {#if spamGrade !== undefined} + + {/if} +
diff --git a/web/src/lib/score.ts b/web/src/lib/score.ts new file mode 100644 index 0000000..e9d9bae --- /dev/null +++ b/web/src/lib/score.ts @@ -0,0 +1,5 @@ +export function getScoreColorClass(percentage: number): string { + if (percentage >= 85) return "success"; + if (percentage >= 50) return "warning"; + return "danger"; +} diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index 4ce53c5..c80cd0b 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -81,12 +81,6 @@ stopPolling(); }); - function getScoreColorClass(percentage: number): string { - if (percentage >= 80) return "text-success"; - if (percentage >= 50) return "text-warning"; - return "text-danger"; - } - async function handleReanalyze() { if (!testId || reanalyzing) return; @@ -150,6 +144,7 @@
@@ -162,6 +157,7 @@
@@ -175,6 +171,7 @@
@@ -187,6 +184,7 @@
@@ -199,6 +197,7 @@
@@ -211,6 +210,7 @@