Score as percentages
This commit is contained in:
parent
dfc0eeb323
commit
74aee54432
23 changed files with 1027 additions and 1488 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
303
pkg/analyzer/headers.go
Normal 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
|
||||
}
|
||||
324
pkg/analyzer/headers_test.go
Normal file
324
pkg/analyzer/headers_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue