Score as percentages

This commit is contained in:
nemunaire 2025-10-21 12:52:20 +07:00
commit 74aee54432
23 changed files with 1027 additions and 1488 deletions

View file

@ -252,8 +252,7 @@ components:
pattern: '^[a-z0-9-]+$'
description: Associated test ID (base32-encoded with hyphens)
score:
type: number
format: float
type: integer
minimum: 0
maximum: 100
description: Overall deliverability score as percentage (0-100)
@ -298,36 +297,31 @@ components:
- header_score
properties:
authentication_score:
type: number
format: float
type: integer
minimum: 0
maximum: 100
description: SPF/DKIM/DMARC score (in percentage)
example: 28
spam_score:
type: number
format: float
type: integer
minimum: 0
maximum: 100
description: SpamAssassin score (in percentage)
example: 15
blacklist_score:
type: number
format: float
type: integer
minimum: 0
maximum: 100
description: Blacklist check score (in percentage)
example: 20
content_score:
type: number
format: float
type: integer
minimum: 0
maximum: 100
description: Content quality score (in percentage)
example: 18
header_score:
type: number
format: float
type: integer
minimum: 0
maximum: 100
description: Header quality score (in percentage)
@ -358,8 +352,7 @@ components:
description: Check result status
example: "pass"
score:
type: number
format: float
type: integer
description: Points contributed to total score
example: 10
grade:

View file

@ -92,10 +92,6 @@ func outputHumanReadable(result *analyzer.AnalysisResult, emailAnalyzer *analyze
fmt.Fprintln(writer, "EMAIL DELIVERABILITY ANALYSIS REPORT")
fmt.Fprintln(writer, strings.Repeat("=", 70))
// Score summary
summary := emailAnalyzer.GetScoreSummaryText(result)
fmt.Fprintln(writer, summary)
// Detailed checks
fmt.Fprintln(writer, "\n"+strings.Repeat("-", 70))
fmt.Fprintln(writer, "DETAILED CHECK RESULTS")

View file

@ -79,14 +79,6 @@ func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*A
}, nil
}
// GetScoreSummaryText returns a human-readable score summary
func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string {
if result == nil || result.Results == nil {
return ""
}
return a.generator.GetScoreSummaryText(result.Results)
}
// APIAdapter adapts the EmailAnalyzer to work with the API package
// This adapter implements the interface expected by the API handler
type APIAdapter struct {

View file

@ -24,6 +24,7 @@ package analyzer
import (
"fmt"
"regexp"
"slices"
"strings"
"git.happydns.org/happyDeliver/internal/api"
@ -190,14 +191,7 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result.Selector = &selector
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
result.Details = &part
return result
}
@ -221,17 +215,7 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result.Domain = &domain
}
// Extract details (action, policy, etc.)
var detailsParts []string
actionRe := regexp.MustCompile(`action=([^\s;]+)`)
if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 {
detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1]))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, " ")
result.Details = &details
}
result.Details = &part
return result
}
@ -262,14 +246,7 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
result.Selector = &selector
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
result.Details = &part
return result
}
@ -286,14 +263,7 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
result.Result = api.ARCResultResult(resultStr)
}
// Extract details
if idx := strings.Index(part, "("); idx != -1 {
endIdx := strings.Index(part[idx:], ")")
if endIdx != -1 {
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
result.Details = &details
}
}
result.Details = &part
return result
}
@ -389,7 +359,7 @@ func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig,
// Verify instances are sequential from 1 to N
for i := 1; i <= len(sealInstances); i++ {
if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
if !slices.Contains(sealInstances, i) || !slices.Contains(sigInstances, i) || !slices.Contains(authInstances, i) {
return false
}
}
@ -413,16 +383,6 @@ func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
return instances
}
// contains checks if a slice contains an integer
func contains(slice []int, val int) bool {
for _, item := range slice {
if item == val {
return true
}
}
return false
}
// pluralize returns "y" or "ies" based on count
func pluralize(count int) string {
if count == 1 {
@ -447,8 +407,10 @@ func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthRe
result.Result = api.AuthResultResult(resultStr)
}
result.Details = &receivedSPF
// Try to extract domain
domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`)
domainRe := regexp.MustCompile(`(?:envelope-from|sender)="?([^\s;"]+)`)
if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
email := matches[1]
if idx := strings.Index(email, "@"); idx != -1 {

View file

@ -41,7 +41,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe
Category: api.Authentication,
Name: "SPF Record",
Status: api.CheckStatusWarn,
Score: 0.0,
Score: 0,
Message: "No SPF authentication result found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
@ -59,7 +59,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe
Category: api.Authentication,
Name: "DKIM Signature",
Status: api.CheckStatusWarn,
Score: 0.0,
Score: 0,
Message: "No DKIM signature found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
@ -75,7 +75,7 @@ func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.Authe
Category: api.Authentication,
Name: "DMARC Policy",
Status: api.CheckStatusWarn,
Score: 0.0,
Score: 0,
Message: "No DMARC authentication result found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Implement DMARC policy for your domain"),
@ -106,37 +106,38 @@ func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check
switch spf.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Score = 100
check.Message = "SPF validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your SPF record is properly configured")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Message = "SPF validation failed"
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
case api.AuthResultResultSoftfail:
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Score = 50
check.Message = "SPF validation softfail"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review your SPF record configuration")
case api.AuthResultResultNeutral:
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Score = 50
check.Message = "SPF validation neutral"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("Consider tightening your SPF policy")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Score = 0
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review your SPF record configuration")
}
if spf.Domain != nil {
if spf.Details != nil {
check.Details = spf.Details
} else if spf.Domain != nil {
details := fmt.Sprintf("Domain: %s", *spf.Domain)
check.Details = &details
}
@ -153,34 +154,38 @@ func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index i
switch dkim.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Score = 10
check.Message = "DKIM signature is valid"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Message = "DKIM signature validation failed"
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Score = 0
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
}
var detailsParts []string
if dkim.Domain != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
}
if dkim.Selector != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
if dkim.Details != nil {
check.Details = dkim.Details
} else {
var detailsParts []string
if dkim.Domain != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
}
if dkim.Selector != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
}
return check
@ -195,25 +200,27 @@ func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.C
switch dmarc.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Score = 10
check.Message = "DMARC validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
case api.AuthResultResultFail:
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Message = "DMARC validation failed"
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
default:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Score = 0
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
}
if dmarc.Domain != nil {
if dmarc.Details != nil {
check.Details = dmarc.Details
} else if dmarc.Domain != nil {
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
check.Details = &details
}
@ -230,25 +237,27 @@ func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Che
switch bimi.Result {
case api.AuthResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
check.Score = 0 // BIMI doesn't contribute to score (branding feature)
check.Message = "BIMI validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
case api.AuthResultResultFail:
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Score = 0
check.Message = "BIMI validation failed"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
default:
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Score = 0
check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
}
if bimi.Domain != nil {
if bimi.Details != nil {
check.Details = bimi.Details
} else if bimi.Domain != nil {
details := fmt.Sprintf("Domain: %s", *bimi.Domain)
check.Details = &details
}
@ -265,39 +274,43 @@ func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check
switch arc.Result {
case api.ARCResultResultPass:
check.Status = api.CheckStatusPass
check.Score = 0.0 // ARC doesn't contribute to score (informational for forwarding)
check.Score = 0 // ARC doesn't contribute to score (informational for forwarding)
check.Message = "ARC chain validation passed"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
case api.ARCResultResultFail:
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Score = 0
check.Message = "ARC chain validation failed"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
default:
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Score = 0
check.Message = "No ARC chain present"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
}
// Build details
var detailsParts []string
if arc.ChainLength != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
}
if arc.ChainValid != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
}
if arc.Details != nil {
detailsParts = append(detailsParts, *arc.Details)
}
check.Details = arc.Details
} else {
// Build details
var detailsParts []string
if arc.ChainLength != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
}
if arc.ChainValid != nil {
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
}
if arc.Details != nil {
detailsParts = append(detailsParts, *arc.Details)
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, ", ")
check.Details = &details
}
}
return check

View file

@ -251,7 +251,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) {
name string
spf *api.AuthResult
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "SPF pass",
@ -260,7 +260,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "SPF fail",
@ -269,7 +269,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "SPF softfail",
@ -278,7 +278,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
expectedScore: 5,
},
{
name: "SPF neutral",
@ -287,7 +287,7 @@ func TestGenerateAuthSPFCheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
expectedScore: 5,
},
}
@ -319,7 +319,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) {
dkim *api.AuthResult
index int
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "DKIM pass",
@ -330,7 +330,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) {
},
index: 0,
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "DKIM fail",
@ -341,7 +341,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) {
},
index: 0,
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "DKIM none",
@ -352,7 +352,7 @@ func TestGenerateAuthDKIMCheck(t *testing.T) {
},
index: 0,
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -383,7 +383,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) {
name string
dmarc *api.AuthResult
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "DMARC pass",
@ -392,7 +392,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "DMARC fail",
@ -401,7 +401,7 @@ func TestGenerateAuthDMARCCheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -432,7 +432,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) {
name string
bimi *api.AuthResult
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "BIMI pass",
@ -441,7 +441,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0, // BIMI doesn't contribute to score
expectedScore: 0, // BIMI doesn't contribute to score
},
{
name: "BIMI fail",
@ -450,7 +450,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "BIMI none",
@ -459,7 +459,7 @@ func TestGenerateAuthBIMICheck(t *testing.T) {
Domain: api.PtrTo("example.com"),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -494,7 +494,7 @@ func TestGetAuthenticationScore(t *testing.T) {
tests := []struct {
name string
results *api.AuthenticationResults
expectedScore float32
expectedScore int
}{
{
name: "Perfect authentication (SPF + DKIM + DMARC)",
@ -509,7 +509,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: api.AuthResultResultPass,
},
},
expectedScore: 30.0,
expectedScore: 30,
},
{
name: "SPF and DKIM only",
@ -521,7 +521,7 @@ func TestGetAuthenticationScore(t *testing.T) {
{Result: api.AuthResultResultPass},
},
},
expectedScore: 20.0,
expectedScore: 20,
},
{
name: "SPF fail, DKIM pass",
@ -533,7 +533,7 @@ func TestGetAuthenticationScore(t *testing.T) {
{Result: api.AuthResultResultPass},
},
},
expectedScore: 10.0,
expectedScore: 10,
},
{
name: "SPF softfail",
@ -542,12 +542,12 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: api.AuthResultResultSoftfail,
},
},
expectedScore: 5.0,
expectedScore: 5,
},
{
name: "No authentication",
results: &api.AuthenticationResults{},
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "BIMI doesn't affect score",
@ -559,7 +559,7 @@ func TestGetAuthenticationScore(t *testing.T) {
Result: api.AuthResultResultPass,
},
},
expectedScore: 10.0, // Only SPF counted, not BIMI
expectedScore: 10, // Only SPF counted, not BIMI
},
}
@ -789,7 +789,7 @@ func TestGenerateARCCheck(t *testing.T) {
name string
arc *api.ARCResult
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "ARC pass",
@ -799,7 +799,7 @@ func TestGenerateARCCheck(t *testing.T) {
ChainValid: api.PtrTo(true),
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0, // ARC doesn't contribute to score
expectedScore: 0, // ARC doesn't contribute to score
},
{
name: "ARC fail",
@ -809,7 +809,7 @@ func TestGenerateARCCheck(t *testing.T) {
ChainValid: api.PtrTo(false),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "ARC none",
@ -819,7 +819,7 @@ func TestGenerateARCCheck(t *testing.T) {
ChainValid: api.PtrTo(true),
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
expectedScore: 0,
},
}

View file

@ -506,7 +506,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
if !results.HTMLValid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "HTML structure is invalid"
if len(results.HTMLErrors) > 0 {
@ -516,7 +516,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.2
check.Score = 2
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "HTML structure is valid"
check.Advice = api.PtrTo("Your HTML is well-formed")
@ -551,7 +551,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
if brokenLinks > 0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
@ -559,7 +559,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
check.Details = &details
} else if warningLinks > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.3
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
check.Advice = api.PtrTo("Review links that could not be verified")
@ -567,7 +567,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.4
check.Score = 4
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
check.Advice = api.PtrTo("Your links are working properly")
@ -600,7 +600,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
if noAltCount == len(results.Images) {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "No images have alt attributes"
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
@ -608,7 +608,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
check.Details = &details
} else if noAltCount > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Score = 2
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
@ -616,7 +616,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All images have alt attributes"
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
@ -635,13 +635,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.
if !results.HasUnsubscribe {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No unsubscribe link found"
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
@ -661,7 +661,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
if consistency < 0.3 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Plain text and HTML versions differ significantly"
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
@ -669,7 +669,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Plain text and HTML versions are consistent"
check.Advice = api.PtrTo("Your multipart email is well-structured")
@ -692,7 +692,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
// Flag if more than 1 image per 100 characters (very image-heavy)
if ratio > 10.0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Email is excessively image-heavy"
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
@ -700,7 +700,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
check.Details = &details
} else if ratio > 5.0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Score = 2
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Email has high image-to-text ratio"
check.Advice = api.PtrTo("Consider adding more text content relative to images")
@ -708,7 +708,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Score = 3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Image-to-text ratio is reasonable"
check.Advice = api.PtrTo("Your content has a good balance of images and text")
@ -746,19 +746,19 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap
}
// GetContentScore calculates the content score (0-20 points)
func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 {
func (c *ContentAnalyzer) GetContentScore(results *ContentResults) int {
if results == nil {
return 0.0
return 0
}
var score float32 = 0.0
var score int = 0
// HTML validity (2 points)
// HTML validity (10 points)
if results.HTMLValid {
score += 2.0
score += 10
}
// Links (4 points)
// Links (20 points)
if len(results.Links) > 0 {
brokenLinks := 0
for _, link := range results.Links {
@ -767,14 +767,14 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 {
}
}
if brokenLinks == 0 {
score += 4.0
score += 20
}
} else {
// No links is neutral, give partial score
score += 2.0
score += 10
}
// Images (3 points)
// Images (15 points)
if len(results.Images) > 0 {
noAltCount := 0
for _, img := range results.Images {
@ -783,47 +783,47 @@ func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 {
}
}
if noAltCount == 0 {
score += 3.0
score += 15
} else if noAltCount < len(results.Images) {
score += 1.5
score += 7
}
} else {
// No images is neutral
score += 1.5
score += 7
}
// Unsubscribe link (3 points)
// Unsubscribe link (15 points)
if results.HasUnsubscribe {
score += 3.0
score += 15
}
// Text consistency (3 points)
// Text consistency (15 points)
if results.TextPlainRatio >= 0.3 {
score += 3.0
score += 15
}
// Image ratio (3 points)
// Image ratio (15 points)
if results.ImageTextRatio <= 5.0 {
score += 3.0
score += 15
} else if results.ImageTextRatio <= 10.0 {
score += 1.5
score += 7
}
// Penalize suspicious URLs (deduct up to 5 points)
if len(results.SuspiciousURLs) > 0 {
penalty := float32(len(results.SuspiciousURLs)) * 1.0
penalty := len(results.SuspiciousURLs)
if penalty > 5.0 {
penalty = 5.0
penalty = 5
}
score -= penalty
}
// Ensure score is between 0 and 20
// Ensure score is between 0 and 100
if score < 0 {
score = 0
}
if score > 20.0 {
score = 20.0
if score > 100 {
score = 100
}
return score

View file

@ -613,7 +613,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "Valid HTML",
@ -621,7 +621,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) {
HTMLValid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.2,
expectedScore: 2,
},
{
name: "Invalid HTML",
@ -630,7 +630,7 @@ func TestGenerateHTMLValidityCheck(t *testing.T) {
HTMLErrors: []string{"Parse error"},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -658,7 +658,7 @@ func TestGenerateLinkChecks(t *testing.T) {
name string
results *ContentResults
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "All links valid",
@ -669,7 +669,7 @@ func TestGenerateLinkChecks(t *testing.T) {
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.4,
expectedScore: 4,
},
{
name: "Broken links",
@ -679,7 +679,7 @@ func TestGenerateLinkChecks(t *testing.T) {
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "Links with warnings",
@ -689,7 +689,7 @@ func TestGenerateLinkChecks(t *testing.T) {
},
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.3,
expectedScore: 3,
},
{
name: "No links",
@ -927,14 +927,14 @@ func TestGetContentScore(t *testing.T) {
tests := []struct {
name string
results *ContentResults
minScore float32
maxScore float32
minScore int
maxScore int
}{
{
name: "Nil results",
results: nil,
minScore: 0.0,
maxScore: 0.0,
minScore: 0,
maxScore: 0,
},
{
name: "Perfect content",
@ -946,8 +946,8 @@ func TestGetContentScore(t *testing.T) {
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
minScore: 18.0,
maxScore: 20.0,
minScore: 90,
maxScore: 100,
},
{
name: "Poor content",
@ -960,8 +960,8 @@ func TestGetContentScore(t *testing.T) {
ImageTextRatio: 15.0,
SuspiciousURLs: []string{"url1", "url2"},
},
minScore: 0.0,
maxScore: 5.0,
minScore: 0,
maxScore: 25,
},
{
name: "Average content",
@ -973,8 +973,8 @@ func TestGetContentScore(t *testing.T) {
TextPlainRatio: 0.5,
ImageTextRatio: 4.0,
},
minScore: 10.0,
maxScore: 18.0,
minScore: 50,
maxScore: 90,
},
}
@ -988,13 +988,13 @@ func TestGetContentScore(t *testing.T) {
t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
// Ensure score is capped at 20.0
if score > 20.0 {
t.Errorf("Score %v exceeds maximum of 20.0", score)
// Ensure score is capped at 100
if score > 100 {
t.Errorf("Score %v exceeds maximum of 100", score)
}
// Ensure score is not negative
if score < 0.0 {
if score < 0 {
t.Errorf("Score %v is negative", score)
}
})

View file

@ -521,7 +521,7 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
// BIMI record check (optional)
if results.BIMIRecord != nil {
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
checks = append(checks, d.generateBIMICheck(results.BIMIRecord, results.DMARCRecord))
}
return checks
@ -536,7 +536,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Severity = api.PtrTo(api.CheckSeverityCritical)
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
@ -547,7 +547,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Score = 100
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
@ -572,25 +572,25 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
}
if !spf.Valid {
// If no record exists at all, it's a failure
if spf.Record == "" {
// If no record exists at all, it's a failure
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Message = spf.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
} else {
// If record exists but is invalid, it's a warning
check.Status = api.CheckStatusWarn
check.Score = 0.5
// If record exists but is invalid, it's a failure
check.Status = api.CheckStatusFail
check.Score = 5
check.Message = "SPF record found but appears invalid"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
check.Details = &spf.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Score = 100
check.Message = "Valid SPF record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &spf.Record
@ -609,7 +609,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
if !dkim.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
@ -617,7 +617,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Score = 100
check.Message = "Valid DKIM record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
@ -637,13 +637,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
if !dmarc.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Score = 0
check.Message = dmarc.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Score = 100
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &dmarc.Record
@ -669,7 +669,7 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
}
// generateBIMICheck creates a check for BIMI records
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord, dmarc *DMARCRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "BIMI Record",
@ -679,14 +679,18 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
// BIMI is optional, so missing record is just informational
if bimi.Record == "" {
check.Status = api.CheckStatusInfo
check.Score = 0.0
check.Score = 0
check.Message = "No BIMI record found (optional)"
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
if dmarc.Policy != "quarantine" && dmarc.Policy != "reject" {
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
} else {
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients.")
}
} else {
// If record exists but is invalid
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Score = 5
check.Message = fmt.Sprintf("BIMI record found but invalid: %s", bimi.Error)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Advice = api.PtrTo("Review and fix your BIMI record syntax. Ensure it contains v=BIMI1 and a valid logo URL (l=)")
@ -694,7 +698,7 @@ func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) api.Check {
}
} else {
check.Status = api.CheckStatusPass
check.Score = 0.0 // BIMI doesn't contribute to score (branding feature)
check.Score = 100 // BIMI doesn't contribute to score (branding feature)
check.Message = "Valid BIMI record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)

View file

@ -305,7 +305,7 @@ func TestGenerateMXCheck(t *testing.T) {
name string
results *DNSResults
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "Valid MX records",
@ -317,7 +317,7 @@ func TestGenerateMXCheck(t *testing.T) {
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "No MX records",
@ -328,7 +328,7 @@ func TestGenerateMXCheck(t *testing.T) {
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "MX lookup failed",
@ -339,7 +339,7 @@ func TestGenerateMXCheck(t *testing.T) {
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -367,7 +367,7 @@ func TestGenerateSPFCheck(t *testing.T) {
name string
spf *SPFRecord
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "Valid SPF",
@ -376,7 +376,7 @@ func TestGenerateSPFCheck(t *testing.T) {
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "Invalid SPF",
@ -386,7 +386,7 @@ func TestGenerateSPFCheck(t *testing.T) {
Error: "SPF record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
expectedScore: 5,
},
{
name: "No SPF record",
@ -395,7 +395,7 @@ func TestGenerateSPFCheck(t *testing.T) {
Error: "No SPF record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -423,7 +423,7 @@ func TestGenerateDKIMCheck(t *testing.T) {
name string
dkim *DKIMRecord
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "Valid DKIM",
@ -434,7 +434,7 @@ func TestGenerateDKIMCheck(t *testing.T) {
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "Invalid DKIM",
@ -445,7 +445,7 @@ func TestGenerateDKIMCheck(t *testing.T) {
Error: "No DKIM record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -476,7 +476,7 @@ func TestGenerateDMARCCheck(t *testing.T) {
name string
dmarc *DMARCRecord
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "Valid DMARC - reject",
@ -486,7 +486,7 @@ func TestGenerateDMARCCheck(t *testing.T) {
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "Valid DMARC - quarantine",
@ -496,7 +496,7 @@ func TestGenerateDMARCCheck(t *testing.T) {
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "Valid DMARC - none",
@ -506,7 +506,7 @@ func TestGenerateDMARCCheck(t *testing.T) {
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
expectedScore: 10,
},
{
name: "No DMARC record",
@ -515,7 +515,7 @@ func TestGenerateDMARCCheck(t *testing.T) {
Error: "No DMARC record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -738,7 +738,7 @@ func TestGenerateBIMICheck(t *testing.T) {
name string
bimi *BIMIRecord
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "Valid BIMI with logo only",
@ -750,7 +750,7 @@ func TestGenerateBIMICheck(t *testing.T) {
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0, // BIMI doesn't contribute to score
expectedScore: 0, // BIMI doesn't contribute to score
},
{
name: "Valid BIMI with VMC",
@ -763,7 +763,7 @@ func TestGenerateBIMICheck(t *testing.T) {
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "No BIMI record (optional)",
@ -774,7 +774,7 @@ func TestGenerateBIMICheck(t *testing.T) {
Error: "No BIMI record found",
},
expectedStatus: api.CheckStatusInfo,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "Invalid BIMI record",
@ -786,7 +786,7 @@ func TestGenerateBIMICheck(t *testing.T) {
Error: "BIMI record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.0,
expectedScore: 0,
},
}

303
pkg/analyzer/headers.go Normal file
View file

@ -0,0 +1,303 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"fmt"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// HeaderAnalyzer analyzes email header quality and structure
type HeaderAnalyzer struct{}
// NewHeaderAnalyzer creates a new header analyzer
func NewHeaderAnalyzer() *HeaderAnalyzer {
return &HeaderAnalyzer{}
}
// calculateHeaderScore evaluates email structural quality
func (h *HeaderAnalyzer) calculateHeaderScore(email *EmailMessage) int {
if email == nil {
return 0
}
score := 0
requiredHeaders := 0
presentHeaders := 0
// Check required headers (RFC 5322)
headers := map[string]bool{
"From": false,
"Date": false,
"Message-ID": false,
}
for header := range headers {
requiredHeaders++
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
headers[header] = true
presentHeaders++
}
}
// Score based on required headers (40 points)
if presentHeaders == requiredHeaders {
score += 40
} else {
score += int(40 * (float32(presentHeaders) / float32(requiredHeaders)))
}
// Check recommended headers (30 points)
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
recommendedPresent := 0
for _, header := range recommendedHeaders {
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
recommendedPresent++
}
}
score += int(30 * (float32(recommendedPresent) / float32(len(recommendedHeaders))))
// Check for proper MIME structure (20 points)
if len(email.Parts) > 0 {
score += 20
}
// Check Message-ID format (10 point)
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
if h.isValidMessageID(messageID) {
score += 10
}
}
// Ensure score doesn't exceed 100
if score > 100 {
score = 100
}
return score
}
// isValidMessageID checks if a Message-ID has proper format
func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool {
// Basic check: should be in format <...@...>
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
return false
}
// Remove angle brackets
messageID = strings.TrimPrefix(messageID, "<")
messageID = strings.TrimSuffix(messageID, ">")
// Should contain @ symbol
if !strings.Contains(messageID, "@") {
return false
}
parts := strings.Split(messageID, "@")
if len(parts) != 2 {
return false
}
// Both parts should be non-empty
return len(parts[0]) > 0 && len(parts[1]) > 0
}
// GenerateHeaderChecks creates checks for email header quality
func (h *HeaderAnalyzer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
var checks []api.Check
if email == nil {
return checks
}
// Required headers check
checks = append(checks, h.generateRequiredHeadersCheck(email))
// Recommended headers check
checks = append(checks, h.generateRecommendedHeadersCheck(email))
// Message-ID check
checks = append(checks, h.generateMessageIDCheck(email))
// MIME structure check
checks = append(checks, h.generateMIMEStructureCheck(email))
return checks
}
// generateRequiredHeadersCheck checks for required RFC 5322 headers
func (h *HeaderAnalyzer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Required Headers",
}
requiredHeaders := []string{"From", "Date", "Message-ID"}
missing := []string{}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 4.0
check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All required headers are present"
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
} else {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
}
return check
}
// generateRecommendedHeadersCheck checks for recommended headers
func (h *HeaderAnalyzer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Recommended Headers",
}
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
missing := []string{}
for _, header := range recommendedHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 30
check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All recommended headers are present"
check.Advice = api.PtrTo("Your email includes all recommended headers")
} else if len(missing) < len(recommendedHeaders) {
check.Status = api.CheckStatusWarn
check.Score = 15
check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
} else {
check.Status = api.CheckStatusWarn
check.Score = 0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Missing all recommended headers"
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
}
return check
}
// generateMessageIDCheck validates Message-ID header
func (h *HeaderAnalyzer) generateMessageIDCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Message-ID Format",
}
messageID := email.GetHeaderValue("Message-ID")
if messageID == "" {
check.Status = api.CheckStatusFail
check.Score = 0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = "Message-ID header is missing"
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
} else if !h.isValidMessageID(messageID) {
check.Status = api.CheckStatusWarn
check.Score = 5
check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Message-ID format is invalid"
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
check.Details = &messageID
} else {
check.Status = api.CheckStatusPass
check.Score = 10
check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Message-ID is properly formatted"
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
check.Details = &messageID
}
return check
}
// generateMIMEStructureCheck validates MIME structure
func (h *HeaderAnalyzer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "MIME Structure",
}
if len(email.Parts) == 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No MIME parts detected"
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
} else {
check.Status = api.CheckStatusPass
check.Score = 2.0
check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
check.Advice = api.PtrTo("Your email has proper MIME structure")
// Add details about parts
partTypes := []string{}
for _, part := range email.Parts {
if part.ContentType != "" {
partTypes = append(partTypes, part.ContentType)
}
}
if len(partTypes) > 0 {
details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
check.Details = &details
}
}
return check
}

View file

@ -0,0 +1,324 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"net/textproto"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestCalculateHeaderScore(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minScore int
maxScore int
}{
{
name: "Nil email",
email: nil,
minScore: 0,
maxScore: 0,
},
{
name: "Perfect headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 70,
maxScore: 100,
},
{
name: "Missing required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Subject": "Test",
}),
},
minScore: 0,
maxScore: 40,
},
{
name: "Required only, no recommended",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 40,
maxScore: 80,
},
{
name: "Invalid Message-ID format",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "invalid-message-id",
"Subject": "Test",
"To": "recipient@example.com",
"Reply-To": "reply@example.com",
}),
MessageID: "invalid-message-id",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 70,
maxScore: 100,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := analyzer.calculateHeaderScore(tt.email)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
})
}
}
func TestGenerateRequiredHeadersCheck(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
expectedStatus api.CheckStatus
expectedScore int
}{
{
name: "All required headers present",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
From: &mail.Address{Address: "sender@example.com"},
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedStatus: api.CheckStatusPass,
expectedScore: 40,
},
{
name: "Missing all required headers",
email: &EmailMessage{
Header: make(mail.Header),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
{
name: "Missing some required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateRequiredHeadersCheck(tt.email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMessageIDCheck(t *testing.T) {
tests := []struct {
name string
messageID string
expectedStatus api.CheckStatus
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expectedStatus: api.CheckStatusPass,
},
{
name: "Invalid Message-ID format",
messageID: "invalid-message-id",
expectedStatus: api.CheckStatusWarn,
},
{
name: "Missing Message-ID",
messageID: "",
expectedStatus: api.CheckStatusFail,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Message-ID": tt.messageID,
}),
}
check := analyzer.generateMessageIDCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMIMEStructureCheck(t *testing.T) {
tests := []struct {
name string
parts []MessagePart
expectedStatus api.CheckStatus
}{
{
name: "With MIME parts",
parts: []MessagePart{
{ContentType: "text/plain", Content: "test"},
{ContentType: "text/html", Content: "<p>test</p>"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No MIME parts",
parts: []MessagePart{},
expectedStatus: api.CheckStatusWarn,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
Parts: tt.parts,
}
check := analyzer.generateMIMEStructureCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateHeaderChecks(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minChecks int
}{
{
name: "Nil email",
email: nil,
minChecks: 0,
},
{
name: "Complete email",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minChecks: 4, // Required, Recommended, Message-ID, MIME
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateHeaderChecks(tt.email)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Headers category
for _, check := range checks {
if check.Category != api.Headers {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers)
}
}
})
}
}
// Helper function to create mail.Header with specific fields
func createHeaderWithFields(fields map[string]string) mail.Header {
header := make(mail.Header)
for key, value := range fields {
if value != "" {
// Use canonical MIME header key format
canonicalKey := textproto.CanonicalMIMEHeaderKey(key)
header[canonicalKey] = []string{value}
}
}
return header
}

View file

@ -238,29 +238,14 @@ func (r *RBLChecker) reverseIP(ipStr string) string {
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
// GetBlacklistScore calculates the blacklist contribution to deliverability (0-20 points)
// Scoring:
// - Not listed on any RBL: 20 points (excellent)
// - Listed on 1 RBL: 10 points (warning)
// - Listed on 2-3 RBLs: 5 points (poor)
// - Listed on 4+ RBLs: 0 points (critical)
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 {
// GetBlacklistScore calculates the blacklist contribution to deliverability
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int {
if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt
return 20.0
return 100
}
listedCount := results.ListedCount
if listedCount == 0 {
return 20.0
} else if listedCount == 1 {
return 10.0
} else if listedCount <= 3 {
return 5.0
}
return 0.0
return 100 - results.ListedCount*100/len(r.RBLs)
}
// GenerateRBLChecks generates check results for RBL analysis
@ -277,8 +262,8 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
Category: api.Blacklist,
Name: "RBL Check",
Status: api.CheckStatusWarn,
Score: 10.0,
Grade: ScoreToCheckGrade((10.0 / 20.0) * 100),
Score: 50,
Grade: ScoreToCheckGrade(50),
Message: "No public IP addresses found to check",
Severity: api.PtrTo(api.CheckSeverityLow),
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
@ -310,7 +295,7 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
score := r.GetBlacklistScore(results)
check.Score = score
check.Grade = ScoreToCheckGrade((score / 20.0) * 100)
check.Grade = ScoreToCheckGrade(score)
totalChecks := len(results.Checks)
listedCount := results.ListedCount
@ -352,8 +337,8 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
Category: api.Blacklist,
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
Status: api.CheckStatusFail,
Score: 0.0,
Grade: ScoreToCheckGrade(0.0),
Score: 0,
Grade: ScoreToCheckGrade(0),
}
check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)

View file

@ -267,19 +267,19 @@ func TestGetBlacklistScore(t *testing.T) {
tests := []struct {
name string
results *RBLResults
expectedScore float32
expectedScore int
}{
{
name: "Nil results",
results: nil,
expectedScore: 20.0,
expectedScore: 200,
},
{
name: "No IPs checked",
results: &RBLResults{
IPsChecked: []string{},
},
expectedScore: 20.0,
expectedScore: 200,
},
{
name: "Not listed on any RBL",
@ -287,7 +287,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
},
expectedScore: 20.0,
expectedScore: 200,
},
{
name: "Listed on 1 RBL",
@ -295,7 +295,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
},
expectedScore: 10.0,
expectedScore: 100,
},
{
name: "Listed on 2 RBLs",
@ -303,7 +303,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
},
expectedScore: 5.0,
expectedScore: 50,
},
{
name: "Listed on 3 RBLs",
@ -311,7 +311,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"},
ListedCount: 3,
},
expectedScore: 5.0,
expectedScore: 50,
},
{
name: "Listed on 4+ RBLs",
@ -319,7 +319,7 @@ func TestGetBlacklistScore(t *testing.T) {
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
},
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -340,7 +340,7 @@ func TestGenerateSummaryCheck(t *testing.T) {
name string
results *RBLResults
expectedStatus api.CheckStatus
expectedScore float32
expectedScore int
}{
{
name: "Not listed",
@ -350,7 +350,7 @@ func TestGenerateSummaryCheck(t *testing.T) {
Checks: make([]RBLCheck, 6), // 6 default RBLs
},
expectedStatus: api.CheckStatusPass,
expectedScore: 20.0,
expectedScore: 200,
},
{
name: "Listed on 1 RBL",
@ -360,7 +360,7 @@ func TestGenerateSummaryCheck(t *testing.T) {
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 10.0,
expectedScore: 100,
},
{
name: "Listed on 2 RBLs",
@ -370,7 +370,7 @@ func TestGenerateSummaryCheck(t *testing.T) {
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 5.0,
expectedScore: 50,
},
{
name: "Listed on 4+ RBLs",
@ -380,7 +380,7 @@ func TestGenerateSummaryCheck(t *testing.T) {
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
expectedScore: 0,
},
}

View file

@ -36,6 +36,7 @@ type ReportGenerator struct {
dnsAnalyzer *DNSAnalyzer
rblChecker *RBLChecker
contentAnalyzer *ContentAnalyzer
headerAnalyzer *HeaderAnalyzer
scorer *DeliverabilityScorer
}
@ -51,6 +52,7 @@ func NewReportGenerator(
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
headerAnalyzer: NewHeaderAnalyzer(),
scorer: NewDeliverabilityScorer(),
}
}
@ -63,7 +65,6 @@ type AnalysisResults struct {
DNS *DNSResults
RBL *RBLResults
Content *ContentResults
Score *ScoringResult
}
// AnalyzeEmail performs complete email analysis
@ -79,15 +80,6 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
results.RBL = r.rblChecker.CheckEmail(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email)
// Calculate overall score
results.Score = r.scorer.CalculateScore(
results.Authentication,
results.SpamAssassin,
results.RBL,
results.Content,
email,
)
return results
}
@ -99,20 +91,9 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
report := &api.Report{
Id: utils.UUIDToBase32(reportID),
TestId: utils.UUIDToBase32(testID),
Score: results.Score.OverallScore,
Grade: ScoreToReportGrade(results.Score.OverallScore),
CreatedAt: now,
}
// Build score summary
report.Summary = &api.ScoreSummary{
AuthenticationScore: results.Score.AuthScore,
SpamScore: results.Score.SpamScore,
BlacklistScore: results.Score.BlacklistScore,
ContentScore: results.Score.ContentScore,
HeaderScore: results.Score.HeaderScore,
}
// Collect all checks from different analyzers
checks := []api.Check{}
@ -147,11 +128,40 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
}
// Header checks
headerChecks := r.scorer.GenerateHeaderChecks(results.Email)
headerChecks := r.headerAnalyzer.GenerateHeaderChecks(results.Email)
checks = append(checks, headerChecks...)
report.Checks = checks
// Summarize scores by category
categoryCounts := make(map[api.CheckCategory]int)
categoryTotals := make(map[api.CheckCategory]int)
for _, check := range checks {
if check.Status == "info" {
continue
}
categoryCounts[check.Category]++
categoryTotals[check.Category] += check.Score
}
// Calculate mean scores for each category
calcCategoryScore := func(category api.CheckCategory) int {
if count := categoryCounts[category]; count > 0 {
return categoryTotals[category] / count
}
return 0
}
report.Summary = &api.ScoreSummary{
AuthenticationScore: calcCategoryScore(api.Authentication),
BlacklistScore: calcCategoryScore(api.Blacklist),
ContentScore: calcCategoryScore(api.Content),
HeaderScore: calcCategoryScore(api.Headers),
SpamScore: calcCategoryScore(api.Spam),
}
// Add authentication results
report.Authentication = results.Authentication
@ -202,6 +212,30 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
report.RawHeaders = &results.Email.RawHeaders
}
// Calculate overall score as mean of all category scores
categoryScores := []int{
report.Summary.AuthenticationScore,
report.Summary.BlacklistScore,
report.Summary.ContentScore,
report.Summary.HeaderScore,
report.Summary.SpamScore,
}
var totalScore int
var categoryCount int
for _, score := range categoryScores {
totalScore += score
categoryCount++
}
if categoryCount > 0 {
report.Score = totalScore / categoryCount
} else {
report.Score = 0
}
report.Grade = ScoreToReportGrade(report.Score)
return report
}
@ -330,21 +364,3 @@ func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
return raw
}
// GetRecommendations returns actionable recommendations based on the score
func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string {
if results == nil || results.Score == nil {
return []string{}
}
return results.Score.Recommendations
}
// GetScoreSummaryText returns a human-readable score summary
func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string {
if results == nil || results.Score == nil {
return ""
}
return r.scorer.GetScoreSummary(results.Score)
}

View file

@ -336,13 +336,13 @@ func TestGetRecommendations(t *testing.T) {
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 1.0,
BlacklistScore: 1.5,
ContentScore: 0.5,
HeaderScore: 0.5,
OverallScore: 50,
Grade: ScoreToReportGrade(50),
AuthScore: 15,
SpamScore: 10,
BlacklistScore: 15,
ContentScore: 5,
HeaderScore: 5,
Recommendations: []string{
"Improve authentication",
"Fix content issues",
@ -381,19 +381,19 @@ func TestGetScoreSummaryText(t *testing.T) {
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
OverallScore: 85,
Grade: ScoreToReportGrade(85),
AuthScore: 25,
SpamScore: 18,
BlacklistScore: 20,
ContentScore: 15,
HeaderScore: 7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
"Authentication": {Score: 25, Status: "Pass"},
"Spam Filters": {Score: 18, Status: "Pass"},
"Blacklists": {Score: 20, Status: "Pass"},
"Content Quality": {Score: 15, Status: "Warn"},
"Email Structure": {Score: 7, Status: "Warn"},
},
},
},

View file

@ -22,15 +22,11 @@
package analyzer
import (
"fmt"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// ScoreToGrade converts a percentage score (0-100) to a letter grade
func ScoreToGrade(score float32) string {
func ScoreToGrade(score int) string {
switch {
case score >= 97:
return "A+"
@ -50,12 +46,12 @@ func ScoreToGrade(score float32) string {
}
// ScoreToCheckGrade converts a percentage score to an api.CheckGrade
func ScoreToCheckGrade(score float32) api.CheckGrade {
func ScoreToCheckGrade(score int) api.CheckGrade {
return api.CheckGrade(ScoreToGrade(score))
}
// ScoreToReportGrade converts a percentage score to an api.ReportGrade
func ScoreToReportGrade(score float32) api.ReportGrade {
func ScoreToReportGrade(score int) api.ReportGrade {
return api.ReportGrade(ScoreToGrade(score))
}
@ -66,520 +62,3 @@ type DeliverabilityScorer struct{}
func NewDeliverabilityScorer() *DeliverabilityScorer {
return &DeliverabilityScorer{}
}
// ScoringResult represents the complete scoring result
type ScoringResult struct {
OverallScore float32
Rating string // Excellent, Good, Fair, Poor, Critical
AuthScore float32
SpamScore float32
BlacklistScore float32
ContentScore float32
HeaderScore float32
Recommendations []string
CategoryBreakdown map[string]CategoryScore
}
// CategoryScore represents score breakdown for a category
type CategoryScore struct {
Score float32
MaxScore float32
Percentage float32
Status string // Pass, Warn, Fail
}
// CalculateScore computes the overall deliverability score from all analyzers
func (s *DeliverabilityScorer) CalculateScore(
authResults *api.AuthenticationResults,
spamResult *SpamAssassinResult,
rblResults *RBLResults,
contentResults *ContentResults,
email *EmailMessage,
) *ScoringResult {
result := &ScoringResult{
CategoryBreakdown: make(map[string]CategoryScore),
Recommendations: []string{},
}
// Calculate individual scores
result.AuthScore = s.GetAuthenticationScore(authResults)
spamAnalyzer := NewSpamAssassinAnalyzer()
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
rblChecker := NewRBLChecker(10*time.Second, DefaultRBLs)
result.BlacklistScore = rblChecker.GetBlacklistScore(rblResults)
contentAnalyzer := NewContentAnalyzer(10 * time.Second)
result.ContentScore = contentAnalyzer.GetContentScore(contentResults)
// Calculate header quality score
result.HeaderScore = s.calculateHeaderScore(email)
// Calculate overall score (out of 100)
result.OverallScore = result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
// Ensure score is within bounds
if result.OverallScore > 100.0 {
result.OverallScore = 100.0
}
if result.OverallScore < 0.0 {
result.OverallScore = 0.0
}
// Determine rating
result.Rating = s.determineRating(result.OverallScore)
// Build category breakdown
result.CategoryBreakdown["Authentication"] = CategoryScore{
Score: result.AuthScore,
MaxScore: 30.0,
Percentage: result.AuthScore,
Status: s.getCategoryStatus(result.AuthScore, 30.0),
}
result.CategoryBreakdown["Spam Filters"] = CategoryScore{
Score: result.SpamScore,
MaxScore: 20.0,
Percentage: result.SpamScore,
Status: s.getCategoryStatus(result.SpamScore, 20.0),
}
result.CategoryBreakdown["Blacklists"] = CategoryScore{
Score: result.BlacklistScore,
MaxScore: 20.0,
Percentage: result.BlacklistScore,
Status: s.getCategoryStatus(result.BlacklistScore, 20.0),
}
result.CategoryBreakdown["Content Quality"] = CategoryScore{
Score: result.ContentScore,
MaxScore: 20.0,
Percentage: result.ContentScore,
Status: s.getCategoryStatus(result.ContentScore, 20.0),
}
result.CategoryBreakdown["Email Structure"] = CategoryScore{
Score: result.HeaderScore,
MaxScore: 10.0,
Percentage: result.HeaderScore,
Status: s.getCategoryStatus(result.HeaderScore, 10.0),
}
// Generate recommendations
result.Recommendations = s.generateRecommendations(result)
return result
}
// calculateHeaderScore evaluates email structural quality (0-10 points)
func (s *DeliverabilityScorer) calculateHeaderScore(email *EmailMessage) float32 {
if email == nil {
return 0.0
}
score := float32(0.0)
requiredHeaders := 0
presentHeaders := 0
// Check required headers (RFC 5322)
headers := map[string]bool{
"From": false,
"Date": false,
"Message-ID": false,
}
for header := range headers {
requiredHeaders++
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
headers[header] = true
presentHeaders++
}
}
// Score based on required headers (4 points)
if presentHeaders == requiredHeaders {
score += 4.0
} else {
score += 4.0 * (float32(presentHeaders) / float32(requiredHeaders))
}
// Check recommended headers (3 points)
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
recommendedPresent := 0
for _, header := range recommendedHeaders {
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
recommendedPresent++
}
}
score += 3.0 * (float32(recommendedPresent) / float32(len(recommendedHeaders)))
// Check for proper MIME structure (2 points)
if len(email.Parts) > 0 {
score += 2.0
}
// Check Message-ID format (1 point)
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
if s.isValidMessageID(messageID) {
score += 1.0
}
}
// Ensure score doesn't exceed 10.0
if score > 10.0 {
score = 10.0
}
return score
}
// isValidMessageID checks if a Message-ID has proper format
func (s *DeliverabilityScorer) isValidMessageID(messageID string) bool {
// Basic check: should be in format <...@...>
if !strings.HasPrefix(messageID, "<") || !strings.HasSuffix(messageID, ">") {
return false
}
// Remove angle brackets
messageID = strings.TrimPrefix(messageID, "<")
messageID = strings.TrimSuffix(messageID, ">")
// Should contain @ symbol
if !strings.Contains(messageID, "@") {
return false
}
parts := strings.Split(messageID, "@")
if len(parts) != 2 {
return false
}
// Both parts should be non-empty
return len(parts[0]) > 0 && len(parts[1]) > 0
}
// determineRating determines the rating based on overall score (0-100)
func (s *DeliverabilityScorer) determineRating(score float32) string {
switch {
case score >= 90.0:
return "Excellent"
case score >= 70.0:
return "Good"
case score >= 50.0:
return "Fair"
case score >= 30.0:
return "Poor"
default:
return "Critical"
}
}
// getCategoryStatus determines status for a category
func (s *DeliverabilityScorer) getCategoryStatus(score, maxScore float32) string {
percentage := (score / maxScore) * 100
switch {
case percentage >= 80.0:
return "Pass"
case percentage >= 50.0:
return "Warn"
default:
return "Fail"
}
}
// generateRecommendations creates actionable recommendations based on scores
func (s *DeliverabilityScorer) generateRecommendations(result *ScoringResult) []string {
var recommendations []string
// Authentication recommendations (0-30 points)
if result.AuthScore < 20.0 {
recommendations = append(recommendations, "🔐 Improve email authentication by configuring SPF, DKIM, and DMARC records")
} else if result.AuthScore < 30.0 {
recommendations = append(recommendations, "🔐 Fine-tune your email authentication setup for optimal deliverability")
}
// Spam recommendations (0-20 points)
if result.SpamScore < 10.0 {
recommendations = append(recommendations, "⚠️ Reduce spam triggers by reviewing email content and avoiding spam-like patterns")
} else if result.SpamScore < 15.0 {
recommendations = append(recommendations, "⚠️ Monitor spam score and address any flagged content issues")
}
// Blacklist recommendations (0-20 points)
if result.BlacklistScore < 10.0 {
recommendations = append(recommendations, "🚫 Your IP is listed on blacklists - take immediate action to delist and improve sender reputation")
} else if result.BlacklistScore < 20.0 {
recommendations = append(recommendations, "🚫 Monitor your IP reputation and ensure clean sending practices")
}
// Content recommendations (0-20 points)
if result.ContentScore < 10.0 {
recommendations = append(recommendations, "📝 Improve email content quality: fix broken links, add alt text to images, and ensure proper HTML structure")
} else if result.ContentScore < 15.0 {
recommendations = append(recommendations, "📝 Enhance email content by optimizing images and ensuring text/HTML consistency")
}
// Header recommendations (0-10 points)
if result.HeaderScore < 5.0 {
recommendations = append(recommendations, "📧 Fix email structure by adding required headers (From, Date, Message-ID)")
} else if result.HeaderScore < 10.0 {
recommendations = append(recommendations, "📧 Improve email headers by ensuring all recommended fields are present")
}
// Overall recommendations based on rating
if result.Rating == "Excellent" {
recommendations = append(recommendations, "✅ Your email has excellent deliverability - maintain current practices")
} else if result.Rating == "Critical" {
recommendations = append(recommendations, "🆘 Critical issues detected - emails will likely be rejected or marked as spam")
}
return recommendations
}
// GenerateHeaderChecks creates checks for email header quality
func (s *DeliverabilityScorer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
var checks []api.Check
if email == nil {
return checks
}
// Required headers check
checks = append(checks, s.generateRequiredHeadersCheck(email))
// Recommended headers check
checks = append(checks, s.generateRecommendedHeadersCheck(email))
// Message-ID check
checks = append(checks, s.generateMessageIDCheck(email))
// MIME structure check
checks = append(checks, s.generateMIMEStructureCheck(email))
return checks
}
// generateRequiredHeadersCheck checks for required RFC 5322 headers
func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Required Headers",
}
requiredHeaders := []string{"From", "Date", "Message-ID"}
missing := []string{}
for _, header := range requiredHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 4.0
check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All required headers are present"
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
} else {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
}
return check
}
// generateRecommendedHeadersCheck checks for recommended headers
func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Recommended Headers",
}
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
missing := []string{}
for _, header := range recommendedHeaders {
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
missing = append(missing, header)
}
}
if len(missing) == 0 {
check.Status = api.CheckStatusPass
check.Score = 3.0
check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All recommended headers are present"
check.Advice = api.PtrTo("Your email includes all recommended headers")
} else if len(missing) < len(recommendedHeaders) {
check.Status = api.CheckStatusWarn
check.Score = 1.5
check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
check.Details = &details
} else {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Missing all recommended headers"
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
}
return check
}
// generateMessageIDCheck validates Message-ID header
func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "Message-ID Format",
}
messageID := email.GetHeaderValue("Message-ID")
if messageID == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = "Message-ID header is missing"
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
} else if !s.isValidMessageID(messageID) {
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Message-ID format is invalid"
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
check.Details = &messageID
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Message-ID is properly formatted"
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
check.Details = &messageID
}
return check
}
// generateMIMEStructureCheck validates MIME structure
func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
check := api.Check{
Category: api.Headers,
Name: "MIME Structure",
}
if len(email.Parts) == 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No MIME parts detected"
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
} else {
check.Status = api.CheckStatusPass
check.Score = 2.0
check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
check.Advice = api.PtrTo("Your email has proper MIME structure")
// Add details about parts
partTypes := []string{}
for _, part := range email.Parts {
if part.ContentType != "" {
partTypes = append(partTypes, part.ContentType)
}
}
if len(partTypes) > 0 {
details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
check.Details = &details
}
}
return check
}
// GetScoreSummary generates a human-readable summary of the score
func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
var summary strings.Builder
summary.WriteString(fmt.Sprintf("Overall Score: %.1f/100 (%s) - Grade: %s\n\n", result.OverallScore, result.Rating, ScoreToGrade(result.OverallScore)))
summary.WriteString("Category Breakdown:\n")
summary.WriteString(fmt.Sprintf(" • Authentication: %.1f/30.0 (%.0f%%) - %s\n",
result.AuthScore, result.CategoryBreakdown["Authentication"].Percentage, result.CategoryBreakdown["Authentication"].Status))
summary.WriteString(fmt.Sprintf(" • Spam Filters: %.1f/20.0 (%.0f%%) - %s\n",
result.SpamScore, result.CategoryBreakdown["Spam Filters"].Percentage, result.CategoryBreakdown["Spam Filters"].Status))
summary.WriteString(fmt.Sprintf(" • Blacklists: %.1f/20.0 (%.0f%%) - %s\n",
result.BlacklistScore, result.CategoryBreakdown["Blacklists"].Percentage, result.CategoryBreakdown["Blacklists"].Status))
summary.WriteString(fmt.Sprintf(" • Content Quality: %.1f/20.0 (%.0f%%) - %s\n",
result.ContentScore, result.CategoryBreakdown["Content Quality"].Percentage, result.CategoryBreakdown["Content Quality"].Status))
summary.WriteString(fmt.Sprintf(" • Email Structure: %.1f/10.0 (%.0f%%) - %s\n",
result.HeaderScore, result.CategoryBreakdown["Email Structure"].Percentage, result.CategoryBreakdown["Email Structure"].Status))
if len(result.Recommendations) > 0 {
summary.WriteString("\nRecommendations:\n")
for _, rec := range result.Recommendations {
summary.WriteString(fmt.Sprintf(" %s\n", rec))
}
}
return summary.String()
}
// GetAuthenticationScore calculates the authentication score (0-30 points)
func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
var score float32 = 0.0
// SPF: 10 points for pass, 5 for neutral/softfail, 0 for fail
if results.Spf != nil {
switch results.Spf.Result {
case api.AuthResultResultPass:
score += 10.0
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
score += 5.0
}
}
// DKIM: 10 points for at least one pass
if results.Dkim != nil && len(*results.Dkim) > 0 {
for _, dkim := range *results.Dkim {
if dkim.Result == api.AuthResultResultPass {
score += 10.0
break
}
}
}
// DMARC: 10 points for pass
if results.Dmarc != nil {
switch results.Dmarc.Result {
case api.AuthResultResultPass:
score += 10.0
}
}
// Cap at 30 points maximum
if score > 30.0 {
score = 30.0
}
return score
}

View file

@ -22,9 +22,6 @@
package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
@ -97,153 +94,6 @@ func TestIsValidMessageID(t *testing.T) {
}
}
func TestCalculateHeaderScore(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minScore float32
maxScore float32
}{
{
name: "Nil email",
email: nil,
minScore: 0.0,
maxScore: 0.0,
},
{
name: "Perfect headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 7.0,
maxScore: 10.0,
},
{
name: "Missing required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Subject": "Test",
}),
},
minScore: 0.0,
maxScore: 4.0,
},
{
name: "Required only, no recommended",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 4.0,
maxScore: 8.0,
},
{
name: "Invalid Message-ID format",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "invalid-message-id",
"Subject": "Test",
"To": "recipient@example.com",
"Reply-To": "reply@example.com",
}),
MessageID: "invalid-message-id",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 7.0,
maxScore: 10.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := scorer.calculateHeaderScore(tt.email)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
})
}
}
func TestDetermineRating(t *testing.T) {
tests := []struct {
name string
score float32
expected string
}{
{name: "Excellent - 10.0", score: 100.0, expected: "Excellent"},
{name: "Excellent - 9.5", score: 95.0, expected: "Excellent"},
{name: "Excellent - 9.0", score: 90.0, expected: "Excellent"},
{name: "Good - 8.5", score: 85.0, expected: "Good"},
{name: "Good - 7.0", score: 70.0, expected: "Good"},
{name: "Fair - 6.5", score: 65.0, expected: "Fair"},
{name: "Fair - 5.0", score: 50.0, expected: "Fair"},
{name: "Poor - 4.5", score: 45.0, expected: "Poor"},
{name: "Poor - 3.0", score: 30.0, expected: "Poor"},
{name: "Critical - 2.5", score: 25.0, expected: "Critical"},
{name: "Critical - 0.0", score: 0.0, expected: "Critical"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.determineRating(tt.score)
if result != tt.expected {
t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected)
}
})
}
}
func TestGetCategoryStatus(t *testing.T) {
tests := []struct {
name string
score float32
maxScore float32
expected string
}{
{name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"},
{name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"},
{name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"},
{name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"},
{name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.getCategoryStatus(tt.score, tt.maxScore)
if result != tt.expected {
t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected)
}
})
}
}
func TestCalculateScore(t *testing.T) {
tests := []struct {
name string
@ -252,9 +102,9 @@ func TestCalculateScore(t *testing.T) {
rblResults *RBLResults
contentResults *ContentResults
email *EmailMessage
minScore float32
maxScore float32
expectedRating string
minScore int
maxScore int
expectedGrade string
}{
{
name: "Perfect email",
@ -294,9 +144,9 @@ func TestCalculateScore(t *testing.T) {
MessageID: "<abc123@example.com>",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 90.0,
maxScore: 100.0,
expectedRating: "Excellent",
minScore: 90.0,
maxScore: 100.0,
expectedGrade: "A+",
},
{
name: "Poor email - auth issues",
@ -329,9 +179,9 @@ func TestCalculateScore(t *testing.T) {
"From": "sender@example.com",
}),
},
minScore: 0.0,
maxScore: 50.0,
expectedRating: "Poor",
minScore: 0.0,
maxScore: 50.0,
expectedGrade: "C",
},
{
name: "Average email",
@ -366,9 +216,9 @@ func TestCalculateScore(t *testing.T) {
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 60.0,
maxScore: 90.0,
expectedRating: "Good",
minScore: 60.0,
maxScore: 90.0,
expectedGrade: "A",
},
}
@ -394,8 +244,8 @@ func TestCalculateScore(t *testing.T) {
}
// Check rating
if result.Rating != tt.expectedRating {
t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating)
if result.Grade != api.ReportGrade(tt.expectedGrade) {
t.Errorf("Grade = %q, want %q", result.Grade, tt.expectedGrade)
}
// Verify score is within bounds
@ -409,354 +259,16 @@ func TestCalculateScore(t *testing.T) {
}
// Verify recommendations exist
if len(result.Recommendations) == 0 && result.Rating != "Excellent" {
if len(result.Recommendations) == 0 && result.Grade != "A+" {
t.Error("Expected recommendations for non-excellent rating")
}
// Verify category scores add up to overall score
totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 {
t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)",
if totalCategoryScore != result.OverallScore {
t.Errorf("Category scores sum (%d) doesn't match overall score (%d)",
totalCategoryScore, result.OverallScore)
}
})
}
}
func TestGenerateRecommendations(t *testing.T) {
tests := []struct {
name string
result *ScoringResult
expectedMinCount int
shouldContainKeyword string
}{
{
name: "Excellent - minimal recommendations",
result: &ScoringResult{
OverallScore: 9.5,
Rating: "Excellent",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 2.0,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "Excellent",
},
{
name: "Critical - many recommendations",
result: &ScoringResult{
OverallScore: 1.0,
Rating: "Critical",
AuthScore: 0.5,
SpamScore: 0.0,
BlacklistScore: 0.0,
ContentScore: 0.3,
HeaderScore: 0.2,
},
expectedMinCount: 5,
shouldContainKeyword: "Critical",
},
{
name: "Poor authentication",
result: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "authentication",
},
{
name: "Blacklist issues",
result: &ScoringResult{
OverallScore: 4.0,
Rating: "Poor",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 0.5,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "blacklist",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recommendations := scorer.generateRecommendations(tt.result)
if len(recommendations) < tt.expectedMinCount {
t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount)
}
// Check if expected keyword appears in any recommendation
found := false
for _, rec := range recommendations {
if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) {
found = true
break
}
}
if !found {
t.Errorf("No recommendation contains keyword %q. Recommendations: %v",
tt.shouldContainKeyword, recommendations)
}
})
}
}
func TestGenerateRequiredHeadersCheck(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "All required headers present",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
From: &mail.Address{Address: "sender@example.com"},
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedStatus: api.CheckStatusPass,
expectedScore: 4.0,
},
{
name: "Missing all required headers",
email: &EmailMessage{
Header: make(mail.Header),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "Missing some required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := scorer.generateRequiredHeadersCheck(tt.email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMessageIDCheck(t *testing.T) {
tests := []struct {
name string
messageID string
expectedStatus api.CheckStatus
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expectedStatus: api.CheckStatusPass,
},
{
name: "Invalid Message-ID format",
messageID: "invalid-message-id",
expectedStatus: api.CheckStatusWarn,
},
{
name: "Missing Message-ID",
messageID: "",
expectedStatus: api.CheckStatusFail,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Message-ID": tt.messageID,
}),
}
check := scorer.generateMessageIDCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMIMEStructureCheck(t *testing.T) {
tests := []struct {
name string
parts []MessagePart
expectedStatus api.CheckStatus
}{
{
name: "With MIME parts",
parts: []MessagePart{
{ContentType: "text/plain", Content: "test"},
{ContentType: "text/html", Content: "<p>test</p>"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No MIME parts",
parts: []MessagePart{},
expectedStatus: api.CheckStatusWarn,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
Parts: tt.parts,
}
check := scorer.generateMIMEStructureCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateHeaderChecks(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minChecks int
}{
{
name: "Nil email",
email: nil,
minChecks: 0,
},
{
name: "Complete email",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minChecks: 4, // Required, Recommended, Message-ID, MIME
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := scorer.GenerateHeaderChecks(tt.email)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Headers category
for _, check := range checks {
if check.Category != api.Headers {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers)
}
}
})
}
}
func TestGetScoreSummary(t *testing.T) {
result := &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
},
Recommendations: []string{
"Improve content quality",
"Add more headers",
},
}
scorer := NewDeliverabilityScorer()
summary := scorer.GetScoreSummary(result)
// Check that summary contains key information
if !strings.Contains(summary, "8.5") {
t.Error("Summary should contain overall score")
}
if !strings.Contains(summary, "Good") {
t.Error("Summary should contain rating")
}
if !strings.Contains(summary, "Authentication") {
t.Error("Summary should contain category names")
}
if !strings.Contains(summary, "Recommendations") {
t.Error("Summary should contain recommendations section")
}
}
// Helper function to create mail.Header with specific fields
func createHeaderWithFields(fields map[string]string) mail.Header {
header := make(mail.Header)
for key, value := range fields {
if value != "" {
// Use canonical MIME header key format
canonicalKey := textproto.CanonicalMIMEHeaderKey(key)
header[canonicalKey] = []string{value}
}
}
return header
}

View file

@ -23,6 +23,7 @@ package analyzer
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
@ -174,41 +175,28 @@ func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssass
}
}
// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-20 points)
// Scoring:
// - Score <= 0: 20 points (excellent)
// - Score < required: 15 points (good)
// - Score slightly above required (< 2x): 10 points (borderline)
// - Score moderately high (< 3x required): 5 points (poor)
// - Score very high: 0 points (spam)
func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 {
// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability
func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) int {
if result == nil {
return 0.0
return 0
}
score := result.Score
required := result.RequiredScore
if required == 0 {
required = 5.0 // Default SpamAssassin threshold
required = 5 // Default SpamAssassin threshold
}
// Calculate deliverability score
if score <= 0 {
return 20.0
} else if score < required {
// Linear scaling from 15 to 20 based on how negative/low the score is
ratio := score / required
return 15.0 + (5.0 * (1.0 - float32(ratio)))
} else if score < required*2 {
// Slightly above threshold
return 10.0
} else if score < required*3 {
// Moderately high
return 5.0
return 100
}
if score <= required*4 {
return 0
}
// Very high spam score
return 0.0
// Linear scaling based on how negative/low the score is
return 100 - int(math.Round(25*score/required))
}
// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
@ -259,9 +247,8 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult)
required = 5.0
}
delivScore := a.GetSpamAssassinScore(result)
check.Score = delivScore
check.Grade = ScoreToCheckGrade((delivScore / 20.0) * 100)
check.Score = a.GetSpamAssassinScore(result)
check.Grade = ScoreToCheckGrade(check.Score)
// Determine status and message based on score
if score <= 0 {
@ -320,7 +307,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
check.Severity = api.PtrTo(api.CheckSeverityMedium)
}
check.Score = 0.0
check.Grade = ScoreToCheckGrade(0.0)
check.Grade = ScoreToCheckGrade(0)
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
check.Advice = &advice
@ -339,11 +326,3 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
return check
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -154,14 +154,14 @@ func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
result *SpamAssassinResult
expectedScore float32
minScore float32
maxScore float32
expectedScore int
minScore int
maxScore int
}{
{
name: "Nil result",
result: nil,
expectedScore: 0.0,
expectedScore: 0,
},
{
name: "Excellent score (negative)",
@ -169,7 +169,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
Score: -2.5,
RequiredScore: 5.0,
},
expectedScore: 20.0,
expectedScore: 100,
},
{
name: "Good score (below threshold)",
@ -177,8 +177,8 @@ func TestGetSpamAssassinScore(t *testing.T) {
Score: 2.0,
RequiredScore: 5.0,
},
minScore: 15.0,
maxScore: 20.0,
minScore: 80,
maxScore: 100,
},
{
name: "Borderline (just above threshold)",
@ -186,7 +186,8 @@ func TestGetSpamAssassinScore(t *testing.T) {
Score: 6.0,
RequiredScore: 5.0,
},
expectedScore: 10.0,
minScore: 60,
maxScore: 80,
},
{
name: "High spam score",
@ -194,7 +195,8 @@ func TestGetSpamAssassinScore(t *testing.T) {
Score: 12.0,
RequiredScore: 5.0,
},
expectedScore: 5.0,
minScore: 20,
maxScore: 50,
},
{
name: "Very high spam score",
@ -202,7 +204,7 @@ func TestGetSpamAssassinScore(t *testing.T) {
Score: 20.0,
RequiredScore: 5.0,
},
expectedScore: 0.0,
expectedScore: 0,
},
}
@ -618,8 +620,8 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
// Test GetSpamAssassinScore
score := analyzer.GetSpamAssassinScore(result)
if score != 20.0 {
t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (excellent score for negative spam score)", score)
if score != 100 {
t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score)
}
// Test GenerateSpamAssassinChecks
@ -639,14 +641,14 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
if !strings.Contains(mainCheck.Message, "spam score") {
t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
}
if mainCheck.Score != 20.0 {
t.Errorf("Main check score = %v, want 20.0", mainCheck.Score)
if mainCheck.Score != 100 {
t.Errorf("Main check score = %v, want 100", mainCheck.Score)
}
// Log all checks for debugging
t.Logf("Generated %d checks:", len(checks))
for i, check := range checks {
t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
t.Logf(" Check %d: %s - %s (score: %d, status: %s)",
i+1, check.Name, check.Message, check.Score, check.Status)
}
}

View file

@ -32,7 +32,7 @@
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<h5 class="fw-bold mb-1">{check.name}</h5>
<span class="badge bg-light text-dark">{check.score.toFixed(1)} pts</span>
<span class="badge bg-light text-dark">{check.score}%</span>
</div>
<p class="mt-2 mb-2">{check.message}</p>
@ -48,7 +48,7 @@
{#if check.details}
<details class="small text-muted">
<summary class="cursor-pointer">Technical Details</summary>
<pre class="mt-2 mb-0 small bg-light p-2 rounded">{check.details}</pre>
<pre class="mt-2 mb-0 small bg-light p-2 rounded" style="white-space: pre-wrap;">{check.details}</pre>
</details>
{/if}
</div>

View file

@ -2,25 +2,26 @@
import type { ScoreSummary } from "$lib/api/types.gen";
interface Props {
grade: string;
score: number;
summary?: ScoreSummary;
}
let { score, summary }: Props = $props();
let { grade, score, summary }: Props = $props();
function getScoreClass(score: number): string {
if (score >= 9) return "score-excellent";
if (score >= 7) return "score-good";
if (score >= 5) return "score-warning";
if (score >= 3) return "score-poor";
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 {
if (score >= 9) return "Excellent";
if (score >= 7) return "Good";
if (score >= 5) return "Fair";
if (score >= 3) return "Poor";
if (score >= 90) return "Excellent";
if (score >= 70) return "Good";
if (score >= 50) return "Fair";
if (score >= 30) return "Poor";
return "Critical";
}
</script>
@ -28,7 +29,7 @@
<div class="card shadow-lg bg-white">
<div class="card-body p-5 text-center">
<h1 class="display-1 fw-bold mb-3 {getScoreClass(score)}">
{score.toFixed(1)}/10
{grade}
</h1>
<h3 class="fw-bold mb-2">{getScoreLabel(score)}</h3>
<p class="text-muted mb-4">Overall Deliverability Score</p>
@ -39,12 +40,12 @@
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.authentication_score >= 3}
class:text-warning={summary.authentication_score < 3 &&
summary.authentication_score >= 1.5}
class:text-danger={summary.authentication_score < 1.5}
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.toFixed(1)}/3
{summary.authentication_score}%
</strong>
<small class="text-muted d-block">Authentication</small>
</div>
@ -53,11 +54,11 @@
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.spam_score >= 2}
class:text-warning={summary.spam_score < 2 && summary.spam_score >= 1}
class:text-danger={summary.spam_score < 1}
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.toFixed(1)}/2
{summary.spam_score}%
</strong>
<small class="text-muted d-block">Spam Score</small>
</div>
@ -66,12 +67,12 @@
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.blacklist_score >= 2}
class:text-warning={summary.blacklist_score < 2 &&
summary.blacklist_score >= 1}
class:text-danger={summary.blacklist_score < 1}
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.toFixed(1)}/2
{summary.blacklist_score}%
</strong>
<small class="text-muted d-block">Blacklists</small>
</div>
@ -80,12 +81,12 @@
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.content_score >= 2}
class:text-warning={summary.content_score < 2 &&
summary.content_score >= 1}
class:text-danger={summary.content_score < 1}
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.toFixed(1)}/2
{summary.content_score}%
</strong>
<small class="text-muted d-block">Content</small>
</div>
@ -94,12 +95,12 @@
<div class="p-2 bg-light rounded text-center">
<strong
class="fs-2"
class:text-success={summary.header_score >= 1}
class:text-warning={summary.header_score < 1 &&
summary.header_score >= 0.5}
class:text-danger={summary.header_score < 0.5}
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.toFixed(1)}/1
{summary.header_score}%
</strong>
<small class="text-muted d-block">Headers</small>
</div>

View file

@ -17,9 +17,9 @@
// Group checks by category
let groupedChecks = $derived(() => {
if (!report) return {};
if (!report) return { };
const groups: Record<string, typeof report.checks> = {};
const groups: Record<string, typeof report.checks> = { };
for (const check of report.checks) {
if (!groups[check.category]) {
groups[check.category] = [];
@ -106,31 +106,10 @@
}
function getCategoryScore(checks: typeof report.checks): number {
return checks.reduce((sum, check) => sum + check.score, 0);
return Math.round(checks.reduce((sum, check) => sum + check.score, 0) / checks.filter((c) => c.status != "info").length);
}
function getCategoryMaxScore(category: string): number {
switch (category) {
case "authentication":
return 3;
case "spam":
return 2;
case "blacklist":
return 2;
case "content":
return 2;
case "headers":
return 1;
case "dns":
return 0; // DNS checks contribute to other categories
default:
return 0;
}
}
function getScoreColorClass(score: number, maxScore: number): string {
if (maxScore === 0) return "text-muted";
const percentage = (score / maxScore) * 100;
function getScoreColorClass(percentage: number): string {
if (percentage >= 80) return "text-success";
if (percentage >= 50) return "text-warning";
return "text-danger";
@ -189,7 +168,7 @@
<!-- Score Header -->
<div class="row mb-4">
<div class="col-12">
<ScoreCard score={report.score} summary={report.summary} />
<ScoreCard grade={report.grade} score={report.score} summary={report.summary} />
</div>
</div>
@ -199,15 +178,14 @@
<h3 class="fw-bold mb-3">Detailed Checks</h3>
{#each Object.entries(groupedChecks()) as [category, checks]}
{@const categoryScore = getCategoryScore(checks)}
{@const maxScore = getCategoryMaxScore(category)}
<div class="category-section mb-4">
<h4 class="category-title text-capitalize mb-3 d-flex justify-content-between align-items-center">
<span>
<i class="bi {getCategoryIcon(category)} me-2"></i>
{category}
</span>
<span class="category-score {getScoreColorClass(categoryScore, maxScore)}">
{categoryScore.toFixed(1)}{#if maxScore > 0} / {maxScore}{/if} pts
<span class="category-score {getScoreColorClass(categoryScore)}">
{categoryScore}%
</span>
</h4>
{#each checks as check}