Remove checks
This commit is contained in:
parent
954a9d705e
commit
0b4d32dada
28 changed files with 1656 additions and 3846 deletions
|
|
@ -191,7 +191,7 @@ func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
|
|||
result.Selector = &selector
|
||||
}
|
||||
|
||||
result.Details = &part
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "dkim="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
|||
result.Domain = &domain
|
||||
}
|
||||
|
||||
result.Details = &part
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "dmarc="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -246,7 +246,7 @@ func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
|||
result.Selector = &selector
|
||||
}
|
||||
|
||||
result.Details = &part
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "bimi="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -263,7 +263,7 @@ func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
|||
result.Result = api.ARCResultResult(resultStr)
|
||||
}
|
||||
|
||||
result.Details = &part
|
||||
result.Details = api.PtrTo(strings.TrimPrefix(part, "arc="))
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
@ -467,3 +467,75 @@ func textprotoCanonical(s string) string {
|
|||
}
|
||||
return strings.Join(words, "-")
|
||||
}
|
||||
|
||||
// CalculateAuthenticationScore calculates the authentication score from auth results
|
||||
// Returns a score from 0-100 where higher is better
|
||||
func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *api.AuthenticationResults) int {
|
||||
if results == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
score := 0
|
||||
|
||||
// SPF (30 points)
|
||||
if results.Spf != nil {
|
||||
switch results.Spf.Result {
|
||||
case api.AuthResultResultPass:
|
||||
score += 30
|
||||
case api.AuthResultResultNeutral, api.AuthResultResultNone:
|
||||
score += 15
|
||||
case api.AuthResultResultSoftfail:
|
||||
score += 5
|
||||
default: // fail, temperror, permerror
|
||||
score += 0
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM (30 points) - at least one passing signature
|
||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||
hasPass := false
|
||||
for _, dkim := range *results.Dkim {
|
||||
if dkim.Result == api.AuthResultResultPass {
|
||||
hasPass = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasPass {
|
||||
score += 30
|
||||
} else {
|
||||
// Has DKIM signatures but none passed
|
||||
score += 10
|
||||
}
|
||||
}
|
||||
|
||||
// DMARC (30 points)
|
||||
if results.Dmarc != nil {
|
||||
switch results.Dmarc.Result {
|
||||
case api.AuthResultResultPass:
|
||||
score += 30
|
||||
case api.AuthResultResultNone:
|
||||
score += 10
|
||||
default: // fail
|
||||
score += 0
|
||||
}
|
||||
}
|
||||
|
||||
// BIMI (10 points)
|
||||
if results.Bimi != nil {
|
||||
switch results.Bimi.Result {
|
||||
case api.AuthResultResultPass:
|
||||
score += 10
|
||||
case api.AuthResultResultNone:
|
||||
score += 5
|
||||
default: // fail
|
||||
score += 0
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score doesn't exceed 100
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,317 +0,0 @@
|
|||
// 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"
|
||||
)
|
||||
|
||||
// GenerateAuthenticationChecks generates check results for authentication
|
||||
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
// SPF check
|
||||
if results.Spf != nil {
|
||||
check := a.generateSPFCheck(results.Spf)
|
||||
checks = append(checks, check)
|
||||
} else {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "SPF Record",
|
||||
Status: api.CheckStatusWarn,
|
||||
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"),
|
||||
})
|
||||
}
|
||||
|
||||
// DKIM check
|
||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||
for i, dkim := range *results.Dkim {
|
||||
check := a.generateDKIMCheck(&dkim, i)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
} else {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "DKIM Signature",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 0,
|
||||
Message: "No DKIM signature found",
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
|
||||
})
|
||||
}
|
||||
|
||||
// DMARC check
|
||||
if results.Dmarc != nil {
|
||||
check := a.generateDMARCCheck(results.Dmarc)
|
||||
checks = append(checks, check)
|
||||
} else {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "DMARC Policy",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 0,
|
||||
Message: "No DMARC authentication result found",
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
||||
})
|
||||
}
|
||||
|
||||
// BIMI check (optional, informational only)
|
||||
if results.Bimi != nil {
|
||||
check := a.generateBIMICheck(results.Bimi)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
// ARC check (optional, for forwarded emails)
|
||||
if results.Arc != nil {
|
||||
check := a.generateARCCheck(results.Arc)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "SPF Record",
|
||||
}
|
||||
|
||||
switch spf.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 100
|
||||
check.Message = "SPF validation passed"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
case api.AuthResultResultFail:
|
||||
check.Status = api.CheckStatusFail
|
||||
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 = 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 = 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
|
||||
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.Details != nil {
|
||||
check.Details = spf.Details
|
||||
} else if spf.Domain != nil {
|
||||
details := fmt.Sprintf("Domain: %s", *spf.Domain)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
|
||||
}
|
||||
|
||||
switch dkim.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
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
|
||||
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
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "DMARC Policy",
|
||||
}
|
||||
|
||||
switch dmarc.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
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
|
||||
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
|
||||
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.Details != nil {
|
||||
check.Details = dmarc.Details
|
||||
} else if dmarc.Domain != nil {
|
||||
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateBIMICheck(bimi *api.AuthResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "BIMI (Brand Indicators)",
|
||||
}
|
||||
|
||||
switch bimi.Result {
|
||||
case api.AuthResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
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
|
||||
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
|
||||
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.Details != nil {
|
||||
check.Details = bimi.Details
|
||||
} else if bimi.Domain != nil {
|
||||
details := fmt.Sprintf("Domain: %s", *bimi.Domain)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
func (a *AuthenticationAnalyzer) generateARCCheck(arc *api.ARCResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Authentication,
|
||||
Name: "ARC (Authenticated Received Chain)",
|
||||
}
|
||||
|
||||
switch arc.Result {
|
||||
case api.ARCResultResultPass:
|
||||
check.Status = api.CheckStatusPass
|
||||
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
|
||||
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
|
||||
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")
|
||||
}
|
||||
|
||||
if arc.Details != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
|
|
@ -246,250 +245,6 @@ func TestParseBIMIResult(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateAuthSPFCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spf *api.AuthResult
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "SPF pass",
|
||||
spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "SPF fail",
|
||||
spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultFail,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "SPF softfail",
|
||||
spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultSoftfail,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 5,
|
||||
},
|
||||
{
|
||||
name: "SPF neutral",
|
||||
spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultNeutral,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 5,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateSPFCheck(tt.spf)
|
||||
|
||||
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.Authentication {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||
}
|
||||
if check.Name != "SPF Record" {
|
||||
t.Errorf("Name = %q, want %q", check.Name, "SPF Record")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAuthDKIMCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dkim *api.AuthResult
|
||||
index int
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "DKIM pass",
|
||||
dkim: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
Selector: api.PtrTo("default"),
|
||||
},
|
||||
index: 0,
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "DKIM fail",
|
||||
dkim: &api.AuthResult{
|
||||
Result: api.AuthResultResultFail,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
Selector: api.PtrTo("default"),
|
||||
},
|
||||
index: 0,
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "DKIM none",
|
||||
dkim: &api.AuthResult{
|
||||
Result: api.AuthResultResultNone,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
Selector: api.PtrTo("default"),
|
||||
},
|
||||
index: 0,
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateDKIMCheck(tt.dkim, tt.index)
|
||||
|
||||
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.Authentication {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||
}
|
||||
if !strings.Contains(check.Name, "DKIM Signature") {
|
||||
t.Errorf("Name should contain 'DKIM Signature', got %q", check.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAuthDMARCCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dmarc *api.AuthResult
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "DMARC pass",
|
||||
dmarc: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "DMARC fail",
|
||||
dmarc: &api.AuthResult{
|
||||
Result: api.AuthResultResultFail,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateDMARCCheck(tt.dmarc)
|
||||
|
||||
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.Authentication {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||
}
|
||||
if check.Name != "DMARC Policy" {
|
||||
t.Errorf("Name = %q, want %q", check.Name, "DMARC Policy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAuthBIMICheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bimi *api.AuthResult
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "BIMI pass",
|
||||
bimi: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0, // BIMI doesn't contribute to score
|
||||
},
|
||||
{
|
||||
name: "BIMI fail",
|
||||
bimi: &api.AuthResult{
|
||||
Result: api.AuthResultResultFail,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusInfo,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "BIMI none",
|
||||
bimi: &api.AuthResult{
|
||||
Result: api.AuthResultResultNone,
|
||||
Domain: api.PtrTo("example.com"),
|
||||
},
|
||||
expectedStatus: api.CheckStatusInfo,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateBIMICheck(tt.bimi)
|
||||
|
||||
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.Authentication {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||
}
|
||||
if check.Name != "BIMI (Brand Indicators)" {
|
||||
t.Errorf("Name = %q, want %q", check.Name, "BIMI (Brand Indicators)")
|
||||
}
|
||||
|
||||
// BIMI should always have score of 0.0 (branding feature)
|
||||
if check.Score != 0.0 {
|
||||
t.Error("BIMI should not contribute to deliverability score")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthenticationScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -563,11 +318,11 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
scorer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := scorer.GetAuthenticationScore(tt.results)
|
||||
score := scorer.CalculateAuthenticationScore(tt.results)
|
||||
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
|
||||
|
|
@ -576,92 +331,6 @@ func TestGetAuthenticationScore(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateAuthenticationChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *api.AuthenticationResults
|
||||
expectedChecks int
|
||||
}{
|
||||
{
|
||||
name: "All authentication methods present",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Bimi: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
},
|
||||
expectedChecks: 4, // SPF, DKIM, DMARC, BIMI
|
||||
},
|
||||
{
|
||||
name: "Without BIMI",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
},
|
||||
expectedChecks: 3, // SPF, DKIM, DMARC
|
||||
},
|
||||
{
|
||||
name: "No authentication results",
|
||||
results: &api.AuthenticationResults{},
|
||||
expectedChecks: 3, // SPF, DKIM, DMARC warnings for missing
|
||||
},
|
||||
{
|
||||
name: "With ARC",
|
||||
results: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: &api.AuthResult{
|
||||
Result: api.AuthResultResultPass,
|
||||
},
|
||||
Arc: &api.ARCResult{
|
||||
Result: api.ARCResultResultPass,
|
||||
ChainLength: api.PtrTo(2),
|
||||
ChainValid: api.PtrTo(true),
|
||||
},
|
||||
},
|
||||
expectedChecks: 4, // SPF, DKIM, DMARC, ARC
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateAuthenticationChecks(tt.results)
|
||||
|
||||
if len(checks) != tt.expectedChecks {
|
||||
t.Errorf("Got %d checks, want %d", len(checks), tt.expectedChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the Authentication category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Authentication {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Authentication)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseARCResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -783,64 +452,3 @@ func TestValidateARCChain(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateARCCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
arc *api.ARCResult
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "ARC pass",
|
||||
arc: &api.ARCResult{
|
||||
Result: api.ARCResultResultPass,
|
||||
ChainLength: api.PtrTo(2),
|
||||
ChainValid: api.PtrTo(true),
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0, // ARC doesn't contribute to score
|
||||
},
|
||||
{
|
||||
name: "ARC fail",
|
||||
arc: &api.ARCResult{
|
||||
Result: api.ARCResultResultFail,
|
||||
ChainLength: api.PtrTo(1),
|
||||
ChainValid: api.PtrTo(false),
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "ARC none",
|
||||
arc: &api.ARCResult{
|
||||
Result: api.ARCResultResultNone,
|
||||
ChainLength: api.PtrTo(0),
|
||||
ChainValid: api.PtrTo(true),
|
||||
},
|
||||
expectedStatus: api.CheckStatusInfo,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewAuthenticationAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateARCCheck(tt.arc)
|
||||
|
||||
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.Authentication {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Authentication)
|
||||
}
|
||||
if !strings.Contains(check.Name, "ARC") {
|
||||
t.Errorf("Name should contain 'ARC', got %q", check.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -459,294 +459,151 @@ func (c *ContentAnalyzer) normalizeText(text string) string {
|
|||
return text
|
||||
}
|
||||
|
||||
// GenerateContentChecks generates check results for content analysis
|
||||
func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
// GenerateContentAnalysis creates structured content analysis from results
|
||||
func (c *ContentAnalyzer) GenerateContentAnalysis(results *ContentResults) *api.ContentAnalysis {
|
||||
if results == nil {
|
||||
return checks
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTML validity check
|
||||
checks = append(checks, c.generateHTMLValidityCheck(results))
|
||||
|
||||
// Link checks
|
||||
checks = append(checks, c.generateLinkChecks(results)...)
|
||||
|
||||
// Image checks
|
||||
checks = append(checks, c.generateImageChecks(results)...)
|
||||
|
||||
// Unsubscribe link check
|
||||
checks = append(checks, c.generateUnsubscribeCheck(results))
|
||||
|
||||
// Text/HTML consistency check
|
||||
if results.TextContent != "" && results.HTMLContent != "" {
|
||||
checks = append(checks, c.generateTextConsistencyCheck(results))
|
||||
analysis := &api.ContentAnalysis{
|
||||
HasHtml: api.PtrTo(results.HTMLContent != ""),
|
||||
HasPlaintext: api.PtrTo(results.TextContent != ""),
|
||||
HasUnsubscribeLink: api.PtrTo(results.HasUnsubscribe),
|
||||
}
|
||||
|
||||
// Image-to-text ratio check
|
||||
// Calculate text-to-image ratio (inverse of image-to-text)
|
||||
if len(results.Images) > 0 && results.HTMLContent != "" {
|
||||
checks = append(checks, c.generateImageRatioCheck(results))
|
||||
}
|
||||
|
||||
// Suspicious URLs check
|
||||
if len(results.SuspiciousURLs) > 0 {
|
||||
checks = append(checks, c.generateSuspiciousURLCheck(results))
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateHTMLValidityCheck creates a check for HTML validity
|
||||
func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "HTML Structure",
|
||||
}
|
||||
|
||||
if !results.HTMLValid {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Message = "HTML structure is invalid"
|
||||
if len(results.HTMLErrors) > 0 {
|
||||
details := strings.Join(results.HTMLErrors, "; ")
|
||||
check.Details = &details
|
||||
}
|
||||
check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering")
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 2
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = "HTML structure is valid"
|
||||
check.Advice = api.PtrTo("Your HTML is well-formed")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateLinkChecks creates checks for links
|
||||
func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if len(results.Links) == 0 {
|
||||
return checks
|
||||
}
|
||||
|
||||
// Count broken links
|
||||
brokenLinks := 0
|
||||
warningLinks := 0
|
||||
for _, link := range results.Links {
|
||||
if link.Status >= 400 {
|
||||
brokenLinks++
|
||||
} else if link.Warning != "" {
|
||||
warningLinks++
|
||||
textLen := float32(len(c.extractTextFromHTML(results.HTMLContent)))
|
||||
if textLen > 0 {
|
||||
ratio := textLen / float32(len(results.Images))
|
||||
analysis.TextToImageRatio = &ratio
|
||||
}
|
||||
}
|
||||
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Links",
|
||||
}
|
||||
// Build HTML issues
|
||||
htmlIssues := []api.ContentIssue{}
|
||||
|
||||
if brokenLinks > 0 {
|
||||
check.Status = api.CheckStatusFail
|
||||
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")
|
||||
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
|
||||
check.Details = &details
|
||||
} else if warningLinks > 0 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
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")
|
||||
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
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")
|
||||
}
|
||||
|
||||
checks = append(checks, check)
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateImageChecks creates checks for images
|
||||
func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if len(results.Images) == 0 {
|
||||
return checks
|
||||
}
|
||||
|
||||
// Count images without alt text
|
||||
noAltCount := 0
|
||||
for _, img := range results.Images {
|
||||
if !img.HasAlt {
|
||||
noAltCount++
|
||||
// Add HTML parsing errors
|
||||
if !results.HTMLValid && len(results.HTMLErrors) > 0 {
|
||||
for _, errMsg := range results.HTMLErrors {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.BrokenHtml,
|
||||
Severity: api.ContentIssueSeverityHigh,
|
||||
Message: errMsg,
|
||||
Advice: api.PtrTo("Fix HTML structure errors to improve email rendering across clients"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Image Alt Attributes",
|
||||
// Add missing alt text issues
|
||||
if len(results.Images) > 0 {
|
||||
missingAltCount := 0
|
||||
for _, img := range results.Images {
|
||||
if !img.HasAlt {
|
||||
missingAltCount++
|
||||
}
|
||||
}
|
||||
if missingAltCount > 0 {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.MissingAlt,
|
||||
Severity: api.ContentIssueSeverityMedium,
|
||||
Message: fmt.Sprintf("%d image(s) missing alt attributes", missingAltCount),
|
||||
Advice: api.PtrTo("Add descriptive alt text to all images for better accessibility and deliverability"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if noAltCount == len(results.Images) {
|
||||
check.Status = api.CheckStatusFail
|
||||
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")
|
||||
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||
check.Details = &details
|
||||
} else if noAltCount > 0 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
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")
|
||||
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
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")
|
||||
// Add excessive images issue
|
||||
if results.ImageTextRatio > 10.0 {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.ExcessiveImages,
|
||||
Severity: api.ContentIssueSeverityMedium,
|
||||
Message: "Email is excessively image-heavy",
|
||||
Advice: api.PtrTo("Reduce the number of images relative to text content"),
|
||||
})
|
||||
}
|
||||
|
||||
checks = append(checks, check)
|
||||
return checks
|
||||
// Add suspicious URL issues
|
||||
for _, suspURL := range results.SuspiciousURLs {
|
||||
htmlIssues = append(htmlIssues, api.ContentIssue{
|
||||
Type: api.SuspiciousLink,
|
||||
Severity: api.ContentIssueSeverityHigh,
|
||||
Message: "Suspicious URL detected",
|
||||
Location: &suspURL,
|
||||
Advice: api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails"),
|
||||
})
|
||||
}
|
||||
|
||||
if len(htmlIssues) > 0 {
|
||||
analysis.HtmlIssues = &htmlIssues
|
||||
}
|
||||
|
||||
// Convert links
|
||||
if len(results.Links) > 0 {
|
||||
links := make([]api.LinkCheck, 0, len(results.Links))
|
||||
for _, link := range results.Links {
|
||||
status := api.Valid
|
||||
if link.Status >= 400 {
|
||||
status = api.Broken
|
||||
} else if !link.IsSafe {
|
||||
status = api.Suspicious
|
||||
} else if link.Warning != "" {
|
||||
status = api.Timeout
|
||||
}
|
||||
|
||||
apiLink := api.LinkCheck{
|
||||
Url: link.URL,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if link.Status > 0 {
|
||||
apiLink.HttpCode = api.PtrTo(link.Status)
|
||||
}
|
||||
|
||||
// Check if it's a URL shortener
|
||||
parsedURL, err := url.Parse(link.URL)
|
||||
if err == nil {
|
||||
isShortened := c.isSuspiciousURL(link.URL, parsedURL)
|
||||
apiLink.IsShortened = api.PtrTo(isShortened)
|
||||
}
|
||||
|
||||
links = append(links, apiLink)
|
||||
}
|
||||
analysis.Links = &links
|
||||
}
|
||||
|
||||
// Convert images
|
||||
if len(results.Images) > 0 {
|
||||
images := make([]api.ImageCheck, 0, len(results.Images))
|
||||
for _, img := range results.Images {
|
||||
apiImg := api.ImageCheck{
|
||||
HasAlt: img.HasAlt,
|
||||
}
|
||||
if img.Src != "" {
|
||||
apiImg.Src = &img.Src
|
||||
}
|
||||
if img.AltText != "" {
|
||||
apiImg.AltText = &img.AltText
|
||||
}
|
||||
// Simple heuristic: tracking pixels are typically 1x1
|
||||
apiImg.IsTrackingPixel = api.PtrTo(false)
|
||||
|
||||
images = append(images, apiImg)
|
||||
}
|
||||
analysis.Images = &images
|
||||
}
|
||||
|
||||
// Unsubscribe methods
|
||||
if results.HasUnsubscribe {
|
||||
methods := []api.ContentAnalysisUnsubscribeMethods{api.Link}
|
||||
analysis.UnsubscribeMethods = &methods
|
||||
}
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// generateUnsubscribeCheck creates a check for unsubscribe links
|
||||
func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Unsubscribe Link",
|
||||
}
|
||||
|
||||
if !results.HasUnsubscribe {
|
||||
check.Status = api.CheckStatusWarn
|
||||
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 = 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")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateTextConsistencyCheck creates a check for text/HTML consistency
|
||||
func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Plain Text Consistency",
|
||||
}
|
||||
|
||||
consistency := results.TextPlainRatio
|
||||
|
||||
if consistency < 0.3 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
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")
|
||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
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")
|
||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateImageRatioCheck creates a check for image-to-text ratio
|
||||
func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Image-to-Text Ratio",
|
||||
}
|
||||
|
||||
ratio := results.ImageTextRatio
|
||||
|
||||
// Flag if more than 1 image per 100 characters (very image-heavy)
|
||||
if ratio > 10.0 {
|
||||
check.Status = api.CheckStatusFail
|
||||
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")
|
||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||
check.Details = &details
|
||||
} else if ratio > 5.0 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
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")
|
||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
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")
|
||||
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateSuspiciousURLCheck creates a check for suspicious URLs
|
||||
func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Content,
|
||||
Name: "Suspicious URLs",
|
||||
}
|
||||
|
||||
count := len(results.SuspiciousURLs)
|
||||
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Score = 0.0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
|
||||
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
|
||||
|
||||
if count <= 3 {
|
||||
details := strings.Join(results.SuspiciousURLs, ", ")
|
||||
check.Details = &details
|
||||
} else {
|
||||
details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// GetContentScore calculates the content score (0-20 points)
|
||||
func (c *ContentAnalyzer) GetContentScore(results *ContentResults) int {
|
||||
// CalculateContentScore calculates the content score (0-20 points)
|
||||
func (c *ContentAnalyzer) CalculateContentScore(results *ContentResults) int {
|
||||
if results == nil {
|
||||
return 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
|
|
@ -608,453 +607,6 @@ func TestAnalyzeContent_ImageAltAttributes(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateHTMLValidityCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Valid HTML",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 2,
|
||||
},
|
||||
{
|
||||
name: "Invalid HTML",
|
||||
results: &ContentResults{
|
||||
HTMLValid: false,
|
||||
HTMLErrors: []string{"Parse error"},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateHTMLValidityCheck(tt.results)
|
||||
|
||||
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.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateLinkChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "All links valid",
|
||||
results: &ContentResults{
|
||||
Links: []LinkCheck{
|
||||
{URL: "https://example.com", Valid: true, Status: 200},
|
||||
{URL: "https://example.org", Valid: true, Status: 200},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 4,
|
||||
},
|
||||
{
|
||||
name: "Broken links",
|
||||
results: &ContentResults{
|
||||
Links: []LinkCheck{
|
||||
{URL: "https://example.com", Valid: true, Status: 404, Error: "Not found"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "Links with warnings",
|
||||
results: &ContentResults{
|
||||
Links: []LinkCheck{
|
||||
{URL: "https://example.com", Valid: true, Warning: "Could not verify"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 3,
|
||||
},
|
||||
{
|
||||
name: "No links",
|
||||
results: &ContentResults{},
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.generateLinkChecks(tt.results)
|
||||
|
||||
if tt.name == "No links" {
|
||||
if len(checks) != 0 {
|
||||
t.Errorf("Expected no checks, got %d", len(checks))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(checks) == 0 {
|
||||
t.Fatal("Expected at least one check")
|
||||
}
|
||||
|
||||
check := checks[0]
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "All images have alt",
|
||||
results: &ContentResults{
|
||||
Images: []ImageCheck{
|
||||
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
|
||||
{Src: "img2.jpg", HasAlt: true, AltText: "Alt 2"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "No images have alt",
|
||||
results: &ContentResults{
|
||||
Images: []ImageCheck{
|
||||
{Src: "img1.jpg", HasAlt: false},
|
||||
{Src: "img2.jpg", HasAlt: false},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
},
|
||||
{
|
||||
name: "Some images have alt",
|
||||
results: &ContentResults{
|
||||
Images: []ImageCheck{
|
||||
{Src: "img1.jpg", HasAlt: true, AltText: "Alt 1"},
|
||||
{Src: "img2.jpg", HasAlt: false},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.generateImageChecks(tt.results)
|
||||
|
||||
if len(checks) == 0 {
|
||||
t.Fatal("Expected at least one check")
|
||||
}
|
||||
|
||||
check := checks[0]
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUnsubscribeCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "Has unsubscribe link",
|
||||
results: &ContentResults{
|
||||
HasUnsubscribe: true,
|
||||
UnsubscribeLinks: []string{"https://example.com/unsubscribe"},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "No unsubscribe link",
|
||||
results: &ContentResults{},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateUnsubscribeCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTextConsistencyCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "High consistency",
|
||||
results: &ContentResults{
|
||||
TextPlainRatio: 0.8,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "Low consistency",
|
||||
results: &ContentResults{
|
||||
TextPlainRatio: 0.1,
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateTextConsistencyCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImageRatioCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "Reasonable ratio",
|
||||
results: &ContentResults{
|
||||
ImageTextRatio: 3.0,
|
||||
Images: []ImageCheck{{}, {}, {}},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
{
|
||||
name: "High ratio",
|
||||
results: &ContentResults{
|
||||
ImageTextRatio: 7.0,
|
||||
Images: make([]ImageCheck, 7),
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
{
|
||||
name: "Excessive ratio",
|
||||
results: &ContentResults{
|
||||
ImageTextRatio: 15.0,
|
||||
Images: make([]ImageCheck, 15),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateImageRatioCheck(tt.results)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSuspiciousURLCheck(t *testing.T) {
|
||||
results := &ContentResults{
|
||||
SuspiciousURLs: []string{
|
||||
"https://bit.ly/abc123",
|
||||
"https://192.168.1.1/page",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
check := analyzer.generateSuspiciousURLCheck(results)
|
||||
|
||||
if check.Status != api.CheckStatusWarn {
|
||||
t.Errorf("Status = %v, want %v", check.Status, api.CheckStatusWarn)
|
||||
}
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Content)
|
||||
}
|
||||
if !strings.Contains(check.Message, "2") {
|
||||
t.Error("Message should mention the count of suspicious URLs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
minScore int
|
||||
maxScore int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minScore: 0,
|
||||
maxScore: 0,
|
||||
},
|
||||
{
|
||||
name: "Perfect content",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: true,
|
||||
TextPlainRatio: 0.8,
|
||||
ImageTextRatio: 3.0,
|
||||
},
|
||||
minScore: 90,
|
||||
maxScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Poor content",
|
||||
results: &ContentResults{
|
||||
HTMLValid: false,
|
||||
Links: []LinkCheck{{Valid: true, Status: 404}},
|
||||
Images: []ImageCheck{{HasAlt: false}},
|
||||
HasUnsubscribe: false,
|
||||
TextPlainRatio: 0.1,
|
||||
ImageTextRatio: 15.0,
|
||||
SuspiciousURLs: []string{"url1", "url2"},
|
||||
},
|
||||
minScore: 0,
|
||||
maxScore: 25,
|
||||
},
|
||||
{
|
||||
name: "Average content",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: false,
|
||||
TextPlainRatio: 0.5,
|
||||
ImageTextRatio: 4.0,
|
||||
},
|
||||
minScore: 50,
|
||||
maxScore: 90,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := analyzer.GetContentScore(tt.results)
|
||||
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
t.Errorf("GetContentScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Errorf("Score %v is negative", score)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateContentChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *ContentResults
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minChecks: 0,
|
||||
},
|
||||
{
|
||||
name: "Complete results",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: true,
|
||||
TextContent: "Plain text",
|
||||
HTMLContent: "<p>HTML text</p>",
|
||||
ImageTextRatio: 3.0,
|
||||
},
|
||||
minChecks: 5, // HTML, Links, Images, Unsubscribe, Text consistency, Image ratio
|
||||
},
|
||||
{
|
||||
name: "With suspicious URLs",
|
||||
results: &ContentResults{
|
||||
HTMLValid: true,
|
||||
SuspiciousURLs: []string{"url1"},
|
||||
},
|
||||
minChecks: 3, // HTML, Unsubscribe, Suspicious URLs
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewContentAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateContentChecks(tt.results)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the Content category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Content {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Content)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for testing
|
||||
|
||||
func parseHTML(htmlStr string) (*html.Node, error) {
|
||||
|
|
|
|||
|
|
@ -492,232 +492,3 @@ func (d *DNSAnalyzer) validateBIMI(record string) bool {
|
|||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateDNSChecks generates check results for DNS validation
|
||||
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if results == nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
// MX record check
|
||||
checks = append(checks, d.generateMXCheck(results))
|
||||
|
||||
// SPF record check
|
||||
if results.SPFRecord != nil {
|
||||
checks = append(checks, d.generateSPFCheck(results.SPFRecord))
|
||||
}
|
||||
|
||||
// DKIM record checks
|
||||
for _, dkim := range results.DKIMRecords {
|
||||
checks = append(checks, d.generateDKIMCheck(&dkim))
|
||||
}
|
||||
|
||||
// DMARC record check
|
||||
if results.DMARCRecord != nil {
|
||||
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
|
||||
}
|
||||
|
||||
// BIMI record check (optional)
|
||||
if results.BIMIRecord != nil {
|
||||
checks = append(checks, d.generateBIMICheck(results.BIMIRecord, results.DMARCRecord))
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateMXCheck creates a check for MX records
|
||||
func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "MX Records",
|
||||
}
|
||||
|
||||
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
|
||||
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
|
||||
check.Message = results.MXRecords[0].Error
|
||||
} else {
|
||||
check.Message = "No valid MX records found"
|
||||
}
|
||||
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 100
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
|
||||
|
||||
// Add details about MX records
|
||||
var mxList []string
|
||||
for _, mx := range results.MXRecords {
|
||||
mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
|
||||
}
|
||||
details := strings.Join(mxList, ", ")
|
||||
check.Details = &details
|
||||
check.Advice = api.PtrTo("Your MX records are properly configured")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateSPFCheck creates a check for SPF records
|
||||
func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "SPF Record",
|
||||
}
|
||||
|
||||
if !spf.Valid {
|
||||
if spf.Record == "" {
|
||||
// If no record exists at all, it's a failure
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 0
|
||||
check.Message = spf.Error
|
||||
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 failure
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Score = 5
|
||||
check.Message = "SPF record found but appears invalid"
|
||||
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 = 100
|
||||
check.Message = "Valid SPF record found"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Details = &spf.Record
|
||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateDKIMCheck creates a check for DKIM records
|
||||
func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
|
||||
}
|
||||
|
||||
if !dkim.Valid {
|
||||
check.Status = api.CheckStatusFail
|
||||
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")
|
||||
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
||||
check.Details = &details
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
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)
|
||||
check.Details = &details
|
||||
check.Advice = api.PtrTo("Your DKIM record is properly published")
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateDMARCCheck creates a check for DMARC records
|
||||
func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "DMARC Record",
|
||||
}
|
||||
|
||||
if !dmarc.Valid {
|
||||
check.Status = api.CheckStatusFail
|
||||
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 = 100
|
||||
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Details = &dmarc.Record
|
||||
|
||||
// Provide advice based on policy
|
||||
switch dmarc.Policy {
|
||||
case "none":
|
||||
advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
|
||||
check.Advice = &advice
|
||||
case "quarantine":
|
||||
advice := "DMARC policy is set to 'quarantine'. This provides good protection"
|
||||
check.Advice = &advice
|
||||
case "reject":
|
||||
advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
|
||||
check.Advice = &advice
|
||||
default:
|
||||
advice := "Your DMARC record is properly configured"
|
||||
check.Advice = &advice
|
||||
}
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateBIMICheck creates a check for BIMI records
|
||||
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord, dmarc *DMARCRecord) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Dns,
|
||||
Name: "BIMI Record",
|
||||
}
|
||||
|
||||
if !bimi.Valid {
|
||||
// BIMI is optional, so missing record is just informational
|
||||
if bimi.Record == "" {
|
||||
check.Status = api.CheckStatusInfo
|
||||
check.Score = 0
|
||||
check.Message = "No BIMI record found (optional)"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
||||
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 = 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=)")
|
||||
check.Details = &bimi.Record
|
||||
}
|
||||
} else {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 100 // BIMI doesn't contribute to score (branding feature)
|
||||
check.Message = "Valid BIMI record found"
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
|
||||
// Build details with logo and VMC URLs
|
||||
var detailsParts []string
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", bimi.Selector))
|
||||
if bimi.LogoURL != "" {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("Logo URL: %s", bimi.LogoURL))
|
||||
}
|
||||
if bimi.VMCURL != "" {
|
||||
detailsParts = append(detailsParts, fmt.Sprintf("VMC URL: %s", bimi.VMCURL))
|
||||
check.Advice = api.PtrTo("Your BIMI record is properly configured with a Verified Mark Certificate")
|
||||
} else {
|
||||
check.Advice = api.PtrTo("Your BIMI record is properly configured. Consider adding a Verified Mark Certificate (VMC) for enhanced trust")
|
||||
}
|
||||
|
||||
details := strings.Join(detailsParts, ", ")
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,8 @@ package analyzer
|
|||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestNewDNSAnalyzer(t *testing.T) {
|
||||
|
|
@ -300,338 +297,6 @@ func TestValidateDMARC(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateMXCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *DNSResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Valid MX records",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
{Host: "mail2.example.com", Priority: 20, Valid: true},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "No MX records",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Valid: false, Error: "No MX records found"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "MX lookup failed",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Valid: false, Error: "DNS lookup failed"},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateMXCheck(tt.results)
|
||||
|
||||
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.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSPFCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spf *SPFRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Valid SPF",
|
||||
spf: &SPFRecord{
|
||||
Record: "v=spf1 include:_spf.example.com -all",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "Invalid SPF",
|
||||
spf: &SPFRecord{
|
||||
Record: "v=spf1 invalid syntax",
|
||||
Valid: false,
|
||||
Error: "SPF record appears malformed",
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 5,
|
||||
},
|
||||
{
|
||||
name: "No SPF record",
|
||||
spf: &SPFRecord{
|
||||
Valid: false,
|
||||
Error: "No SPF record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateSPFCheck(tt.spf)
|
||||
|
||||
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.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDKIMCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dkim *DKIMRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Valid DKIM",
|
||||
dkim: &DKIMRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "Invalid DKIM",
|
||||
dkim: &DKIMRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Valid: false,
|
||||
Error: "No DKIM record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateDKIMCheck(tt.dkim)
|
||||
|
||||
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.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
if !strings.Contains(check.Name, tt.dkim.Selector) {
|
||||
t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDMARCCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dmarc *DMARCRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Valid DMARC - reject",
|
||||
dmarc: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=reject",
|
||||
Policy: "reject",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "Valid DMARC - quarantine",
|
||||
dmarc: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=quarantine",
|
||||
Policy: "quarantine",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "Valid DMARC - none",
|
||||
dmarc: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=none",
|
||||
Policy: "none",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 10,
|
||||
},
|
||||
{
|
||||
name: "No DMARC record",
|
||||
dmarc: &DMARCRecord{
|
||||
Valid: false,
|
||||
Error: "No DMARC record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateDMARCCheck(tt.dmarc)
|
||||
|
||||
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.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
|
||||
// Check that advice mentions policy for valid DMARC
|
||||
if tt.dmarc.Valid && check.Advice != nil {
|
||||
if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
|
||||
t.Error("Advice should mention 'none' policy")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDNSChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *DNSResults
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minChecks: 0,
|
||||
},
|
||||
{
|
||||
name: "Complete results",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
},
|
||||
SPFRecord: &SPFRecord{
|
||||
Record: "v=spf1 include:_spf.example.com -all",
|
||||
Valid: true,
|
||||
},
|
||||
DKIMRecords: []DKIMRecord{
|
||||
{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
DMARCRecord: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=quarantine",
|
||||
Policy: "quarantine",
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
minChecks: 4, // MX, SPF, DKIM, DMARC
|
||||
},
|
||||
{
|
||||
name: "Partial results",
|
||||
results: &DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
},
|
||||
},
|
||||
minChecks: 1, // Only MX
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateDNSChecks(tt.results)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the DNS category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Dns {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDNS_NoDomain(t *testing.T) {
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
email := &EmailMessage{
|
||||
Header: make(mail.Header),
|
||||
// No From address
|
||||
}
|
||||
|
||||
results := analyzer.AnalyzeDNS(email, nil)
|
||||
|
||||
if results == nil {
|
||||
t.Fatal("Expected results, got nil")
|
||||
}
|
||||
|
||||
if len(results.Errors) == 0 {
|
||||
t.Error("Expected error when no domain can be extracted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBIMITag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -732,89 +397,3 @@ func TestValidateBIMI(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBIMICheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bimi *BIMIRecord
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Valid BIMI with logo only",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=BIMI1; l=https://example.com/logo.svg",
|
||||
LogoURL: "https://example.com/logo.svg",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0, // BIMI doesn't contribute to score
|
||||
},
|
||||
{
|
||||
name: "Valid BIMI with VMC",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
||||
LogoURL: "https://example.com/logo.svg",
|
||||
VMCURL: "https://example.com/vmc.pem",
|
||||
Valid: true,
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "No BIMI record (optional)",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Valid: false,
|
||||
Error: "No BIMI record found",
|
||||
},
|
||||
expectedStatus: api.CheckStatusInfo,
|
||||
expectedScore: 0,
|
||||
},
|
||||
{
|
||||
name: "Invalid BIMI record",
|
||||
bimi: &BIMIRecord{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=BIMI1",
|
||||
Valid: false,
|
||||
Error: "BIMI record appears malformed",
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateBIMICheck(tt.bimi)
|
||||
|
||||
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.Dns {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
|
||||
}
|
||||
if check.Name != "BIMI Record" {
|
||||
t.Errorf("Name = %q, want %q", check.Name, "BIMI Record")
|
||||
}
|
||||
|
||||
// Check details for valid BIMI with VMC
|
||||
if tt.bimi.Valid && tt.bimi.VMCURL != "" && check.Details != nil {
|
||||
if !strings.Contains(*check.Details, "VMC URL") {
|
||||
t.Error("Details should contain VMC URL for valid BIMI with VMC")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,56 +36,53 @@ func NewHeaderAnalyzer() *HeaderAnalyzer {
|
|||
return &HeaderAnalyzer{}
|
||||
}
|
||||
|
||||
// calculateHeaderScore evaluates email structural quality
|
||||
func (h *HeaderAnalyzer) calculateHeaderScore(email *EmailMessage) int {
|
||||
if email == nil {
|
||||
// CalculateHeaderScore evaluates email structural quality from header analysis
|
||||
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.HeaderAnalysis) int {
|
||||
if analysis == nil || analysis.Headers == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
score := 0
|
||||
requiredHeaders := 0
|
||||
presentHeaders := 0
|
||||
headers := *analysis.Headers
|
||||
|
||||
// Check required headers (RFC 5322)
|
||||
headers := map[string]bool{
|
||||
"From": false,
|
||||
"Date": false,
|
||||
"Message-ID": false,
|
||||
}
|
||||
// Check required headers (RFC 5322) - 40 points
|
||||
requiredHeaders := []string{"from", "date", "message-id"}
|
||||
requiredCount := len(requiredHeaders)
|
||||
presentRequired := 0
|
||||
|
||||
for header := range headers {
|
||||
requiredHeaders++
|
||||
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
|
||||
headers[header] = true
|
||||
presentHeaders++
|
||||
for _, headerName := range requiredHeaders {
|
||||
if check, exists := headers[headerName]; exists && check.Present {
|
||||
presentRequired++
|
||||
}
|
||||
}
|
||||
|
||||
// Score based on required headers (40 points)
|
||||
if presentHeaders == requiredHeaders {
|
||||
if presentRequired == requiredCount {
|
||||
score += 40
|
||||
} else {
|
||||
score += int(40 * (float32(presentHeaders) / float32(requiredHeaders)))
|
||||
score += int(40 * (float32(presentRequired) / float32(requiredCount)))
|
||||
}
|
||||
|
||||
// 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++
|
||||
recommendedHeaders := []string{"subject", "to", "reply-to"}
|
||||
recommendedCount := len(recommendedHeaders)
|
||||
presentRecommended := 0
|
||||
|
||||
for _, headerName := range recommendedHeaders {
|
||||
if check, exists := headers[headerName]; exists && check.Present {
|
||||
presentRecommended++
|
||||
}
|
||||
}
|
||||
score += int(30 * (float32(recommendedPresent) / float32(len(recommendedHeaders))))
|
||||
score += int(30 * (float32(presentRecommended) / float32(recommendedCount)))
|
||||
|
||||
// Check for proper MIME structure (20 points)
|
||||
if len(email.Parts) > 0 {
|
||||
if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Check Message-ID format (10 point)
|
||||
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
|
||||
if h.isValidMessageID(messageID) {
|
||||
// Check Message-ID format (10 points)
|
||||
if check, exists := headers["message-id"]; exists && check.Present {
|
||||
// If Valid is set and true, award points
|
||||
if check.Valid != nil && *check.Valid {
|
||||
score += 10
|
||||
}
|
||||
}
|
||||
|
|
@ -123,181 +120,187 @@ func (h *HeaderAnalyzer) isValidMessageID(messageID string) bool {
|
|||
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
|
||||
|
||||
// GenerateHeaderAnalysis creates structured header analysis from email
|
||||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.HeaderAnalysis {
|
||||
if email == nil {
|
||||
return checks
|
||||
return nil
|
||||
}
|
||||
|
||||
// Required headers check
|
||||
checks = append(checks, h.generateRequiredHeadersCheck(email))
|
||||
analysis := &api.HeaderAnalysis{}
|
||||
|
||||
// Recommended headers check
|
||||
checks = append(checks, h.generateRecommendedHeadersCheck(email))
|
||||
// Check for proper MIME structure
|
||||
analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0)
|
||||
|
||||
// Message-ID check
|
||||
checks = append(checks, h.generateMessageIDCheck(email))
|
||||
// Initialize headers map
|
||||
headers := make(map[string]api.HeaderCheck)
|
||||
|
||||
// MIME structure check
|
||||
checks = append(checks, h.generateMIMEStructureCheck(email))
|
||||
// Check required headers
|
||||
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
||||
for _, headerName := range requiredHeaders {
|
||||
check := h.checkHeader(email, headerName, "required")
|
||||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
return checks
|
||||
// Check recommended headers
|
||||
recommendedHeaders := []string{"Reply-To", "Return-Path"}
|
||||
for _, headerName := range recommendedHeaders {
|
||||
check := h.checkHeader(email, headerName, "recommended")
|
||||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
// Check optional headers
|
||||
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post", "Precedence"}
|
||||
for _, headerName := range optionalHeaders {
|
||||
check := h.checkHeader(email, headerName, "optional")
|
||||
headers[strings.ToLower(headerName)] = *check
|
||||
}
|
||||
|
||||
analysis.Headers = &headers
|
||||
|
||||
// Domain alignment
|
||||
domainAlignment := h.analyzeDomainAlignment(email)
|
||||
if domainAlignment != nil {
|
||||
analysis.DomainAlignment = domainAlignment
|
||||
}
|
||||
|
||||
// Header issues
|
||||
issues := h.findHeaderIssues(email)
|
||||
if len(issues) > 0 {
|
||||
analysis.Issues = &issues
|
||||
}
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// generateRequiredHeadersCheck checks for required RFC 5322 headers
|
||||
func (h *HeaderAnalyzer) generateRequiredHeadersCheck(email *EmailMessage) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Headers,
|
||||
Name: "Required Headers",
|
||||
// checkHeader checks if a header is present and valid
|
||||
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *api.HeaderCheck {
|
||||
value := email.GetHeaderValue(headerName)
|
||||
present := email.HasHeader(headerName) && value != ""
|
||||
|
||||
importanceEnum := api.HeaderCheckImportance(importance)
|
||||
check := &api.HeaderCheck{
|
||||
Present: present,
|
||||
Importance: &importanceEnum,
|
||||
}
|
||||
|
||||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||||
missing := []string{}
|
||||
if present {
|
||||
check.Value = &value
|
||||
|
||||
// Validate specific headers
|
||||
valid := true
|
||||
var headerIssues []string
|
||||
|
||||
switch headerName {
|
||||
case "Message-ID":
|
||||
if !h.isValidMessageID(value) {
|
||||
valid = false
|
||||
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
||||
}
|
||||
case "Date":
|
||||
// Could add date validation here
|
||||
}
|
||||
|
||||
check.Valid = &valid
|
||||
if len(headerIssues) > 0 {
|
||||
check.Issues = &headerIssues
|
||||
}
|
||||
} else {
|
||||
valid := false
|
||||
check.Valid = &valid
|
||||
if importance == "required" {
|
||||
issues := []string{"Required header is missing"}
|
||||
check.Issues = &issues
|
||||
}
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// analyzeDomainAlignment checks domain alignment between headers
|
||||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage) *api.DomainAlignment {
|
||||
alignment := &api.DomainAlignment{
|
||||
Aligned: api.PtrTo(true),
|
||||
}
|
||||
|
||||
// Extract From domain
|
||||
fromAddr := email.GetHeaderValue("From")
|
||||
if fromAddr != "" {
|
||||
domain := h.extractDomain(fromAddr)
|
||||
if domain != "" {
|
||||
alignment.FromDomain = &domain
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Return-Path domain
|
||||
returnPath := email.GetHeaderValue("Return-Path")
|
||||
if returnPath != "" {
|
||||
domain := h.extractDomain(returnPath)
|
||||
if domain != "" {
|
||||
alignment.ReturnPathDomain = &domain
|
||||
}
|
||||
}
|
||||
|
||||
// Check alignment
|
||||
issues := []string{}
|
||||
if alignment.FromDomain != nil && alignment.ReturnPathDomain != nil {
|
||||
if *alignment.FromDomain != *alignment.ReturnPathDomain {
|
||||
*alignment.Aligned = false
|
||||
issues = append(issues, "Return-Path domain does not match From domain")
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
alignment.Issues = &issues
|
||||
}
|
||||
|
||||
return alignment
|
||||
}
|
||||
|
||||
// extractDomain extracts domain from email address
|
||||
func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
|
||||
// Remove angle brackets if present
|
||||
emailAddr = strings.Trim(emailAddr, "<> ")
|
||||
|
||||
// Find @ symbol
|
||||
atIndex := strings.LastIndex(emailAddr, "@")
|
||||
if atIndex == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
domain := emailAddr[atIndex+1:]
|
||||
// Remove any trailing >
|
||||
domain = strings.TrimRight(domain, ">")
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
// findHeaderIssues identifies issues with headers
|
||||
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue {
|
||||
var issues []api.HeaderIssue
|
||||
|
||||
// Check for missing required headers
|
||||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||||
for _, header := range requiredHeaders {
|
||||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||||
missing = append(missing, header)
|
||||
issues = append(issues, api.HeaderIssue{
|
||||
Header: header,
|
||||
Severity: api.HeaderIssueSeverityCritical,
|
||||
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
||||
Advice: api.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", 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",
|
||||
}
|
||||
|
||||
// Check 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
|
||||
if messageID != "" && !h.isValidMessageID(messageID) {
|
||||
issues = append(issues, api.HeaderIssue{
|
||||
Header: "Message-ID",
|
||||
Severity: api.HeaderIssueSeverityMedium,
|
||||
Message: "Message-ID format is invalid",
|
||||
Advice: api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
return issues
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ import (
|
|||
"net/mail"
|
||||
"net/textproto"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestCalculateHeaderScore(t *testing.T) {
|
||||
|
|
@ -109,95 +107,70 @@ func TestCalculateHeaderScore(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := analyzer.calculateHeaderScore(tt.email)
|
||||
// Generate header analysis first
|
||||
analysis := analyzer.GenerateHeaderAnalysis(tt.email)
|
||||
score := analyzer.CalculateHeaderScore(analysis)
|
||||
if score < tt.minScore || score > tt.maxScore {
|
||||
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
|
||||
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequiredHeadersCheck(t *testing.T) {
|
||||
func TestCheckHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email *EmailMessage
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
name string
|
||||
headerName string
|
||||
headerValue string
|
||||
importance string
|
||||
expectedPresent bool
|
||||
expectedValid bool
|
||||
expectedIssuesLen 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: "Valid Message-ID",
|
||||
headerName: "Message-ID",
|
||||
headerValue: "<abc123@example.com>",
|
||||
importance: "required",
|
||||
expectedPresent: true,
|
||||
expectedValid: true,
|
||||
expectedIssuesLen: 0,
|
||||
},
|
||||
{
|
||||
name: "Missing all required headers",
|
||||
email: &EmailMessage{
|
||||
Header: make(mail.Header),
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
name: "Invalid Message-ID format",
|
||||
headerName: "Message-ID",
|
||||
headerValue: "invalid-message-id",
|
||||
importance: "required",
|
||||
expectedPresent: true,
|
||||
expectedValid: false,
|
||||
expectedIssuesLen: 1,
|
||||
},
|
||||
{
|
||||
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: "Missing required header",
|
||||
headerName: "From",
|
||||
headerValue: "",
|
||||
importance: "required",
|
||||
expectedPresent: false,
|
||||
expectedValid: false,
|
||||
expectedIssuesLen: 1,
|
||||
},
|
||||
{
|
||||
name: "Invalid Message-ID format",
|
||||
messageID: "invalid-message-id",
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
name: "Missing optional header",
|
||||
headerName: "Reply-To",
|
||||
headerValue: "",
|
||||
importance: "optional",
|
||||
expectedPresent: false,
|
||||
expectedValid: false,
|
||||
expectedIssuesLen: 0,
|
||||
},
|
||||
{
|
||||
name: "Missing Message-ID",
|
||||
messageID: "",
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
name: "Valid Date header",
|
||||
headerName: "Date",
|
||||
headerValue: "Mon, 01 Jan 2024 12:00:00 +0000",
|
||||
importance: "required",
|
||||
expectedPresent: true,
|
||||
expectedValid: true,
|
||||
expectedIssuesLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -207,86 +180,77 @@ func TestGenerateMessageIDCheck(t *testing.T) {
|
|||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"Message-ID": tt.messageID,
|
||||
tt.headerName: tt.headerValue,
|
||||
}),
|
||||
}
|
||||
|
||||
check := analyzer.generateMessageIDCheck(email)
|
||||
check := analyzer.checkHeader(email, tt.headerName, tt.importance)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
if check.Present != tt.expectedPresent {
|
||||
t.Errorf("Present = %v, want %v", check.Present, tt.expectedPresent)
|
||||
}
|
||||
if check.Category != api.Headers {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
|
||||
|
||||
if check.Valid != nil && *check.Valid != tt.expectedValid {
|
||||
t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid)
|
||||
}
|
||||
|
||||
if check.Importance == nil {
|
||||
t.Error("Importance is nil")
|
||||
} else if string(*check.Importance) != tt.importance {
|
||||
t.Errorf("Importance = %v, want %v", *check.Importance, tt.importance)
|
||||
}
|
||||
|
||||
issuesLen := 0
|
||||
if check.Issues != nil {
|
||||
issuesLen = len(*check.Issues)
|
||||
}
|
||||
if issuesLen != tt.expectedIssuesLen {
|
||||
t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectedIssuesLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func TestHeaderAnalyzer_IsValidMessageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email *EmailMessage
|
||||
minChecks int
|
||||
messageID string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Nil email",
|
||||
email: nil,
|
||||
minChecks: 0,
|
||||
name: "Valid Message-ID",
|
||||
messageID: "<abc123@example.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
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
|
||||
name: "Valid with complex local part",
|
||||
messageID: "<complex.id-123_xyz@subdomain.example.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Missing angle brackets",
|
||||
messageID: "abc123@example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Missing @ symbol",
|
||||
messageID: "<abc123example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty local part",
|
||||
messageID: "<@example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty domain",
|
||||
messageID: "<abc123@>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Multiple @ symbols",
|
||||
messageID: "<abc@123@example.com>",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -294,17 +258,126 @@ func TestGenerateHeaderChecks(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateHeaderChecks(tt.email)
|
||||
result := analyzer.isValidMessageID(tt.messageID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
func TestHeaderAnalyzer_ExtractDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple email",
|
||||
email: "user@example.com",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Email with angle brackets",
|
||||
email: "<user@example.com>",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Email with display name",
|
||||
email: "User Name <user@example.com>",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Email with spaces",
|
||||
email: " user@example.com ",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
name: "Invalid email",
|
||||
email: "not-an-email",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
email: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewHeaderAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := analyzer.extractDomain(tt.email)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractDomain(%q) = %q, want %q", tt.email, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeDomainAlignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fromHeader string
|
||||
returnPath string
|
||||
expectAligned bool
|
||||
expectIssuesLen int
|
||||
}{
|
||||
{
|
||||
name: "Aligned domains",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "bounce@example.com",
|
||||
expectAligned: true,
|
||||
expectIssuesLen: 0,
|
||||
},
|
||||
{
|
||||
name: "Misaligned domains",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "bounce@different.com",
|
||||
expectAligned: false,
|
||||
expectIssuesLen: 1,
|
||||
},
|
||||
{
|
||||
name: "Only From header",
|
||||
fromHeader: "sender@example.com",
|
||||
returnPath: "",
|
||||
expectAligned: true,
|
||||
expectIssuesLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewHeaderAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": tt.fromHeader,
|
||||
"Return-Path": tt.returnPath,
|
||||
}),
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
alignment := analyzer.analyzeDomainAlignment(email)
|
||||
|
||||
if alignment == nil {
|
||||
t.Fatal("Expected non-nil alignment")
|
||||
}
|
||||
|
||||
if alignment.Aligned == nil {
|
||||
t.Fatal("Expected non-nil Aligned field")
|
||||
}
|
||||
|
||||
if *alignment.Aligned != tt.expectAligned {
|
||||
t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectAligned)
|
||||
}
|
||||
|
||||
issuesLen := 0
|
||||
if alignment.Issues != nil {
|
||||
issuesLen = len(*alignment.Issues)
|
||||
}
|
||||
if issuesLen != tt.expectIssuesLen {
|
||||
t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectIssuesLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,24 +68,15 @@ func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
|
|||
|
||||
// RBLResults represents the results of RBL checks
|
||||
type RBLResults struct {
|
||||
Checks map[string][]RBLCheck // Map of IP -> list of RBL checks for that IP
|
||||
Checks map[string][]api.BlacklistCheck // Map of IP -> list of RBL checks for that IP
|
||||
IPsChecked []string
|
||||
ListedCount int
|
||||
}
|
||||
|
||||
// RBLCheck represents a single RBL check result
|
||||
// Note: IP is not included here as it's used as the map key in the API
|
||||
type RBLCheck struct {
|
||||
RBL string
|
||||
Listed bool
|
||||
Response string
|
||||
Error string
|
||||
}
|
||||
|
||||
// CheckEmail checks all IPs found in the email headers against RBLs
|
||||
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
|
||||
results := &RBLResults{
|
||||
Checks: make(map[string][]RBLCheck),
|
||||
Checks: make(map[string][]api.BlacklistCheck),
|
||||
}
|
||||
|
||||
// Extract IPs from Received headers
|
||||
|
|
@ -179,15 +170,15 @@ func (r *RBLChecker) isPublicIP(ipStr string) bool {
|
|||
}
|
||||
|
||||
// checkIP checks a single IP against a single RBL
|
||||
func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
|
||||
check := RBLCheck{
|
||||
RBL: rbl,
|
||||
func (r *RBLChecker) checkIP(ip, rbl string) api.BlacklistCheck {
|
||||
check := api.BlacklistCheck{
|
||||
Rbl: rbl,
|
||||
}
|
||||
|
||||
// Reverse the IP for DNSBL query
|
||||
reversedIP := r.reverseIP(ip)
|
||||
if reversedIP == "" {
|
||||
check.Error = "Failed to reverse IP address"
|
||||
check.Error = api.PtrTo("Failed to reverse IP address")
|
||||
return check
|
||||
}
|
||||
|
||||
|
|
@ -208,19 +199,19 @@ func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
|
|||
}
|
||||
}
|
||||
// Other DNS errors
|
||||
check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
|
||||
check.Error = api.PtrTo(fmt.Sprintf("DNS lookup failed: %v", err))
|
||||
return check
|
||||
}
|
||||
|
||||
// If we got a response, check the return code
|
||||
if len(addrs) > 0 {
|
||||
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
|
||||
check.Response = api.PtrTo(addrs[0]) // Return code (e.g., 127.0.0.2)
|
||||
|
||||
// Check for RBL error codes: 127.255.255.253, 127.255.255.254, 127.255.255.255
|
||||
// These indicate RBL operational issues, not actual listings
|
||||
if addrs[0] == "127.255.255.253" || addrs[0] == "127.255.255.254" || addrs[0] == "127.255.255.255" {
|
||||
check.Listed = false
|
||||
check.Error = fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0])
|
||||
check.Error = api.PtrTo(fmt.Sprintf("RBL %s returned error code %s (RBL operational issue)", rbl, addrs[0]))
|
||||
} else {
|
||||
// Normal listing response
|
||||
check.Listed = true
|
||||
|
|
@ -248,8 +239,8 @@ 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
|
||||
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int {
|
||||
// CalculateRBLScore calculates the blacklist contribution to deliverability
|
||||
func (r *RBLChecker) CalculateRBLScore(results *RBLResults) int {
|
||||
if results == nil || len(results.IPsChecked) == 0 {
|
||||
// No IPs to check, give benefit of doubt
|
||||
return 100
|
||||
|
|
@ -258,159 +249,6 @@ func (r *RBLChecker) GetBlacklistScore(results *RBLResults) int {
|
|||
return 100 - results.ListedCount*100/len(r.RBLs)
|
||||
}
|
||||
|
||||
// GenerateRBLChecks generates check results for RBL analysis
|
||||
func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if results == nil {
|
||||
return checks
|
||||
}
|
||||
|
||||
// If no IPs were checked, add a warning
|
||||
if len(results.IPsChecked) == 0 {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Blacklist,
|
||||
Name: "RBL Check",
|
||||
Status: api.CheckStatusWarn,
|
||||
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"),
|
||||
})
|
||||
return checks
|
||||
}
|
||||
|
||||
// Create a summary check
|
||||
summaryCheck := r.generateSummaryCheck(results)
|
||||
checks = append(checks, summaryCheck)
|
||||
|
||||
// Create individual checks for each listing and RBL errors
|
||||
for ip, rblChecks := range results.Checks {
|
||||
for _, check := range rblChecks {
|
||||
if check.Listed {
|
||||
detailCheck := r.generateListingCheck(ip, &check)
|
||||
checks = append(checks, detailCheck)
|
||||
} else if check.Error != "" && strings.Contains(check.Error, "RBL operational issue") {
|
||||
// Generate info check for RBL errors
|
||||
detailCheck := r.generateRBLErrorCheck(ip, &check)
|
||||
checks = append(checks, detailCheck)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateSummaryCheck creates an overall RBL summary check
|
||||
func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Blacklist,
|
||||
Name: "RBL Summary",
|
||||
}
|
||||
|
||||
score := r.GetBlacklistScore(results)
|
||||
check.Score = score
|
||||
check.Grade = ScoreToCheckGrade(score)
|
||||
|
||||
// Calculate total checks across all IPs
|
||||
totalChecks := 0
|
||||
for _, rblChecks := range results.Checks {
|
||||
totalChecks += len(rblChecks)
|
||||
}
|
||||
listedCount := results.ListedCount
|
||||
|
||||
if listedCount == 0 {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your sending IP has a good reputation")
|
||||
} else if listedCount == 1 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
|
||||
} else if listedCount <= 3 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
|
||||
} else {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
|
||||
}
|
||||
|
||||
// Add details about IPs checked
|
||||
if len(results.IPsChecked) > 0 {
|
||||
details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateListingCheck creates a check for a specific RBL listing
|
||||
func (r *RBLChecker) generateListingCheck(ip string, rblCheck *RBLCheck) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Blacklist,
|
||||
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
|
||||
Status: api.CheckStatusFail,
|
||||
Score: 0,
|
||||
Grade: ScoreToCheckGrade(0),
|
||||
}
|
||||
|
||||
check.Message = fmt.Sprintf("IP %s is listed on %s", ip, rblCheck.RBL)
|
||||
|
||||
// Determine severity based on which RBL
|
||||
if strings.Contains(rblCheck.RBL, "spamhaus") {
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
|
||||
check.Advice = &advice
|
||||
} else if strings.Contains(rblCheck.RBL, "spamcop") {
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
|
||||
check.Advice = &advice
|
||||
} else {
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
|
||||
check.Advice = &advice
|
||||
}
|
||||
|
||||
// Add response code details
|
||||
if rblCheck.Response != "" {
|
||||
details := fmt.Sprintf("Response: %s", rblCheck.Response)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateRBLErrorCheck creates an info-level check for RBL operational errors
|
||||
func (r *RBLChecker) generateRBLErrorCheck(ip string, rblCheck *RBLCheck) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Blacklist,
|
||||
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
|
||||
Status: api.CheckStatusInfo,
|
||||
Score: 0, // No penalty for RBL operational issues
|
||||
Grade: ScoreToCheckGrade(-1),
|
||||
Severity: api.PtrTo(api.CheckSeverityInfo),
|
||||
}
|
||||
|
||||
check.Message = fmt.Sprintf("RBL %s returned an error code for IP %s", rblCheck.RBL, ip)
|
||||
|
||||
advice := fmt.Sprintf("The RBL %s is experiencing operational issues (error code: %s).", rblCheck.RBL, rblCheck.Response)
|
||||
check.Advice = &advice
|
||||
|
||||
if rblCheck.Response != "" {
|
||||
details := fmt.Sprintf("Error code: %s (RBL operational issue, not a listing)", rblCheck.Response)
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
|
||||
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
|
||||
var listedIPs []string
|
||||
|
|
@ -434,7 +272,7 @@ func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
|
|||
if rblChecks, exists := results.Checks[ip]; exists {
|
||||
for _, check := range rblChecks {
|
||||
if check.Listed {
|
||||
rbls = append(rbls, check.RBL)
|
||||
rbls = append(rbls, check.Rbl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ package analyzer
|
|||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -327,7 +326,7 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score := checker.GetBlacklistScore(tt.results)
|
||||
score := checker.CalculateRBLScore(tt.results)
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
|
||||
}
|
||||
|
|
@ -335,225 +334,19 @@ func TestGetBlacklistScore(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateSummaryCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *RBLResults
|
||||
expectedStatus api.CheckStatus
|
||||
expectedScore int
|
||||
}{
|
||||
{
|
||||
name: "Not listed",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
Checks: map[string][]RBLCheck{
|
||||
"198.51.100.1": make([]RBLCheck, 6), // 6 default RBLs
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
expectedScore: 200,
|
||||
},
|
||||
{
|
||||
name: "Listed on 1 RBL",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 1,
|
||||
Checks: map[string][]RBLCheck{
|
||||
"198.51.100.1": make([]RBLCheck, 6),
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 100,
|
||||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
Checks: map[string][]RBLCheck{
|
||||
"198.51.100.1": make([]RBLCheck, 6),
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
expectedScore: 50,
|
||||
},
|
||||
{
|
||||
name: "Listed on 4+ RBLs",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 4,
|
||||
Checks: map[string][]RBLCheck{
|
||||
"198.51.100.1": make([]RBLCheck, 6),
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedScore: 0,
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := checker.generateSummaryCheck(tt.results)
|
||||
|
||||
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.Blacklist {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateListingCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rblCheck *RBLCheck
|
||||
expectedStatus api.CheckStatus
|
||||
expectedSeverity api.CheckSeverity
|
||||
}{
|
||||
{
|
||||
name: "Spamhaus listing",
|
||||
rblCheck: &RBLCheck{
|
||||
RBL: "zen.spamhaus.org",
|
||||
Listed: true,
|
||||
Response: "127.0.0.2",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedSeverity: api.CheckSeverityCritical,
|
||||
},
|
||||
{
|
||||
name: "SpamCop listing",
|
||||
rblCheck: &RBLCheck{
|
||||
RBL: "bl.spamcop.net",
|
||||
Listed: true,
|
||||
Response: "127.0.0.2",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedSeverity: api.CheckSeverityHigh,
|
||||
},
|
||||
{
|
||||
name: "Other RBL listing",
|
||||
rblCheck: &RBLCheck{
|
||||
RBL: "dnsbl.sorbs.net",
|
||||
Listed: true,
|
||||
Response: "127.0.0.2",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
expectedSeverity: api.CheckSeverityHigh,
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := checker.generateListingCheck("198.51.100.1", tt.rblCheck)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Severity == nil || *check.Severity != tt.expectedSeverity {
|
||||
t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
|
||||
}
|
||||
if check.Category != api.Blacklist {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
|
||||
}
|
||||
if !strings.Contains(check.Name, tt.rblCheck.RBL) {
|
||||
t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRBLChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results *RBLResults
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
minChecks: 0,
|
||||
},
|
||||
{
|
||||
name: "No IPs checked",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{},
|
||||
},
|
||||
minChecks: 1, // Warning check
|
||||
},
|
||||
{
|
||||
name: "Not listed on any RBL",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 0,
|
||||
Checks: map[string][]RBLCheck{
|
||||
"198.51.100.1": {
|
||||
{RBL: "zen.spamhaus.org", Listed: false},
|
||||
{RBL: "bl.spamcop.net", Listed: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
minChecks: 1, // Summary check only
|
||||
},
|
||||
{
|
||||
name: "Listed on 2 RBLs",
|
||||
results: &RBLResults{
|
||||
IPsChecked: []string{"198.51.100.1"},
|
||||
ListedCount: 2,
|
||||
Checks: map[string][]RBLCheck{
|
||||
"198.51.100.1": {
|
||||
{RBL: "zen.spamhaus.org", Listed: true},
|
||||
{RBL: "bl.spamcop.net", Listed: true},
|
||||
{RBL: "dnsbl.sorbs.net", Listed: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
minChecks: 3, // Summary + 2 listing checks
|
||||
},
|
||||
}
|
||||
|
||||
checker := NewRBLChecker(5*time.Second, nil)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := checker.GenerateRBLChecks(tt.results)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Verify all checks have the Blacklist category
|
||||
for _, check := range checks {
|
||||
if check.Category != api.Blacklist {
|
||||
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUniqueListedIPs(t *testing.T) {
|
||||
results := &RBLResults{
|
||||
Checks: map[string][]RBLCheck{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{RBL: "zen.spamhaus.org", Listed: true},
|
||||
{RBL: "bl.spamcop.net", Listed: true},
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: true},
|
||||
},
|
||||
"198.51.100.2": {
|
||||
{RBL: "zen.spamhaus.org", Listed: true},
|
||||
{RBL: "bl.spamcop.net", Listed: false},
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: false},
|
||||
},
|
||||
"198.51.100.3": {
|
||||
{RBL: "zen.spamhaus.org", Listed: false},
|
||||
{Rbl: "zen.spamhaus.org", Listed: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -571,14 +364,14 @@ func TestGetUniqueListedIPs(t *testing.T) {
|
|||
|
||||
func TestGetRBLsForIP(t *testing.T) {
|
||||
results := &RBLResults{
|
||||
Checks: map[string][]RBLCheck{
|
||||
Checks: map[string][]api.BlacklistCheck{
|
||||
"198.51.100.1": {
|
||||
{RBL: "zen.spamhaus.org", Listed: true},
|
||||
{RBL: "bl.spamcop.net", Listed: true},
|
||||
{RBL: "dnsbl.sorbs.net", Listed: false},
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "bl.spamcop.net", Listed: true},
|
||||
{Rbl: "dnsbl.sorbs.net", Listed: false},
|
||||
},
|
||||
"198.51.100.2": {
|
||||
{RBL: "zen.spamhaus.org", Listed: true},
|
||||
{Rbl: "zen.spamhaus.org", Listed: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,10 +61,11 @@ func NewReportGenerator(
|
|||
type AnalysisResults struct {
|
||||
Email *EmailMessage
|
||||
Authentication *api.AuthenticationResults
|
||||
SpamAssassin *SpamAssassinResult
|
||||
DNS *DNSResults
|
||||
RBL *RBLResults
|
||||
Content *ContentResults
|
||||
DNS *DNSResults
|
||||
Headers *api.HeaderAnalysis
|
||||
RBL *RBLResults
|
||||
SpamAssassin *SpamAssassinResult
|
||||
}
|
||||
|
||||
// AnalyzeEmail performs complete email analysis
|
||||
|
|
@ -75,10 +76,11 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
|||
|
||||
// Run all analyzers
|
||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.Content = r.contentAnalyzer.AnalyzeContent(email)
|
||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
|
||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email)
|
||||
results.RBL = r.rblChecker.CheckEmail(email)
|
||||
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
|
||||
|
||||
return results
|
||||
}
|
||||
|
|
@ -94,77 +96,65 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
CreatedAt: now,
|
||||
}
|
||||
|
||||
// Collect all checks from different analyzers
|
||||
checks := []api.Check{}
|
||||
|
||||
// Authentication checks
|
||||
// Calculate scores directly from analyzers (no more checks array)
|
||||
authScore := 0
|
||||
if results.Authentication != nil {
|
||||
authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
|
||||
checks = append(checks, authChecks...)
|
||||
authScore = r.authAnalyzer.CalculateAuthenticationScore(results.Authentication)
|
||||
}
|
||||
|
||||
// DNS checks
|
||||
if results.DNS != nil {
|
||||
dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
|
||||
checks = append(checks, dnsChecks...)
|
||||
}
|
||||
|
||||
// RBL checks
|
||||
if results.RBL != nil {
|
||||
rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
|
||||
checks = append(checks, rblChecks...)
|
||||
}
|
||||
|
||||
// SpamAssassin checks
|
||||
if results.SpamAssassin != nil {
|
||||
spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
|
||||
checks = append(checks, spamChecks...)
|
||||
}
|
||||
|
||||
// Content checks
|
||||
contentScore := 0
|
||||
if results.Content != nil {
|
||||
contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
|
||||
checks = append(checks, contentChecks...)
|
||||
contentScore = r.contentAnalyzer.CalculateContentScore(results.Content)
|
||||
}
|
||||
|
||||
// Header checks
|
||||
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
|
||||
headerScore := 0
|
||||
if results.Headers != nil {
|
||||
headerScore = r.headerAnalyzer.CalculateHeaderScore(results.Headers)
|
||||
}
|
||||
|
||||
// Calculate mean scores for each category
|
||||
calcCategoryScore := func(category api.CheckCategory) int {
|
||||
if count := categoryCounts[category]; count > 0 {
|
||||
return categoryTotals[category] / count
|
||||
}
|
||||
return 0
|
||||
blacklistScore := 0
|
||||
if results.RBL != nil {
|
||||
blacklistScore = r.rblChecker.CalculateRBLScore(results.RBL)
|
||||
}
|
||||
|
||||
spamScore := 0
|
||||
if results.SpamAssassin != nil {
|
||||
spamScore = r.scorer.CalculateSpamScore(results.SpamAssassin)
|
||||
}
|
||||
|
||||
report.Summary = &api.ScoreSummary{
|
||||
AuthenticationScore: calcCategoryScore(api.Authentication),
|
||||
BlacklistScore: calcCategoryScore(api.Blacklist),
|
||||
ContentScore: calcCategoryScore(api.Content),
|
||||
HeaderScore: calcCategoryScore(api.Headers),
|
||||
SpamScore: calcCategoryScore(api.Spam),
|
||||
AuthenticationScore: authScore,
|
||||
BlacklistScore: blacklistScore,
|
||||
ContentScore: contentScore,
|
||||
HeaderScore: headerScore,
|
||||
SpamScore: spamScore,
|
||||
}
|
||||
|
||||
// Add authentication results
|
||||
report.Authentication = results.Authentication
|
||||
|
||||
// Add content analysis
|
||||
if results.Content != nil {
|
||||
contentAnalysis := r.contentAnalyzer.GenerateContentAnalysis(results.Content)
|
||||
report.ContentAnalysis = contentAnalysis
|
||||
}
|
||||
|
||||
// Add DNS records
|
||||
if results.DNS != nil {
|
||||
dnsRecords := r.buildDNSRecords(results.DNS)
|
||||
if len(dnsRecords) > 0 {
|
||||
report.DnsRecords = &dnsRecords
|
||||
}
|
||||
}
|
||||
|
||||
// Add headers results
|
||||
report.HeaderAnalysis = results.Headers
|
||||
|
||||
// Add blacklist checks as a map of IP -> array of BlacklistCheck
|
||||
if results.RBL != nil && len(results.RBL.Checks) > 0 {
|
||||
report.Blacklists = &results.RBL.Checks
|
||||
}
|
||||
|
||||
// Add SpamAssassin result
|
||||
if results.SpamAssassin != nil {
|
||||
report.Spamassassin = &api.SpamAssassinResult{
|
||||
|
|
@ -182,39 +172,6 @@ func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResu
|
|||
}
|
||||
}
|
||||
|
||||
// Add DNS records
|
||||
if results.DNS != nil {
|
||||
dnsRecords := r.buildDNSRecords(results.DNS)
|
||||
if len(dnsRecords) > 0 {
|
||||
report.DnsRecords = &dnsRecords
|
||||
}
|
||||
}
|
||||
|
||||
// Add blacklist checks as a map of IP -> array of BlacklistCheck
|
||||
if results.RBL != nil && len(results.RBL.Checks) > 0 {
|
||||
blacklistMap := make(map[string][]api.BlacklistCheck)
|
||||
|
||||
// Convert internal RBL checks to API format
|
||||
for ip, rblChecks := range results.RBL.Checks {
|
||||
apiChecks := make([]api.BlacklistCheck, 0, len(rblChecks))
|
||||
for _, check := range rblChecks {
|
||||
blCheck := api.BlacklistCheck{
|
||||
Rbl: check.RBL,
|
||||
Listed: check.Listed,
|
||||
}
|
||||
if check.Response != "" {
|
||||
blCheck.Response = &check.Response
|
||||
}
|
||||
apiChecks = append(apiChecks, blCheck)
|
||||
}
|
||||
blacklistMap[ip] = apiChecks
|
||||
}
|
||||
|
||||
if len(blacklistMap) > 0 {
|
||||
report.Blacklists = &blacklistMap
|
||||
}
|
||||
}
|
||||
|
||||
// Add raw headers
|
||||
if results.Email != nil && results.Email.RawHeaders != "" {
|
||||
report.RawHeaders = &results.Email.RawHeaders
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ package analyzer
|
|||
import (
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -54,9 +53,6 @@ func TestNewReportGenerator(t *testing.T) {
|
|||
if gen.contentAnalyzer == nil {
|
||||
t.Error("contentAnalyzer should not be nil")
|
||||
}
|
||||
if gen.scorer == nil {
|
||||
t.Error("scorer should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEmail(t *testing.T) {
|
||||
|
|
@ -77,20 +73,6 @@ func TestAnalyzeEmail(t *testing.T) {
|
|||
if results.Authentication == nil {
|
||||
t.Error("Authentication should not be nil")
|
||||
}
|
||||
|
||||
// SpamAssassin might be nil if headers don't exist
|
||||
// DNS results should exist
|
||||
// RBL results should exist
|
||||
// Content results should exist
|
||||
|
||||
if results.Score == nil {
|
||||
t.Error("Score should not be nil")
|
||||
}
|
||||
|
||||
// Verify score is within bounds
|
||||
if results.Score.OverallScore < 0 || results.Score.OverallScore > 100 {
|
||||
t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReport(t *testing.T) {
|
||||
|
|
@ -125,10 +107,6 @@ func TestGenerateReport(t *testing.T) {
|
|||
t.Error("Summary should not be nil")
|
||||
}
|
||||
|
||||
if len(report.Checks) == 0 {
|
||||
t.Error("Checks should not be empty")
|
||||
}
|
||||
|
||||
// Verify score summary
|
||||
if report.Summary != nil {
|
||||
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 {
|
||||
|
|
@ -147,22 +125,6 @@ func TestGenerateReport(t *testing.T) {
|
|||
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify checks have required fields
|
||||
for i, check := range report.Checks {
|
||||
if string(check.Category) == "" {
|
||||
t.Errorf("Check %d: Category should not be empty", i)
|
||||
}
|
||||
if check.Name == "" {
|
||||
t.Errorf("Check %d: Name should not be empty", i)
|
||||
}
|
||||
if string(check.Status) == "" {
|
||||
t.Errorf("Check %d: Status should not be empty", i)
|
||||
}
|
||||
if check.Message == "" {
|
||||
t.Errorf("Check %d: Message should not be empty", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReportWithSpamAssassin(t *testing.T) {
|
||||
|
|
@ -190,9 +152,9 @@ func TestBuildDNSRecords(t *testing.T) {
|
|||
|
||||
tests := []struct {
|
||||
name string
|
||||
dns *DNSResults
|
||||
dns *api.DNSResults
|
||||
expectedCount int
|
||||
expectTypes []api.DNSRecordRecordType
|
||||
expectTypes []DNSRecordRecordType
|
||||
}{
|
||||
{
|
||||
name: "Nil DNS results",
|
||||
|
|
@ -201,25 +163,25 @@ func TestBuildDNSRecords(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "Complete DNS results",
|
||||
dns: &DNSResults{
|
||||
dns: &api.DNSResults{
|
||||
Domain: "example.com",
|
||||
MXRecords: []MXRecord{
|
||||
MxRecords: &[]api.MXRecord{
|
||||
{Host: "mail.example.com", Priority: 10, Valid: true},
|
||||
},
|
||||
SPFRecord: &SPFRecord{
|
||||
Record: "v=spf1 include:_spf.example.com -all",
|
||||
SpfRecord: &api.SPFRecord{
|
||||
Record: api.PtrTo("v=spf1 include:_spf.example.com -all"),
|
||||
Valid: true,
|
||||
},
|
||||
DKIMRecords: []DKIMRecord{
|
||||
DkimRecords: &[]api.DKIMRecord{
|
||||
{
|
||||
Selector: "default",
|
||||
Domain: "example.com",
|
||||
Record: "v=DKIM1; k=rsa; p=...",
|
||||
Record: api.PtrTo("v=DKIM1; k=rsa; p=..."),
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
DMARCRecord: &DMARCRecord{
|
||||
Record: "v=DMARC1; p=quarantine",
|
||||
DmarcRecord: &api.DMARCRecord{
|
||||
Record: api.PtrTo("v=DMARC1; p=quarantine"),
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -319,135 +281,6 @@ func TestGenerateRawEmail(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetRecommendations(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
results *AnalysisResults
|
||||
expectCount int
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
expectCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Results with score",
|
||||
results: &AnalysisResults{
|
||||
Score: &ScoringResult{
|
||||
OverallScore: 50,
|
||||
Grade: ScoreToReportGrade(50),
|
||||
AuthScore: 15,
|
||||
SpamScore: 10,
|
||||
BlacklistScore: 15,
|
||||
ContentScore: 5,
|
||||
HeaderScore: 5,
|
||||
Recommendations: []string{
|
||||
"Improve authentication",
|
||||
"Fix content issues",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
recs := gen.GetRecommendations(tt.results)
|
||||
if len(recs) != tt.expectCount {
|
||||
t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScoreSummaryText(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
results *AnalysisResults
|
||||
expectEmpty bool
|
||||
expectString string
|
||||
}{
|
||||
{
|
||||
name: "Nil results",
|
||||
results: nil,
|
||||
expectEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "Results with score",
|
||||
results: &AnalysisResults{
|
||||
Score: &ScoringResult{
|
||||
OverallScore: 85,
|
||||
Grade: ScoreToReportGrade(85),
|
||||
AuthScore: 25,
|
||||
SpamScore: 18,
|
||||
BlacklistScore: 20,
|
||||
ContentScore: 15,
|
||||
HeaderScore: 7,
|
||||
CategoryBreakdown: map[string]CategoryScore{
|
||||
"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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectEmpty: false,
|
||||
expectString: "8.5/10",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
summary := gen.GetScoreSummaryText(tt.results)
|
||||
if tt.expectEmpty {
|
||||
if summary != "" {
|
||||
t.Errorf("Expected empty summary, got %q", summary)
|
||||
}
|
||||
} else {
|
||||
if summary == "" {
|
||||
t.Error("Expected non-empty summary")
|
||||
}
|
||||
if tt.expectString != "" && !strings.Contains(summary, tt.expectString) {
|
||||
t.Errorf("Summary should contain %q, got %q", tt.expectString, summary)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportCategories(t *testing.T) {
|
||||
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
|
||||
testID := uuid.New()
|
||||
|
||||
email := createComprehensiveTestEmail()
|
||||
results := gen.AnalyzeEmail(email)
|
||||
report := gen.GenerateReport(testID, results)
|
||||
|
||||
// Verify all check categories are present
|
||||
categories := make(map[api.CheckCategory]bool)
|
||||
for _, check := range report.Checks {
|
||||
categories[check.Category] = true
|
||||
}
|
||||
|
||||
expectedCategories := []api.CheckCategory{
|
||||
api.Authentication,
|
||||
api.Dns,
|
||||
api.Headers,
|
||||
}
|
||||
|
||||
for _, cat := range expectedCategories {
|
||||
if !categories[cat] {
|
||||
t.Errorf("Expected category %s not found in checks", cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestEmail() *EmailMessage {
|
||||
|
|
@ -484,21 +317,3 @@ func createTestEmailWithSpamAssassin() *EmailMessage {
|
|||
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
|
||||
return email
|
||||
}
|
||||
|
||||
func createComprehensiveTestEmail() *EmailMessage {
|
||||
email := createTestEmailWithSpamAssassin()
|
||||
|
||||
// Add authentication headers
|
||||
email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{
|
||||
"example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass",
|
||||
}
|
||||
|
||||
// Add HTML content
|
||||
email.Parts = append(email.Parts, MessagePart{
|
||||
ContentType: "text/html",
|
||||
Content: "<html><body><p>Test</p><a href='https://example.com'>Link</a></body></html>",
|
||||
IsHTML: true,
|
||||
})
|
||||
|
||||
return email
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,11 +45,6 @@ func ScoreToGrade(score int) string {
|
|||
}
|
||||
}
|
||||
|
||||
// ScoreToCheckGrade converts a percentage score to an 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 int) api.ReportGrade {
|
||||
return api.ReportGrade(ScoreToGrade(score))
|
||||
|
|
@ -62,3 +57,29 @@ type DeliverabilityScorer struct{}
|
|||
func NewDeliverabilityScorer() *DeliverabilityScorer {
|
||||
return &DeliverabilityScorer{}
|
||||
}
|
||||
|
||||
// CalculateSpamScore calculates spam score from SpamAssassin results
|
||||
// Returns a score from 0-100 where higher is better
|
||||
func (s *DeliverabilityScorer) CalculateSpamScore(result *SpamAssassinResult) int {
|
||||
if result == nil {
|
||||
return 100 // No spam scan results, assume good
|
||||
}
|
||||
|
||||
// SpamAssassin score typically ranges from -10 to +20
|
||||
// Score < 0 is very likely ham (good)
|
||||
// Score 0-5 is threshold range (configurable, usually 5.0)
|
||||
// Score > 5 is likely spam
|
||||
|
||||
score := result.Score
|
||||
|
||||
// Convert SpamAssassin score to 0-100 scale (inverted - lower SA score is better)
|
||||
if score <= 0 {
|
||||
return 100 // Perfect score for ham
|
||||
} else if score >= result.RequiredScore {
|
||||
return 0 // Failed spam test
|
||||
} else {
|
||||
// Linear scale between 0 and required threshold
|
||||
percentage := (score / result.RequiredScore) * 100
|
||||
return int(100 - percentage)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,254 +21,4 @@
|
|||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestNewDeliverabilityScorer(t *testing.T) {
|
||||
scorer := NewDeliverabilityScorer()
|
||||
if scorer == nil {
|
||||
t.Fatal("Expected scorer, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidMessageID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messageID string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid Message-ID",
|
||||
messageID: "<abc123@example.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid with UUID",
|
||||
messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Missing angle brackets",
|
||||
messageID: "abc123@example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Missing @ symbol",
|
||||
messageID: "<abc123example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Multiple @ symbols",
|
||||
messageID: "<abc@123@example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty local part",
|
||||
messageID: "<@example.com>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty domain part",
|
||||
messageID: "<abc123@>",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
messageID: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scorer.isValidMessageID(tt.messageID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authResults *api.AuthenticationResults
|
||||
spamResult *SpamAssassinResult
|
||||
rblResults *RBLResults
|
||||
contentResults *ContentResults
|
||||
email *EmailMessage
|
||||
minScore int
|
||||
maxScore int
|
||||
expectedGrade string
|
||||
}{
|
||||
{
|
||||
name: "Perfect email",
|
||||
authResults: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: &api.AuthResult{Result: api.AuthResultResultPass},
|
||||
},
|
||||
spamResult: &SpamAssassinResult{
|
||||
Score: -1.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
rblResults: &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{IP: "192.0.2.1", Listed: false},
|
||||
},
|
||||
},
|
||||
contentResults: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
Images: []ImageCheck{{HasAlt: true}},
|
||||
HasUnsubscribe: true,
|
||||
TextPlainRatio: 0.8,
|
||||
ImageTextRatio: 3.0,
|
||||
},
|
||||
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>",
|
||||
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
|
||||
},
|
||||
minScore: 90.0,
|
||||
maxScore: 100.0,
|
||||
expectedGrade: "A+",
|
||||
},
|
||||
{
|
||||
name: "Poor email - auth issues",
|
||||
authResults: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{Result: api.AuthResultResultFail},
|
||||
Dkim: &[]api.AuthResult{},
|
||||
Dmarc: nil,
|
||||
},
|
||||
spamResult: &SpamAssassinResult{
|
||||
Score: 8.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
rblResults: &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{
|
||||
IP: "192.0.2.1",
|
||||
RBL: "zen.spamhaus.org",
|
||||
Listed: true,
|
||||
},
|
||||
},
|
||||
ListedCount: 1,
|
||||
},
|
||||
contentResults: &ContentResults{
|
||||
HTMLValid: false,
|
||||
Links: []LinkCheck{{Valid: true, Status: 404}},
|
||||
HasUnsubscribe: false,
|
||||
},
|
||||
email: &EmailMessage{
|
||||
Header: createHeaderWithFields(map[string]string{
|
||||
"From": "sender@example.com",
|
||||
}),
|
||||
},
|
||||
minScore: 0.0,
|
||||
maxScore: 50.0,
|
||||
expectedGrade: "C",
|
||||
},
|
||||
{
|
||||
name: "Average email",
|
||||
authResults: &api.AuthenticationResults{
|
||||
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
|
||||
Dkim: &[]api.AuthResult{
|
||||
{Result: api.AuthResultResultPass},
|
||||
},
|
||||
Dmarc: nil,
|
||||
},
|
||||
spamResult: &SpamAssassinResult{
|
||||
Score: 4.0,
|
||||
RequiredScore: 5.0,
|
||||
},
|
||||
rblResults: &RBLResults{
|
||||
Checks: []RBLCheck{
|
||||
{IP: "192.0.2.1", Listed: false},
|
||||
},
|
||||
},
|
||||
contentResults: &ContentResults{
|
||||
HTMLValid: true,
|
||||
Links: []LinkCheck{{Valid: true, Status: 200}},
|
||||
HasUnsubscribe: false,
|
||||
},
|
||||
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: 60.0,
|
||||
maxScore: 90.0,
|
||||
expectedGrade: "A",
|
||||
},
|
||||
}
|
||||
|
||||
scorer := NewDeliverabilityScorer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scorer.CalculateScore(
|
||||
tt.authResults,
|
||||
tt.spamResult,
|
||||
tt.rblResults,
|
||||
tt.contentResults,
|
||||
tt.email,
|
||||
)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected result, got nil")
|
||||
}
|
||||
|
||||
// Check overall score
|
||||
if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore {
|
||||
t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore)
|
||||
}
|
||||
|
||||
// Check rating
|
||||
if result.Grade != api.ReportGrade(tt.expectedGrade) {
|
||||
t.Errorf("Grade = %q, want %q", result.Grade, tt.expectedGrade)
|
||||
}
|
||||
|
||||
// Verify score is within bounds
|
||||
if result.OverallScore < 0.0 || result.OverallScore > 100.0 {
|
||||
t.Errorf("OverallScore %v is out of bounds [0.0, 100.0]", result.OverallScore)
|
||||
}
|
||||
|
||||
// Verify category breakdown exists
|
||||
if len(result.CategoryBreakdown) != 5 {
|
||||
t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown))
|
||||
}
|
||||
|
||||
// Verify recommendations exist
|
||||
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 {
|
||||
t.Errorf("Category scores sum (%d) doesn't match overall score (%d)",
|
||||
totalCategoryScore, result.OverallScore)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
import ()
|
||||
|
|
|
|||
|
|
@ -22,13 +22,10 @@
|
|||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
|
||||
|
|
@ -198,131 +195,3 @@ func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult)
|
|||
// 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
|
||||
func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
|
||||
var checks []api.Check
|
||||
|
||||
if result == nil {
|
||||
checks = append(checks, api.Check{
|
||||
Category: api.Spam,
|
||||
Name: "SpamAssassin Analysis",
|
||||
Status: api.CheckStatusWarn,
|
||||
Score: 0.0,
|
||||
Grade: ScoreToCheckGrade(0.0),
|
||||
Message: "No SpamAssassin headers found",
|
||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
||||
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
|
||||
})
|
||||
return checks
|
||||
}
|
||||
|
||||
// Main spam score check
|
||||
mainCheck := a.generateMainSpamCheck(result)
|
||||
checks = append(checks, mainCheck)
|
||||
|
||||
// Add checks for significant spam tests (score > 1.0 or < -1.0)
|
||||
for _, test := range result.Tests {
|
||||
if detail, ok := result.TestDetails[test]; ok {
|
||||
if detail.Score > 1.0 || detail.Score < -1.0 {
|
||||
check := a.generateTestCheck(detail)
|
||||
checks = append(checks, check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// generateMainSpamCheck creates the main spam score check
|
||||
func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Spam,
|
||||
Name: "SpamAssassin Score",
|
||||
}
|
||||
|
||||
score := result.Score
|
||||
required := result.RequiredScore
|
||||
if required == 0 {
|
||||
required = 5.0
|
||||
}
|
||||
|
||||
check.Score = a.GetSpamAssassinScore(result)
|
||||
check.Grade = ScoreToCheckGrade(check.Score)
|
||||
|
||||
// Determine status and message based on score
|
||||
if score <= 0 {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
|
||||
} else if score < required {
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Advice = api.PtrTo("Your email passes spam filters")
|
||||
} else if score < required*1.5 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
|
||||
} else if score < required*2 {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
|
||||
} else {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
||||
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
|
||||
}
|
||||
|
||||
// Add details
|
||||
if len(result.Tests) > 0 {
|
||||
details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
|
||||
if len(result.Tests) > 5 {
|
||||
details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
|
||||
}
|
||||
check.Details = &details
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// generateTestCheck creates a check for a specific spam test
|
||||
func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
|
||||
check := api.Check{
|
||||
Category: api.Spam,
|
||||
Name: fmt.Sprintf("Spam Test: %s", detail.Name),
|
||||
}
|
||||
|
||||
if detail.Score > 0 {
|
||||
// Negative indicator (increases spam score)
|
||||
if detail.Score > 2.0 {
|
||||
check.Status = api.CheckStatusFail
|
||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
||||
} else {
|
||||
check.Status = api.CheckStatusWarn
|
||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
||||
}
|
||||
check.Score = 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
|
||||
} else {
|
||||
// Positive indicator (decreases spam score)
|
||||
check.Status = api.CheckStatusPass
|
||||
check.Score = 1.0
|
||||
check.Grade = ScoreToCheckGrade((1.0 / 20.0) * 100)
|
||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
||||
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
|
||||
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
|
||||
check.Advice = &advice
|
||||
}
|
||||
|
||||
check.Details = &detail.Description
|
||||
|
||||
return check
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import (
|
|||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestParseSpamStatus(t *testing.T) {
|
||||
|
|
@ -298,86 +296,6 @@ func TestAnalyzeSpamAssassin(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateSpamAssassinChecks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *SpamAssassinResult
|
||||
expectedStatus api.CheckStatus
|
||||
minChecks int
|
||||
}{
|
||||
{
|
||||
name: "Nil result",
|
||||
result: nil,
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
minChecks: 1,
|
||||
},
|
||||
{
|
||||
name: "Clean email",
|
||||
result: &SpamAssassinResult{
|
||||
IsSpam: false,
|
||||
Score: -0.5,
|
||||
RequiredScore: 5.0,
|
||||
Tests: []string{"ALL_TRUSTED"},
|
||||
TestDetails: map[string]SpamTestDetail{
|
||||
"ALL_TRUSTED": {
|
||||
Name: "ALL_TRUSTED",
|
||||
Score: -1.5,
|
||||
Description: "All mail servers are trusted",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
minChecks: 2, // Main check + one test detail
|
||||
},
|
||||
{
|
||||
name: "Spam email",
|
||||
result: &SpamAssassinResult{
|
||||
IsSpam: true,
|
||||
Score: 15.0,
|
||||
RequiredScore: 5.0,
|
||||
Tests: []string{"BAYES_99", "SPOOFED_SENDER"},
|
||||
TestDetails: map[string]SpamTestDetail{
|
||||
"BAYES_99": {
|
||||
Name: "BAYES_99",
|
||||
Score: 5.0,
|
||||
Description: "Bayes spam probability is 99 to 100%",
|
||||
},
|
||||
"SPOOFED_SENDER": {
|
||||
Name: "SPOOFED_SENDER",
|
||||
Score: 3.5,
|
||||
Description: "From address doesn't match envelope sender",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
minChecks: 3, // Main check + two significant tests
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checks := analyzer.GenerateSpamAssassinChecks(tt.result)
|
||||
|
||||
if len(checks) < tt.minChecks {
|
||||
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
|
||||
}
|
||||
|
||||
// Check main check (first one)
|
||||
if len(checks) > 0 {
|
||||
mainCheck := checks[0]
|
||||
if mainCheck.Status != tt.expectedStatus {
|
||||
t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus)
|
||||
}
|
||||
if mainCheck.Category != api.Spam {
|
||||
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
email := &EmailMessage{
|
||||
|
|
@ -391,98 +309,6 @@ func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenerateMainSpamCheck(t *testing.T) {
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
score float64
|
||||
required float64
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{"Excellent", -1.0, 5.0, api.CheckStatusPass},
|
||||
{"Good", 2.0, 5.0, api.CheckStatusPass},
|
||||
{"Borderline", 6.0, 5.0, api.CheckStatusWarn},
|
||||
{"High", 8.0, 5.0, api.CheckStatusWarn},
|
||||
{"Very High", 15.0, 5.0, api.CheckStatusFail},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &SpamAssassinResult{
|
||||
Score: tt.score,
|
||||
RequiredScore: tt.required,
|
||||
}
|
||||
|
||||
check := analyzer.generateMainSpamCheck(result)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Spam {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
|
||||
}
|
||||
if !strings.Contains(check.Message, "spam score") {
|
||||
t.Error("Message should contain 'spam score'")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTestCheck(t *testing.T) {
|
||||
analyzer := NewSpamAssassinAnalyzer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
detail SpamTestDetail
|
||||
expectedStatus api.CheckStatus
|
||||
}{
|
||||
{
|
||||
name: "High penalty test",
|
||||
detail: SpamTestDetail{
|
||||
Name: "BAYES_99",
|
||||
Score: 5.0,
|
||||
Description: "Bayes spam probability is 99 to 100%",
|
||||
},
|
||||
expectedStatus: api.CheckStatusFail,
|
||||
},
|
||||
{
|
||||
name: "Medium penalty test",
|
||||
detail: SpamTestDetail{
|
||||
Name: "HTML_MESSAGE",
|
||||
Score: 1.5,
|
||||
Description: "Contains HTML",
|
||||
},
|
||||
expectedStatus: api.CheckStatusWarn,
|
||||
},
|
||||
{
|
||||
name: "Positive test",
|
||||
detail: SpamTestDetail{
|
||||
Name: "ALL_TRUSTED",
|
||||
Score: -2.0,
|
||||
Description: "All mail servers are trusted",
|
||||
},
|
||||
expectedStatus: api.CheckStatusPass,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
check := analyzer.generateTestCheck(tt.detail)
|
||||
|
||||
if check.Status != tt.expectedStatus {
|
||||
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
|
||||
}
|
||||
if check.Category != api.Spam {
|
||||
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
|
||||
}
|
||||
if !strings.Contains(check.Name, tt.detail.Name) {
|
||||
t.Errorf("Check name should contain test name %s", tt.detail.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sampleEmailWithSpamassassinHeader = `X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on e4a8b8eb87ec
|
||||
X-Spam-Status: No, score=-0.1 required=5.0 tests=DKIM_SIGNED,DKIM_VALID,
|
||||
DKIM_VALID_AU,RCVD_IN_VALIDITY_CERTIFIED_BLOCKED,
|
||||
|
|
@ -623,34 +449,6 @@ func TestAnalyzeRealEmailExample(t *testing.T) {
|
|||
if score != 100 {
|
||||
t.Errorf("GetSpamAssassinScore() = %v, want 100 (excellent score for negative spam score)", score)
|
||||
}
|
||||
|
||||
// Test GenerateSpamAssassinChecks
|
||||
checks := analyzer.GenerateSpamAssassinChecks(result)
|
||||
if len(checks) < 1 {
|
||||
t.Fatal("Expected at least 1 check, got none")
|
||||
}
|
||||
|
||||
// Main check should be PASS with excellent score
|
||||
mainCheck := checks[0]
|
||||
if mainCheck.Status != api.CheckStatusPass {
|
||||
t.Errorf("Main check status = %v, want %v", mainCheck.Status, api.CheckStatusPass)
|
||||
}
|
||||
if mainCheck.Category != api.Spam {
|
||||
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
|
||||
}
|
||||
if !strings.Contains(mainCheck.Message, "spam score") {
|
||||
t.Errorf("Main check message should contain 'spam score', got: %s", mainCheck.Message)
|
||||
}
|
||||
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: %d, status: %s)",
|
||||
i+1, check.Name, check.Message, check.Score, check.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compare string slices
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue