Add grades
This commit is contained in:
parent
33d394a27b
commit
a64b866cfa
25 changed files with 362 additions and 207 deletions
26
README.md
26
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -370,16 +370,16 @@ func TestParseARCResult(t *testing.T) {
|
||||||
|
|
||||||
func TestParseAuthenticationResultsHeader(t *testing.T) {
|
func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header string
|
header string
|
||||||
expectedSPFResult *api.AuthResultResult
|
expectedSPFResult *api.AuthResultResult
|
||||||
expectedSPFDomain *string
|
expectedSPFDomain *string
|
||||||
expectedDKIMCount int
|
expectedDKIMCount int
|
||||||
expectedDKIMResult *api.AuthResultResult
|
expectedDKIMResult *api.AuthResultResult
|
||||||
expectedDMARCResult *api.AuthResultResult
|
expectedDMARCResult *api.AuthResultResult
|
||||||
expectedDMARCDomain *string
|
expectedDMARCDomain *string
|
||||||
expectedBIMIResult *api.AuthResultResult
|
expectedBIMIResult *api.AuthResultResult
|
||||||
expectedARCResult *api.ARCResultResult
|
expectedARCResult *api.ARCResultResult
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Complete authentication results",
|
name: "Complete authentication results",
|
||||||
|
|
@ -441,12 +441,12 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
expectedDMARCDomain: api.PtrTo("example.com"),
|
expectedDMARCDomain: api.PtrTo("example.com"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BIMI pass",
|
name: "BIMI pass",
|
||||||
header: "mail.example.com; spf=pass smtp.mailfrom=sender@example.com; bimi=pass header.d=example.com header.selector=default",
|
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),
|
expectedSPFResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
expectedSPFDomain: api.PtrTo("example.com"),
|
expectedSPFDomain: api.PtrTo("example.com"),
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 0,
|
||||||
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
expectedBIMIResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ARC pass",
|
name: "ARC pass",
|
||||||
|
|
@ -468,24 +468,24 @@ func TestParseAuthenticationResultsHeader(t *testing.T) {
|
||||||
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
expectedARCResult: api.PtrTo(api.ARCResultResultPass),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Empty header (authserv-id only)",
|
name: "Empty header (authserv-id only)",
|
||||||
header: "mx.google.com",
|
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,
|
expectedSPFResult: nil,
|
||||||
expectedDKIMCount: 0,
|
expectedDKIMCount: 1,
|
||||||
},
|
expectedDKIMResult: api.PtrTo(api.AuthResultResultPass),
|
||||||
{
|
|
||||||
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),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SPF neutral",
|
name: "SPF neutral",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{#if authenticationScore !== undefined}
|
<span>
|
||||||
<span class="badge bg-secondary">
|
{#if authenticationScore !== undefined}
|
||||||
{authenticationScore}%
|
<span class="badge bg-{getScoreColorClass(authenticationScore)}">
|
||||||
</span>
|
{authenticationScore}%
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if authenticationGrade !== undefined}
|
||||||
|
<GradeDisplay grade={authenticationGrade} size="small" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{#if blacklistScore !== undefined}
|
<span>
|
||||||
<span class="badge bg-secondary">
|
{#if blacklistScore !== undefined}
|
||||||
{blacklistScore}%
|
<span class="badge bg-{getScoreColorClass(blacklistScore)}">
|
||||||
</span>
|
{blacklistScore}%
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if blacklistGrade !== undefined}
|
||||||
|
<GradeDisplay grade={blacklistGrade} size="small" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{#if contentScore !== undefined}
|
<span>
|
||||||
<span class="badge bg-secondary">
|
{#if contentScore !== undefined}
|
||||||
{contentScore}%
|
<span class="badge bg-{getScoreColorClass(contentScore)}">
|
||||||
</span>
|
{contentScore}%
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if contentGrade !== undefined}
|
||||||
|
<GradeDisplay grade={contentGrade} size="small" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{#if dnsScore !== undefined}
|
<span>
|
||||||
<span class="badge bg-secondary">
|
{#if dnsScore !== undefined}
|
||||||
{dnsScore}%
|
<span class="badge bg-{getScoreColorClass(dnsScore)}">
|
||||||
</span>
|
{dnsScore}%
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if dnsGrade !== undefined}
|
||||||
|
<GradeDisplay grade={dnsGrade} size="small" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
61
web/src/lib/components/GradeDisplay.svelte
Normal file
61
web/src/lib/components/GradeDisplay.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
{#if headerScore !== undefined}
|
<span>
|
||||||
<span class="badge bg-secondary">
|
{#if headerScore !== undefined}
|
||||||
{headerScore}%
|
<span class="badge bg-{getScoreColorClass(headerScore)}">
|
||||||
</span>
|
{headerScore}%
|
||||||
{/if}
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if headerGrade !== undefined}
|
||||||
|
<GradeDisplay grade={headerGrade} size="small" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{#if spamScore !== undefined}
|
<span>
|
||||||
<span class="badge bg-secondary">
|
{#if spamScore !== undefined}
|
||||||
{spamScore}%
|
<span class="badge bg-{getScoreColorClass(spamScore)}">
|
||||||
</span>
|
{spamScore}%
|
||||||
{/if}
|
</span>
|
||||||
|
{/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
5
web/src/lib/score.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function getScoreColorClass(percentage: number): string {
|
||||||
|
if (percentage >= 85) return "success";
|
||||||
|
if (percentage >= 50) return "warning";
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue