Add grades

This commit is contained in:
nemunaire 2025-10-22 15:39:40 +07:00
commit a64b866cfa
25 changed files with 362 additions and 207 deletions

View file

@ -6,10 +6,10 @@ An open-source email deliverability testing platform that analyzes test emails a
## Features ## 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 - **REST API**: Full-featured API for creating tests and retrieving reports
- **LMTP Server**: Built-in LMTP server for seamless MTA integration - **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 - **Database Storage**: SQLite or PostgreSQL support
- **Configurable**: via environment or config file for all settings - **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 ## 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 - **DNS**: Step-by-step analysis of PTR, Forward-Confirmed Reverse DNS, MX, SPF, DKIM, DMARC and BIMI records
- **Spam (2 pts)**: SpamAssassin score - **Authentication**: IPRev, SPF, DKIM, DMARC, BIMI and ARC validation
- **Blacklist (2 pts)**: RBL/DNSBL checks - **Blacklist**: RBL/DNSBL checks
- **Content (2 pts)**: HTML quality, links, images, unsubscribe - **Headers**: Required headers, MIME structure, Domain alignment
- **Headers (1 pt)**: Required headers, MIME structure - **Spam**: SpamAssassin score
- **Content**: HTML quality, links, images, unsubscribe
**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
## Funding ## Funding

View file

@ -297,11 +297,17 @@ components:
type: object type: object
required: required:
- dns_score - dns_score
- dns_grade
- authentication_score - authentication_score
- authentication_grade
- spam_score - spam_score
- spam_grade
- blacklist_score - blacklist_score
- content_score - blacklist_grade
- header_score - header_score
- header_grade
- content_score
- content_grade
properties: properties:
dns_score: dns_score:
type: integer type: integer
@ -309,36 +315,66 @@ components:
maximum: 100 maximum: 100
description: DNS records score (in percentage) description: DNS records score (in percentage)
example: 42 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: authentication_score:
type: integer type: integer
minimum: 0 minimum: 0
maximum: 100 maximum: 100
description: SPF/DKIM/DMARC score (in percentage) description: SPF/DKIM/DMARC score (in percentage)
example: 28 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: spam_score:
type: integer type: integer
minimum: 0 minimum: 0
maximum: 100 maximum: 100
description: SpamAssassin score (in percentage) description: SpamAssassin score (in percentage)
example: 15 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: blacklist_score:
type: integer type: integer
minimum: 0 minimum: 0
maximum: 100 maximum: 100
description: Blacklist check score (in percentage) description: Blacklist check score (in percentage)
example: 20 example: 20
content_score: blacklist_grade:
type: integer type: string
minimum: 0 enum: [A+, A, B, C, D, E, F]
maximum: 100 description: Letter grade representation of the score (A+ is best, F is worst)
description: Content quality score (in percentage) example: "A"
example: 18
header_score: header_score:
type: integer type: integer
minimum: 0 minimum: 0
maximum: 100 maximum: 100
description: Header quality score (in percentage) description: Header quality score (in percentage)
example: 9 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: ContentAnalysis:
type: object type: object

View file

@ -96,7 +96,7 @@ func (r *EmailReceiver) ProcessEmailBytes(rawEmail []byte, recipientEmail string
return fmt.Errorf("failed to analyze email: %w", err) 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 // Marshal report to JSON
reportJSON, err := json.Marshal(result.Report) reportJSON, err := json.Marshal(result.Report)

View file

@ -463,9 +463,9 @@ func textprotoCanonical(s string) string {
// CalculateAuthenticationScore calculates the authentication score from auth results // CalculateAuthenticationScore calculates the authentication score from auth results
// Returns a score from 0-100 where higher is better // 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 { if results == nil {
return 0 return 0, ""
} }
score := 0 score := 0
@ -530,5 +530,5 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.Authe
score = 100 score = 100
} }
return score return score, ScoreToGrade(score)
} }

View file

@ -323,7 +323,7 @@ func TestGetAuthenticationScore(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
score := scorer.CalculateAuthenticationScore(tt.results) score, _ := scorer.CalculateAuthenticationScore(tt.results)
if score != tt.expectedScore { if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore) t.Errorf("Score = %v, want %v", score, tt.expectedScore)

View file

@ -726,9 +726,9 @@ func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.
} }
// CalculateContentScore calculates the content score (0-20 points) // 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 { if results == nil {
return 0 return 0, ""
} }
var score int = 10 var score int = 10
@ -819,5 +819,5 @@ func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) int {
score = 100 score = 100
} }
return score return score, ScoreToGrade(score)
} }

View file

@ -444,9 +444,9 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool {
// CalculateDNSScore calculates the DNS score from records results // CalculateDNSScore calculates the DNS score from records results
// Returns a score from 0-100 where higher is better // 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 { if results == nil {
return 0 return 0, ""
} }
score := 0 score := 0
@ -525,7 +525,7 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) int {
// BIMI is optional but indicates advanced email branding // BIMI is optional but indicates advanced email branding
if results.BimiRecord != nil && results.BimiRecord.Valid { if results.BimiRecord != nil && results.BimiRecord.Valid {
if score >= 100 { if score >= 100 {
return 100 return 100, "A+"
} }
} }
@ -539,5 +539,5 @@ func (d *DNSAnalyzer) CalculateDNSScore(results *api.DNSResults) int {
score = 0 score = 0
} }
return score return score, ScoreToGrade(score)
} }

View file

@ -37,12 +37,13 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
} }
// CalculateHeaderScore evaluates email structural quality from header analysis // 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 { if analysis == nil || analysis.Headers == nil {
return 0 return 0, ' '
} }
score := 0 score := 0
maxGrade := 6
headers := *analysis.Headers headers := *analysis.Headers
// Check required headers (RFC 5322) - 40 points // Check required headers (RFC 5322) - 40 points
@ -60,6 +61,7 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int
score += 40 score += 40
} else { } else {
score += int(40 * (float32(presentRequired) / float32(requiredCount))) score += int(40 * (float32(presentRequired) / float32(requiredCount)))
maxGrade = 1
} }
// Check recommended headers (30 points) // Check recommended headers (30 points)
@ -80,9 +82,15 @@ func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int
} }
score += presentRecommended * 30 / recommendedCount score += presentRecommended * 30 / recommendedCount
if presentRecommended < recommendedCount {
maxGrade -= 1
}
// Check for proper MIME structure (20 points) // Check for proper MIME structure (20 points)
if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure { if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure {
score += 20 score += 20
} else {
maxGrade -= 1
} }
// Check Message-ID format (10 points) // 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 Valid is set and true, award points
if check.Valid != nil && *check.Valid { if check.Valid != nil && *check.Valid {
score += 10 score += 10
} else {
maxGrade -= 1
} }
} else {
maxGrade -= 1
} }
// Ensure score doesn't exceed 100 // Ensure score doesn't exceed 100
if score > 100 { if score > 100 {
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 // isValidMessageID checks if a Message-ID has proper format

View file

@ -109,7 +109,7 @@ func TestCalculateHeaderScore(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Generate header analysis first // Generate header analysis first
analysis := analyzer.GenerateHeaderAnalysis(tt.email) analysis := analyzer.GenerateHeaderAnalysis(tt.email)
score := analyzer.CalculateHeaderScore(analysis) score, _ := analyzer.CalculateHeaderScore(analysis)
if score < tt.minScore || score > tt.maxScore { if score < tt.minScore || score > tt.maxScore {
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
} }

View file

@ -240,13 +240,14 @@ func (r *RBLChecker) reverseIP(ipStr string) string {
} }
// CalculateRBLScore calculates the blacklist contribution to deliverability // 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 { if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt // 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 // GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL

View file

@ -271,14 +271,14 @@ func TestGetBlacklistScore(t *testing.T) {
{ {
name: "Nil results", name: "Nil results",
results: nil, results: nil,
expectedScore: 200, expectedScore: 100,
}, },
{ {
name: "No IPs checked", name: "No IPs checked",
results: &RBLResults{ results: &RBLResults{
IPsChecked: []string{}, IPsChecked: []string{},
}, },
expectedScore: 200, expectedScore: 100,
}, },
{ {
name: "Not listed on any RBL", name: "Not listed on any RBL",
@ -286,7 +286,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 0, ListedCount: 0,
}, },
expectedScore: 200, expectedScore: 100,
}, },
{ {
name: "Listed on 1 RBL", name: "Listed on 1 RBL",
@ -294,7 +294,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 1, ListedCount: 1,
}, },
expectedScore: 100, expectedScore: 84, // 100 - 1*100/6 = 84 (integer division: 100/6=16)
}, },
{ {
name: "Listed on 2 RBLs", name: "Listed on 2 RBLs",
@ -302,7 +302,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 2, ListedCount: 2,
}, },
expectedScore: 50, expectedScore: 67, // 100 - 2*100/6 = 67 (integer division: 200/6=33)
}, },
{ {
name: "Listed on 3 RBLs", name: "Listed on 3 RBLs",
@ -310,7 +310,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 3, ListedCount: 3,
}, },
expectedScore: 50, expectedScore: 50, // 100 - 3*100/6 = 50 (integer division: 300/6=50)
}, },
{ {
name: "Listed on 4+ RBLs", name: "Listed on 4+ RBLs",
@ -318,7 +318,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"}, IPsChecked: []string{"198.51.100.1"},
ListedCount: 4, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
score := checker.CalculateRBLScore(tt.results) score, _ := checker.CalculateRBLScore(tt.results)
if score != tt.expectedScore { if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore) t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
} }

View file

@ -96,42 +96,54 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
// Calculate scores directly from analyzers (no more checks array) // Calculate scores directly from analyzers (no more checks array)
dnsScore := 0 dnsScore := 0
var dnsGrade string
if results.DNS != nil { if results.DNS != nil {
dnsScore = r.dnsAnalyzer.CalculateDNSScore(results.DNS) dnsScore, dnsGrade = r.dnsAnalyzer.CalculateDNSScore(results.DNS)
} }
authScore := 0 authScore := 0
var authGrade string
if results.Authentication != nil { if results.Authentication != nil {
authScore = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication) authScore, authGrade = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
} }
contentScore := 0 contentScore := 0
var contentGrade string
if results.Content != nil { if results.Content != nil {
contentScore = r.contentAnalyzer.CalculateContentScore(results.Content) contentScore, contentGrade = r.contentAnalyzer.CalculateContentScore(results.Content)
} }
headerScore := 0 headerScore := 0
var headerGrade rune
if results.Headers != nil { if results.Headers != nil {
headerScore = r.headerAnalyzer.CalculateHeaderScore(results.Headers) headerScore, headerGrade = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
} }
blacklistScore := 0 blacklistScore := 0
var blacklistGrade string
if results.RBL != nil { if results.RBL != nil {
blacklistScore = r.rblChecker.CalculateRBLScore(results.RBL) blacklistScore, blacklistGrade = r.rblChecker.CalculateRBLScore(results.RBL)
} }
spamScore := 0 spamScore := 0
var spamGrade string
if results.SpamAssassin != nil { if results.SpamAssassin != nil {
spamScore = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin) spamScore, spamGrade = r.spamAnalyzer.CalculateSpamAssassinScore(results.SpamAssassin)
} }
report.Summary = &api.ScoreSummary{ report.Summary = &api.ScoreSummary{
DnsScore: dnsScore, DnsScore: dnsScore,
DnsGrade: api.ScoreSummaryDnsGrade(dnsGrade),
AuthenticationScore: authScore, AuthenticationScore: authScore,
AuthenticationGrade: api.ScoreSummaryAuthenticationGrade(authGrade),
BlacklistScore: blacklistScore, BlacklistScore: blacklistScore,
BlacklistGrade: api.ScoreSummaryBlacklistGrade(blacklistGrade),
ContentScore: contentScore, ContentScore: contentScore,
ContentGrade: api.ScoreSummaryContentGrade(contentGrade),
HeaderScore: headerScore, HeaderScore: headerScore,
HeaderGrade: api.ScoreSummaryHeaderGrade(headerGrade),
SpamScore: spamScore, SpamScore: spamScore,
SpamGrade: api.ScoreSummarySpamGrade(spamGrade),
} }
// Add authentication results // Add authentication results
@ -187,6 +199,41 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
} }
report.Grade = ScoreToReportGrade(report.Score) 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 return report
} }

View file

@ -106,23 +106,26 @@ func TestGenerateReport(t *testing.T) {
t.Error("Summary should not be nil") 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 != 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) 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) 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) 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) 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) 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)
}
} }
} }

View file

@ -28,9 +28,9 @@ import (
// ScoreToGrade converts a percentage score (0-100) to a letter grade // ScoreToGrade converts a percentage score (0-100) to a letter grade
func ScoreToGrade(score int) string { func ScoreToGrade(score int) string {
switch { switch {
case score >= 97: case score > 100:
return "A+" return "A+"
case score >= 93: case score > 95:
return "A" return "A"
case score >= 85: case score >= 85:
return "B" return "B"

View file

@ -193,9 +193,9 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *api.SpamAs
} }
// CalculateSpamAssassinScore calculates the SpamAssassin contribution to deliverability // 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 { 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 // SpamAssassin score typically ranges from -10 to +20
@ -206,12 +206,15 @@ func (a *SpamAssassinAnalyzer) CalculateSpamAssassinScore(result *api.SpamAssass
score := result.Score score := result.Score
// Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better) // Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better)
if score <= 0 { if score < 0 {
return 100 // Perfect score for ham return 100, "A+" // Perfect score for ham
} else if score == 0 {
return 100, "A" // Perfect score for ham
} else if score >= result.RequiredScore { } else if score >= result.RequiredScore {
return 0 // Failed spam test return 0, "F" // Failed spam test
} else { } else {
// Linear scale between 0 and required threshold // 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)
} }
} }

View file

@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { Authentication, DNSResults, ReportSummary } from "$lib/api/types.gen"; import type { Authentication, DNSResults, ReportSummary } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
authentication: Authentication; authentication: Authentication;
authenticationGrade?: string;
authenticationScore?: number; authenticationScore?: number;
dnsResults?: DNSResults; dnsResults?: DNSResults;
} }
let { authentication, authenticationScore, dnsResults }: Props = $props(); let { authentication, authenticationGrade, authenticationScore, dnsResults }: Props = $props();
function getAuthResultClass(result: string): string { function getAuthResultClass(result: string): string {
switch (result) { switch (result) {
@ -57,11 +60,16 @@
<i class="bi bi-shield-check me-2"></i> <i class="bi bi-shield-check me-2"></i>
Authentication Authentication
</span> </span>
<span>
{#if authenticationScore !== undefined} {#if authenticationScore !== undefined}
<span class="badge bg-secondary"> <span class="badge bg-{getScoreColorClass(authenticationScore)}">
{authenticationScore}% {authenticationScore}%
</span> </span>
{/if} {/if}
{#if authenticationGrade !== undefined}
<GradeDisplay grade={authenticationGrade} size="small" />
{/if}
</span>
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { RBLCheck } from "$lib/api/types.gen"; import type { RBLCheck } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
blacklists: Record<string, RBLCheck[]>; blacklists: Record<string, RBLCheck[]>;
blacklistGrade?: string;
blacklistScore?: number; blacklistScore?: number;
} }
let { blacklists, blacklistScore }: Props = $props(); let { blacklists, blacklistGrade, blacklistScore }: Props = $props();
</script> </script>
<div class="card shadow-sm"> <div class="card shadow-sm">
@ -16,11 +19,16 @@
<i class="bi bi-shield-exclamation me-2"></i> <i class="bi bi-shield-exclamation me-2"></i>
Blacklist Checks Blacklist Checks
</span> </span>
<span>
{#if blacklistScore !== undefined} {#if blacklistScore !== undefined}
<span class="badge bg-secondary"> <span class="badge bg-{getScoreColorClass(blacklistScore)}">
{blacklistScore}% {blacklistScore}%
</span> </span>
{/if} {/if}
{#if blacklistGrade !== undefined}
<GradeDisplay grade={blacklistGrade} size="small" />
{/if}
</span>
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { ContentAnalysis } from "$lib/api/types.gen"; import type { ContentAnalysis } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
contentAnalysis: ContentAnalysis; contentAnalysis: ContentAnalysis;
contentGrade?: string;
contentScore?: number; contentScore?: number;
} }
let { contentAnalysis, contentScore }: Props = $props(); let { contentAnalysis, contentGrade, contentScore }: Props = $props();
</script> </script>
<div class="card shadow-sm"> <div class="card shadow-sm">
@ -16,11 +19,16 @@
<i class="bi bi-file-text me-2"></i> <i class="bi bi-file-text me-2"></i>
Content Analysis Content Analysis
</span> </span>
<span>
{#if contentScore !== undefined} {#if contentScore !== undefined}
<span class="badge bg-secondary"> <span class="badge bg-{getScoreColorClass(contentScore)}">
{contentScore}% {contentScore}%
</span> </span>
{/if} {/if}
{#if contentGrade !== undefined}
<GradeDisplay grade={contentGrade} size="small" />
{/if}
</span>
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { DNSResults } from "$lib/api/types.gen"; import type { DNSResults } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
dnsResults?: DNSResults; dnsResults?: DNSResults;
dnsGrade?: string;
dnsScore?: number; dnsScore?: number;
} }
let { dnsResults, dnsScore }: Props = $props(); let { dnsResults, dnsGrade, dnsScore }: Props = $props();
</script> </script>
<div class="card shadow-sm"> <div class="card shadow-sm">
@ -16,11 +19,16 @@
<i class="bi bi-diagram-3 me-2"></i> <i class="bi bi-diagram-3 me-2"></i>
DNS Records DNS Records
</span> </span>
<span>
{#if dnsScore !== undefined} {#if dnsScore !== undefined}
<span class="badge bg-secondary"> <span class="badge bg-{getScoreColorClass(dnsScore)}">
{dnsScore}% {dnsScore}%
</span> </span>
{/if} {/if}
{#if dnsGrade !== undefined}
<GradeDisplay grade={dnsGrade} size="small" />
{/if}
</span>
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">

View file

@ -0,0 +1,61 @@
<script lang="ts">
interface Props {
grade?: string;
score: number;
size?: "small" | "medium" | "large";
}
let { grade, score, size = "medium" }: Props = $props();
function getGradeColor(grade?: string): string {
if (!grade) return "#6b7280"; // Gray for no grade
const baseLetter = grade.charAt(0).toUpperCase();
const modifier = grade.length > 1 ? grade.charAt(1) : "";
// Gradient from green (A+) to red (F)
switch (baseLetter) {
case "A":
if (modifier === "+") return "#22c55e"; // Bright green
if (modifier === "-") return "#16a34a"; // Green
return "#22c55e"; // Green
case "B":
if (modifier === "-") return "#65a30d"; // Darker lime
return "#84cc16"; // Lime
case "C":
if (modifier === "-") return "#ca8a04"; // Darker yellow
return "#eab308"; // Yellow
case "D":
return "#f97316"; // Orange
case "E":
return "#ea580c"; // Red
case "F":
return "#dc2626"; // Red
default:
return "#6b7280"; // Gray
}
}
function getSizeClass(size: "small" | "medium" | "large"): string {
if (size === "small") return "fs-4";
if (size === "large") return "display-1";
return "fs-2";
}
</script>
<strong
class={getSizeClass(size)}
style="color: {getGradeColor(grade)}; font-weight: 700;"
>
{#if grade}
{grade}
{:else}
{score}%
{/if}
</strong>
<style>
strong {
transition: color 0.3s ease;
}
</style>

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { HeaderAnalysis } from "$lib/api/types.gen"; import type { HeaderAnalysis } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
headerAnalysis: HeaderAnalysis; headerAnalysis: HeaderAnalysis;
headerGrade?: string;
headerScore?: number; headerScore?: number;
} }
let { headerAnalysis, headerScore }: Props = $props(); let { headerAnalysis, headerGrade, headerScore }: Props = $props();
</script> </script>
<div class="card shadow-sm"> <div class="card shadow-sm">
@ -16,11 +19,16 @@
<i class="bi bi-list-ul me-2"></i> <i class="bi bi-list-ul me-2"></i>
Header Analysis Header Analysis
</span> </span>
<span>
{#if headerScore !== undefined} {#if headerScore !== undefined}
<span class="badge bg-secondary"> <span class="badge bg-{getScoreColorClass(headerScore)}">
{headerScore}% {headerScore}%
</span> </span>
{/if} {/if}
{#if headerGrade !== undefined}
<GradeDisplay grade={headerGrade} size="small" />
{/if}
</span>
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ScoreSummary } from "$lib/api/types.gen"; import type { ScoreSummary } from "$lib/api/types.gen";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
grade: string; grade: string;
@ -9,14 +10,6 @@
let { grade, score, summary }: Props = $props(); let { grade, score, summary }: Props = $props();
function getScoreClass(score: number): string {
if (score >= 90) return "score-excellent";
if (score >= 70) return "score-good";
if (score >= 50) return "score-warning";
if (score >= 30) return "score-poor";
return "score-bad";
}
function getScoreLabel(score: number): string { function getScoreLabel(score: number): string {
if (score >= 90) return "Excellent"; if (score >= 90) return "Excellent";
if (score >= 70) return "Good"; if (score >= 70) return "Good";
@ -28,9 +21,9 @@
<div class="card shadow-lg bg-white"> <div class="card shadow-lg bg-white">
<div class="card-body p-5 text-center"> <div class="card-body p-5 text-center">
<h1 class="display-1 fw-bold mb-3 {getScoreClass(score)}"> <div class="mb-3">
{grade} <GradeDisplay {grade} {score} size="large" />
</h1> </div>
<h3 class="fw-bold mb-2">{getScoreLabel(score)}</h3> <h3 class="fw-bold mb-2">{getScoreLabel(score)}</h3>
<p class="text-muted mb-4">Overall Deliverability Score</p> <p class="text-muted mb-4">Overall Deliverability Score</p>
@ -38,84 +31,37 @@
<div class="row g-3 text-start"> <div class="row g-3 text-start">
<div class="col-md-6 col-lg"> <div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center"> <div class="p-2 bg-light rounded text-center">
<strong <GradeDisplay grade={summary.dns_grade} score={summary.dns_score} />
class="fs-2"
class:text-success={summary.dns_score >= 100}
class:text-warning={summary.dns_score < 100 &&
summary.dns_score >= 50}
class:text-danger={summary.dns_score < 50}
>
{summary.dns_score}%
</strong>
<small class="text-muted d-block">DNS</small> <small class="text-muted d-block">DNS</small>
</div> </div>
</div> </div>
<div class="col-md-6 col-lg"> <div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center"> <div class="p-2 bg-light rounded text-center">
<strong <GradeDisplay grade={summary.authentication_grade} score={summary.authentication_score} />
class="fs-2"
class:text-success={summary.authentication_score >= 100}
class:text-warning={summary.authentication_score < 100 &&
summary.authentication_score >= 50}
class:text-danger={summary.authentication_score < 50}
>
{summary.authentication_score}%
</strong>
<small class="text-muted d-block">Authentication</small> <small class="text-muted d-block">Authentication</small>
</div> </div>
</div> </div>
<div class="col-md-6 col-lg"> <div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center"> <div class="p-2 bg-light rounded text-center">
<strong <GradeDisplay grade={summary.blacklist_grade} score={summary.blacklist_score} />
class="fs-2"
class:text-success={summary.blacklist_score >= 100}
class:text-warning={summary.blacklist_score < 100 &&
summary.blacklist_score >= 50}
class:text-danger={summary.blacklist_score < 50}
>
{summary.blacklist_score}%
</strong>
<small class="text-muted d-block">Blacklists</small> <small class="text-muted d-block">Blacklists</small>
</div> </div>
</div> </div>
<div class="col-md-6 col-lg"> <div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center"> <div class="p-2 bg-light rounded text-center">
<strong <GradeDisplay grade={summary.header_grade} score={summary.header_score} />
class="fs-2"
class:text-success={summary.header_score >= 100}
class:text-warning={summary.header_score < 100 &&
summary.header_score >= 50}
class:text-danger={summary.header_score < 50}
>
{summary.header_score}%
</strong>
<small class="text-muted d-block">Headers</small> <small class="text-muted d-block">Headers</small>
</div> </div>
</div> </div>
<div class="col-md-6 col-lg"> <div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center"> <div class="p-2 bg-light rounded text-center">
<strong <GradeDisplay grade={summary.spam_grade} score={summary.spam_score} />
class="fs-2"
class:text-success={summary.spam_score >= 100}
class:text-warning={summary.spam_score < 100 && summary.spam_score >= 50}
class:text-danger={summary.spam_score < 50}
>
{summary.spam_score}%
</strong>
<small class="text-muted d-block">Spam Score</small> <small class="text-muted d-block">Spam Score</small>
</div> </div>
</div> </div>
<div class="col-md-6 col-lg"> <div class="col-md-6 col-lg">
<div class="p-2 bg-light rounded text-center"> <div class="p-2 bg-light rounded text-center">
<strong <GradeDisplay grade={summary.content_grade} score={summary.content_score} />
class="fs-2"
class:text-success={summary.content_score >= 100}
class:text-warning={summary.content_score < 100 &&
summary.content_score >= 50}
class:text-danger={summary.content_score < 50}
>
{summary.content_score}%
</strong>
<small class="text-muted d-block">Content</small> <small class="text-muted d-block">Content</small>
</div> </div>
</div> </div>

View file

@ -1,12 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { SpamAssassinResult } from "$lib/api/types.gen"; import type { SpamAssassinResult } from "$lib/api/types.gen";
import { getScoreColorClass } from "$lib/score";
import GradeDisplay from "./GradeDisplay.svelte";
interface Props { interface Props {
spamassassin: SpamAssassinResult; spamassassin: SpamAssassinResult;
spamGrade: string;
spamScore: number; spamScore: number;
} }
let { spamassassin, spamScore }: Props = $props(); let { spamassassin, spamGrade, spamScore }: Props = $props();
</script> </script>
<div class="card"> <div class="card">
@ -16,11 +19,16 @@
<i class="bi bi-bug me-2"></i> <i class="bi bi-bug me-2"></i>
SpamAssassin Analysis SpamAssassin Analysis
</span> </span>
<span>
{#if spamScore !== undefined} {#if spamScore !== undefined}
<span class="badge bg-secondary"> <span class="badge bg-{getScoreColorClass(spamScore)}">
{spamScore}% {spamScore}%
</span> </span>
{/if} {/if}
{#if spamGrade !== undefined}
<GradeDisplay grade={spamGrade} size="small" />
{/if}
</span>
</h4> </h4>
</div> </div>
<div class="card-body"> <div class="card-body">

5
web/src/lib/score.ts Normal file
View file

@ -0,0 +1,5 @@
export function getScoreColorClass(percentage: number): string {
if (percentage >= 85) return "success";
if (percentage >= 50) return "warning";
return "danger";
}

View file

@ -81,12 +81,6 @@
stopPolling(); stopPolling();
}); });
function getScoreColorClass(percentage: number): string {
if (percentage >= 80) return "text-success";
if (percentage >= 50) return "text-warning";
return "text-danger";
}
async function handleReanalyze() { async function handleReanalyze() {
if (!testId || reanalyzing) return; if (!testId || reanalyzing) return;
@ -150,6 +144,7 @@
<div class="col-12"> <div class="col-12">
<DnsRecordsCard <DnsRecordsCard
dnsResults={report.dns_results} dnsResults={report.dns_results}
dnsGrade={report.summary?.dns_grade}
dnsScore={report.summary?.dns_score} dnsScore={report.summary?.dns_score}
/> />
</div> </div>
@ -162,6 +157,7 @@
<div class="col-12"> <div class="col-12">
<AuthenticationCard <AuthenticationCard
authentication={report.authentication} authentication={report.authentication}
authenticationGrade={report.summary?.authentication_grade}
authenticationScore={report.summary?.authentication_score} authenticationScore={report.summary?.authentication_score}
dnsResults={report.dns_results} dnsResults={report.dns_results}
/> />
@ -175,6 +171,7 @@
<div class="col-12"> <div class="col-12">
<BlacklistCard <BlacklistCard
blacklists={report.blacklists} blacklists={report.blacklists}
blacklistGrade={report.summary?.blacklist_grade}
blacklistScore={report.summary?.blacklist_score} blacklistScore={report.summary?.blacklist_score}
/> />
</div> </div>
@ -187,6 +184,7 @@
<div class="col-12"> <div class="col-12">
<HeaderAnalysisCard <HeaderAnalysisCard
headerAnalysis={report.header_analysis} headerAnalysis={report.header_analysis}
headerGrade={report.summary?.header_grade}
headerScore={report.summary?.header_score} headerScore={report.summary?.header_score}
/> />
</div> </div>
@ -199,6 +197,7 @@
<div class="col-12"> <div class="col-12">
<SpamAssassinCard <SpamAssassinCard
spamassassin={report.spamassassin} spamassassin={report.spamassassin}
spamGrade={report.summary?.spam_grade}
spamScore={report.summary?.spam_score} spamScore={report.summary?.spam_score}
/> />
</div> </div>
@ -211,6 +210,7 @@
<div class="col-12"> <div class="col-12">
<ContentAnalysisCard <ContentAnalysisCard
contentAnalysis={report.content_analysis} contentAnalysis={report.content_analysis}
contentGrade={report.summary?.content_grade}
contentScore={report.summary?.content_score} contentScore={report.summary?.content_score}
/> />
</div> </div>