Compare commits
No commits in common. "f1b9ac1e27f78b8067b40a3f87aa439d0caca0cf" and "35ff54b2e1c9049aa87bfdbce91ec6670f8c2306" have entirely different histories.
f1b9ac1e27
...
35ff54b2e1
23 changed files with 293 additions and 2029 deletions
|
|
@ -4,7 +4,7 @@ An open-source email deliverability testing platform that analyzes test emails a
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, BIMI, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
- **Complete Email Analysis**: Analyzes SPF, DKIM, DMARC, SpamAssassin scores, DNS records, blacklist status, content quality, and more
|
||||||
- **REST API**: Full-featured API for creating tests and retrieving reports
|
- **REST API**: Full-featured API for creating tests and retrieving reports
|
||||||
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
- **LMTP Server**: Built-in LMTP server for seamless MTA integration
|
||||||
- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
|
- **Scoring System**: 0-10 scoring with weighted factors across authentication, spam, blacklists, content, and headers
|
||||||
|
|
@ -194,8 +194,6 @@ The deliverability score is calculated from 0 to 10 based on:
|
||||||
- **Content (2 pts)**: HTML quality, links, images, unsubscribe
|
- **Content (2 pts)**: HTML quality, links, images, unsubscribe
|
||||||
- **Headers (1 pt)**: Required headers, MIME structure
|
- **Headers (1 pt)**: Required headers, MIME structure
|
||||||
|
|
||||||
**Note:** BIMI (Brand Indicators for Message Identification) is also checked and reported but does not contribute to the score, as it's a branding feature rather than a deliverability factor.
|
|
||||||
|
|
||||||
**Ratings:**
|
**Ratings:**
|
||||||
- 9-10: Excellent
|
- 9-10: Excellent
|
||||||
- 7-8.9: Good
|
- 7-8.9: Good
|
||||||
|
|
|
||||||
|
|
@ -353,10 +353,6 @@ components:
|
||||||
$ref: '#/components/schemas/AuthResult'
|
$ref: '#/components/schemas/AuthResult'
|
||||||
dmarc:
|
dmarc:
|
||||||
$ref: '#/components/schemas/AuthResult'
|
$ref: '#/components/schemas/AuthResult'
|
||||||
bimi:
|
|
||||||
$ref: '#/components/schemas/AuthResult'
|
|
||||||
arc:
|
|
||||||
$ref: '#/components/schemas/ARCResult'
|
|
||||||
|
|
||||||
AuthResult:
|
AuthResult:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -380,29 +376,6 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Additional details about the result
|
description: Additional details about the result
|
||||||
|
|
||||||
ARCResult:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- result
|
|
||||||
properties:
|
|
||||||
result:
|
|
||||||
type: string
|
|
||||||
enum: [pass, fail, none]
|
|
||||||
description: Overall ARC chain validation result
|
|
||||||
example: "pass"
|
|
||||||
chain_valid:
|
|
||||||
type: boolean
|
|
||||||
description: Whether the ARC chain signatures are valid
|
|
||||||
example: true
|
|
||||||
chain_length:
|
|
||||||
type: integer
|
|
||||||
description: Number of ARC sets in the chain
|
|
||||||
example: 2
|
|
||||||
details:
|
|
||||||
type: string
|
|
||||||
description: Additional details about ARC validation
|
|
||||||
example: "ARC chain valid with 2 intermediaries"
|
|
||||||
|
|
||||||
SpamAssassinResult:
|
SpamAssassinResult:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|
@ -447,7 +420,7 @@ components:
|
||||||
example: "example.com"
|
example: "example.com"
|
||||||
record_type:
|
record_type:
|
||||||
type: string
|
type: string
|
||||||
enum: [MX, SPF, DKIM, DMARC, BIMI]
|
enum: [MX, SPF, DKIM, DMARC]
|
||||||
description: DNS record type
|
description: DNS record type
|
||||||
example: "SPF"
|
example: "SPF"
|
||||||
status:
|
status:
|
||||||
|
|
|
||||||
|
|
@ -59,14 +59,6 @@ func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse ARC headers if not already parsed from Authentication-Results
|
|
||||||
if results.Arc == nil {
|
|
||||||
results.Arc = a.parseARCHeaders(email)
|
|
||||||
} else {
|
|
||||||
// Enhance the ARC result with chain information from raw headers
|
|
||||||
a.enhanceARCResult(email, results.Arc)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,20 +104,6 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
results.Dmarc = a.parseDMARCResult(part)
|
results.Dmarc = a.parseDMARCResult(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse BIMI
|
|
||||||
if strings.HasPrefix(part, "bimi=") {
|
|
||||||
if results.Bimi == nil {
|
|
||||||
results.Bimi = a.parseBIMIResult(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ARC
|
|
||||||
if strings.HasPrefix(part, "arc=") {
|
|
||||||
if results.Arc == nil {
|
|
||||||
results.Arc = a.parseARCResult(part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,201 +214,6 @@ func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseBIMIResult parses BIMI result from Authentication-Results
|
|
||||||
// Example: bimi=pass header.d=example.com header.selector=default
|
|
||||||
func (a *AuthenticationAnalyzer) parseBIMIResult(part string) *api.AuthResult {
|
|
||||||
result := &api.AuthResult{}
|
|
||||||
|
|
||||||
// Extract result (pass, fail, etc.)
|
|
||||||
re := regexp.MustCompile(`bimi=(\w+)`)
|
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
resultStr := strings.ToLower(matches[1])
|
|
||||||
result.Result = api.AuthResultResult(resultStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract domain (header.d or d)
|
|
||||||
domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`)
|
|
||||||
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
domain := matches[1]
|
|
||||||
result.Domain = &domain
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract selector (header.selector or selector)
|
|
||||||
selectorRe := regexp.MustCompile(`(?:header\.)?selector=([^\s;]+)`)
|
|
||||||
if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
selector := matches[1]
|
|
||||||
result.Selector = &selector
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract details
|
|
||||||
if idx := strings.Index(part, "("); idx != -1 {
|
|
||||||
endIdx := strings.Index(part[idx:], ")")
|
|
||||||
if endIdx != -1 {
|
|
||||||
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
|
||||||
result.Details = &details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseARCResult parses ARC result from Authentication-Results
|
|
||||||
// Example: arc=pass
|
|
||||||
func (a *AuthenticationAnalyzer) parseARCResult(part string) *api.ARCResult {
|
|
||||||
result := &api.ARCResult{}
|
|
||||||
|
|
||||||
// Extract result (pass, fail, none)
|
|
||||||
re := regexp.MustCompile(`arc=(\w+)`)
|
|
||||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
|
||||||
resultStr := strings.ToLower(matches[1])
|
|
||||||
result.Result = api.ARCResultResult(resultStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract details
|
|
||||||
if idx := strings.Index(part, "("); idx != -1 {
|
|
||||||
endIdx := strings.Index(part[idx:], ")")
|
|
||||||
if endIdx != -1 {
|
|
||||||
details := strings.TrimSpace(part[idx+1 : idx+endIdx])
|
|
||||||
result.Details = &details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseARCHeaders parses ARC headers from email message
|
|
||||||
// ARC consists of three headers per hop: ARC-Authentication-Results, ARC-Message-Signature, ARC-Seal
|
|
||||||
func (a *AuthenticationAnalyzer) parseARCHeaders(email *EmailMessage) *api.ARCResult {
|
|
||||||
// Get all ARC-related headers
|
|
||||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
|
||||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
|
||||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
|
||||||
|
|
||||||
// If no ARC headers present, return nil
|
|
||||||
if len(arcAuthResults) == 0 && len(arcMessageSig) == 0 && len(arcSeal) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &api.ARCResult{
|
|
||||||
Result: api.ARCResultResultNone,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count the ARC chain length (number of sets)
|
|
||||||
chainLength := len(arcSeal)
|
|
||||||
result.ChainLength = &chainLength
|
|
||||||
|
|
||||||
// Validate the ARC chain
|
|
||||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
|
||||||
result.ChainValid = &chainValid
|
|
||||||
|
|
||||||
// Determine overall result
|
|
||||||
if chainLength == 0 {
|
|
||||||
result.Result = api.ARCResultResultNone
|
|
||||||
details := "No ARC chain present"
|
|
||||||
result.Details = &details
|
|
||||||
} else if !chainValid {
|
|
||||||
result.Result = api.ARCResultResultFail
|
|
||||||
details := fmt.Sprintf("ARC chain validation failed (chain length: %d)", chainLength)
|
|
||||||
result.Details = &details
|
|
||||||
} else {
|
|
||||||
result.Result = api.ARCResultResultPass
|
|
||||||
details := fmt.Sprintf("ARC chain valid with %d intermediar%s", chainLength, pluralize(chainLength))
|
|
||||||
result.Details = &details
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// enhanceARCResult enhances an existing ARC result with chain information
|
|
||||||
func (a *AuthenticationAnalyzer) enhanceARCResult(email *EmailMessage, arcResult *api.ARCResult) {
|
|
||||||
if arcResult == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get ARC headers
|
|
||||||
arcSeal := email.Header[textprotoCanonical("ARC-Seal")]
|
|
||||||
arcMessageSig := email.Header[textprotoCanonical("ARC-Message-Signature")]
|
|
||||||
arcAuthResults := email.Header[textprotoCanonical("ARC-Authentication-Results")]
|
|
||||||
|
|
||||||
// Set chain length if not already set
|
|
||||||
if arcResult.ChainLength == nil {
|
|
||||||
chainLength := len(arcSeal)
|
|
||||||
arcResult.ChainLength = &chainLength
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate chain if not already validated
|
|
||||||
if arcResult.ChainValid == nil {
|
|
||||||
chainValid := a.validateARCChain(arcAuthResults, arcMessageSig, arcSeal)
|
|
||||||
arcResult.ChainValid = &chainValid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateARCChain validates the ARC chain for completeness
|
|
||||||
// Each instance should have all three headers with matching instance numbers
|
|
||||||
func (a *AuthenticationAnalyzer) validateARCChain(arcAuthResults, arcMessageSig, arcSeal []string) bool {
|
|
||||||
// All three header types should have the same count
|
|
||||||
if len(arcAuthResults) != len(arcMessageSig) || len(arcAuthResults) != len(arcSeal) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(arcSeal) == 0 {
|
|
||||||
return true // No ARC chain is technically valid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract instance numbers from each header type
|
|
||||||
sealInstances := a.extractARCInstances(arcSeal)
|
|
||||||
sigInstances := a.extractARCInstances(arcMessageSig)
|
|
||||||
authInstances := a.extractARCInstances(arcAuthResults)
|
|
||||||
|
|
||||||
// Check that all instance numbers match and are sequential starting from 1
|
|
||||||
if len(sealInstances) != len(sigInstances) || len(sealInstances) != len(authInstances) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify instances are sequential from 1 to N
|
|
||||||
for i := 1; i <= len(sealInstances); i++ {
|
|
||||||
if !contains(sealInstances, i) || !contains(sigInstances, i) || !contains(authInstances, i) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractARCInstances extracts instance numbers from ARC headers
|
|
||||||
func (a *AuthenticationAnalyzer) extractARCInstances(headers []string) []int {
|
|
||||||
var instances []int
|
|
||||||
re := regexp.MustCompile(`i=(\d+)`)
|
|
||||||
|
|
||||||
for _, header := range headers {
|
|
||||||
if matches := re.FindStringSubmatch(header); len(matches) > 1 {
|
|
||||||
var instance int
|
|
||||||
fmt.Sscanf(matches[1], "%d", &instance)
|
|
||||||
instances = append(instances, instance)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return instances
|
|
||||||
}
|
|
||||||
|
|
||||||
// contains checks if a slice contains an integer
|
|
||||||
func contains(slice []int, val int) bool {
|
|
||||||
for _, item := range slice {
|
|
||||||
if item == val {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// pluralize returns "y" or "ies" based on count
|
|
||||||
func pluralize(count int) string {
|
|
||||||
if count == 1 {
|
|
||||||
return "y"
|
|
||||||
}
|
|
||||||
return "ies"
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
// parseLegacySPF attempts to parse SPF from Received-SPF header
|
||||||
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
|
||||||
receivedSPF := email.Header.Get("Received-SPF")
|
receivedSPF := email.Header.Get("Received-SPF")
|
||||||
|
|
@ -505,3 +288,224 @@ func textprotoCanonical(s string) string {
|
||||||
}
|
}
|
||||||
return strings.Join(words, "-")
|
return strings.Join(words, "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAuthenticationScore calculates the authentication score (0-3 points)
|
||||||
|
func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
|
||||||
|
var score float32 = 0.0
|
||||||
|
|
||||||
|
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
|
||||||
|
if results.Spf != nil {
|
||||||
|
switch results.Spf.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
score += 1.0
|
||||||
|
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DKIM: 1 point for at least one pass
|
||||||
|
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||||
|
for _, dkim := range *results.Dkim {
|
||||||
|
if dkim.Result == api.AuthResultResultPass {
|
||||||
|
score += 1.0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARC: 1 point for pass
|
||||||
|
if results.Dmarc != nil {
|
||||||
|
switch results.Dmarc.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 3 points maximum
|
||||||
|
if score > 3.0 {
|
||||||
|
score = 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.0,
|
||||||
|
Message: "No SPF authentication result found",
|
||||||
|
Severity: api.PtrTo(api.Medium),
|
||||||
|
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.0,
|
||||||
|
Message: "No DKIM signature found",
|
||||||
|
Severity: api.PtrTo(api.Medium),
|
||||||
|
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.0,
|
||||||
|
Message: "No DMARC authentication result found",
|
||||||
|
Severity: api.PtrTo(api.Medium),
|
||||||
|
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 1.0
|
||||||
|
check.Message = "SPF validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.Info)
|
||||||
|
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "SPF validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
|
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
|
||||||
|
case api.AuthResultResultSoftfail:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.5
|
||||||
|
check.Message = "SPF validation softfail"
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||||
|
case api.AuthResultResultNeutral:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.5
|
||||||
|
check.Message = "SPF validation neutral"
|
||||||
|
check.Severity = api.PtrTo(api.Low)
|
||||||
|
check.Advice = api.PtrTo("Consider tightening your SPF policy")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 1.0
|
||||||
|
check.Message = "DKIM signature is valid"
|
||||||
|
check.Severity = api.PtrTo(api.Info)
|
||||||
|
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "DKIM signature validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.High)
|
||||||
|
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsParts []string
|
||||||
|
if dkim.Domain != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
|
||||||
|
}
|
||||||
|
if dkim.Selector != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
|
||||||
|
}
|
||||||
|
if len(detailsParts) > 0 {
|
||||||
|
details := strings.Join(detailsParts, ", ")
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 1.0
|
||||||
|
check.Message = "DMARC validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.Info)
|
||||||
|
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "DMARC validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.High)
|
||||||
|
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dmarc.Domain != nil {
|
||||||
|
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
@ -507,7 +507,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
|
||||||
if !results.HTMLValid {
|
if !results.HTMLValid {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Message = "HTML structure is invalid"
|
check.Message = "HTML structure is invalid"
|
||||||
if len(results.HTMLErrors) > 0 {
|
if len(results.HTMLErrors) > 0 {
|
||||||
details := strings.Join(results.HTMLErrors, "; ")
|
details := strings.Join(results.HTMLErrors, "; ")
|
||||||
|
|
@ -517,7 +517,7 @@ func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = "HTML structure is valid"
|
check.Message = "HTML structure is valid"
|
||||||
check.Advice = api.PtrTo("Your HTML is well-formed")
|
check.Advice = api.PtrTo("Your HTML is well-formed")
|
||||||
}
|
}
|
||||||
|
|
@ -552,7 +552,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
|
||||||
if brokenLinks > 0 {
|
if brokenLinks > 0 {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
|
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
|
||||||
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
|
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
|
||||||
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
|
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
|
||||||
|
|
@ -560,7 +560,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
|
||||||
} else if warningLinks > 0 {
|
} else if warningLinks > 0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
check.Severity = api.PtrTo(api.Low)
|
||||||
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
|
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")
|
check.Advice = api.PtrTo("Review links that could not be verified")
|
||||||
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
|
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
|
||||||
|
|
@ -568,7 +568,7 @@ func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Chec
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.4
|
check.Score = 0.4
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
|
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
|
||||||
check.Advice = api.PtrTo("Your links are working properly")
|
check.Advice = api.PtrTo("Your links are working properly")
|
||||||
}
|
}
|
||||||
|
|
@ -601,7 +601,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
|
||||||
if noAltCount == len(results.Images) {
|
if noAltCount == len(results.Images) {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Message = "No images have alt attributes"
|
check.Message = "No images have alt attributes"
|
||||||
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
|
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))
|
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||||
|
|
@ -609,7 +609,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
|
||||||
} else if noAltCount > 0 {
|
} else if noAltCount > 0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
check.Severity = api.PtrTo(api.Low)
|
||||||
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
|
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
|
||||||
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
|
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))
|
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
|
||||||
|
|
@ -617,7 +617,7 @@ func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Che
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = "All images have alt attributes"
|
check.Message = "All images have alt attributes"
|
||||||
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
|
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
|
||||||
}
|
}
|
||||||
|
|
@ -636,13 +636,13 @@ func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.
|
||||||
if !results.HasUnsubscribe {
|
if !results.HasUnsubscribe {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
check.Severity = api.PtrTo(api.Low)
|
||||||
check.Message = "No unsubscribe link found"
|
check.Message = "No unsubscribe link found"
|
||||||
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
|
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
|
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
|
||||||
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
|
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
|
||||||
}
|
}
|
||||||
|
|
@ -662,7 +662,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
|
||||||
if consistency < 0.3 {
|
if consistency < 0.3 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
check.Severity = api.PtrTo(api.Low)
|
||||||
check.Message = "Plain text and HTML versions differ significantly"
|
check.Message = "Plain text and HTML versions differ significantly"
|
||||||
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
|
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
|
||||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||||
|
|
@ -670,7 +670,7 @@ func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults)
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = "Plain text and HTML versions are consistent"
|
check.Message = "Plain text and HTML versions are consistent"
|
||||||
check.Advice = api.PtrTo("Your multipart email is well-structured")
|
check.Advice = api.PtrTo("Your multipart email is well-structured")
|
||||||
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
|
||||||
|
|
@ -693,7 +693,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
|
||||||
if ratio > 10.0 {
|
if ratio > 10.0 {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Message = "Email is excessively image-heavy"
|
check.Message = "Email is excessively image-heavy"
|
||||||
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
|
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)
|
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||||
|
|
@ -701,7 +701,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
|
||||||
} else if ratio > 5.0 {
|
} else if ratio > 5.0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
check.Severity = api.PtrTo(api.Low)
|
||||||
check.Message = "Email has high image-to-text ratio"
|
check.Message = "Email has high image-to-text ratio"
|
||||||
check.Advice = api.PtrTo("Consider adding more text content relative to images")
|
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)
|
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||||
|
|
@ -709,7 +709,7 @@ func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.C
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = "Image-to-text ratio is reasonable"
|
check.Message = "Image-to-text ratio is reasonable"
|
||||||
check.Advice = api.PtrTo("Your content has a good balance of images and text")
|
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)
|
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
|
||||||
|
|
@ -730,7 +730,7 @@ func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) ap
|
||||||
|
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
|
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
|
||||||
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
|
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
|
||||||
|
|
||||||
|
|
@ -58,7 +58,6 @@ type DNSResults struct {
|
||||||
SPFRecord *SPFRecord
|
SPFRecord *SPFRecord
|
||||||
DKIMRecords []DKIMRecord
|
DKIMRecords []DKIMRecord
|
||||||
DMARCRecord *DMARCRecord
|
DMARCRecord *DMARCRecord
|
||||||
BIMIRecord *BIMIRecord
|
|
||||||
Errors []string
|
Errors []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,17 +93,6 @@ type DMARCRecord struct {
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BIMIRecord represents a BIMI record
|
|
||||||
type BIMIRecord struct {
|
|
||||||
Selector string
|
|
||||||
Domain string
|
|
||||||
Record string
|
|
||||||
LogoURL string // URL to the brand logo (SVG)
|
|
||||||
VMCURL string // URL to Verified Mark Certificate (optional)
|
|
||||||
Valid bool
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnalyzeDNS performs DNS validation for the email's domain
|
// AnalyzeDNS performs DNS validation for the email's domain
|
||||||
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
|
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
|
||||||
// Extract domain from From address
|
// Extract domain from From address
|
||||||
|
|
@ -140,9 +128,6 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic
|
||||||
// Check DMARC record
|
// Check DMARC record
|
||||||
results.DMARCRecord = d.checkDMARCRecord(domain)
|
results.DMARCRecord = d.checkDMARCRecord(domain)
|
||||||
|
|
||||||
// Check BIMI record (using default selector)
|
|
||||||
results.BIMIRecord = d.checkBIMIRecord(domain, "default")
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -410,89 +395,6 @@ func (d *DNSAnalyzer) validateDMARC(record string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkBIMIRecord looks up and validates BIMI record for a domain and selector
|
|
||||||
func (d *DNSAnalyzer) checkBIMIRecord(domain, selector string) *BIMIRecord {
|
|
||||||
// BIMI records are at: selector._bimi.domain
|
|
||||||
bimiDomain := fmt.Sprintf("%s._bimi.%s", selector, domain)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
txtRecords, err := d.resolver.LookupTXT(ctx, bimiDomain)
|
|
||||||
if err != nil {
|
|
||||||
return &BIMIRecord{
|
|
||||||
Selector: selector,
|
|
||||||
Domain: domain,
|
|
||||||
Valid: false,
|
|
||||||
Error: fmt.Sprintf("Failed to lookup BIMI record: %v", err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(txtRecords) == 0 {
|
|
||||||
return &BIMIRecord{
|
|
||||||
Selector: selector,
|
|
||||||
Domain: domain,
|
|
||||||
Valid: false,
|
|
||||||
Error: "No BIMI record found",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Concatenate all TXT record parts (BIMI can be split)
|
|
||||||
bimiRecord := strings.Join(txtRecords, "")
|
|
||||||
|
|
||||||
// Extract logo URL and VMC URL
|
|
||||||
logoURL := d.extractBIMITag(bimiRecord, "l")
|
|
||||||
vmcURL := d.extractBIMITag(bimiRecord, "a")
|
|
||||||
|
|
||||||
// Basic validation - should contain "v=BIMI1" and "l=" (logo URL)
|
|
||||||
if !d.validateBIMI(bimiRecord) {
|
|
||||||
return &BIMIRecord{
|
|
||||||
Selector: selector,
|
|
||||||
Domain: domain,
|
|
||||||
Record: bimiRecord,
|
|
||||||
LogoURL: logoURL,
|
|
||||||
VMCURL: vmcURL,
|
|
||||||
Valid: false,
|
|
||||||
Error: "BIMI record appears malformed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &BIMIRecord{
|
|
||||||
Selector: selector,
|
|
||||||
Domain: domain,
|
|
||||||
Record: bimiRecord,
|
|
||||||
LogoURL: logoURL,
|
|
||||||
VMCURL: vmcURL,
|
|
||||||
Valid: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractBIMITag extracts a tag value from a BIMI record
|
|
||||||
func (d *DNSAnalyzer) extractBIMITag(record, tag string) string {
|
|
||||||
// Look for tag=value pattern
|
|
||||||
re := regexp.MustCompile(tag + `=([^;]+)`)
|
|
||||||
matches := re.FindStringSubmatch(record)
|
|
||||||
if len(matches) > 1 {
|
|
||||||
return strings.TrimSpace(matches[1])
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateBIMI performs basic BIMI record validation
|
|
||||||
func (d *DNSAnalyzer) validateBIMI(record string) bool {
|
|
||||||
// Must start with v=BIMI1
|
|
||||||
if !strings.HasPrefix(record, "v=BIMI1") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must have a logo URL tag (l=)
|
|
||||||
if !strings.Contains(record, "l=") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateDNSChecks generates check results for DNS validation
|
// GenerateDNSChecks generates check results for DNS validation
|
||||||
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
||||||
var checks []api.Check
|
var checks []api.Check
|
||||||
|
|
@ -519,11 +421,6 @@ func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
|
||||||
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
|
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
|
||||||
}
|
}
|
||||||
|
|
||||||
// BIMI record check (optional)
|
|
||||||
if results.BIMIRecord != nil {
|
|
||||||
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
|
|
||||||
}
|
|
||||||
|
|
||||||
return checks
|
return checks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -537,7 +434,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
|
||||||
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
|
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
|
|
||||||
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
|
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
|
||||||
check.Message = results.MXRecords[0].Error
|
check.Message = results.MXRecords[0].Error
|
||||||
|
|
@ -548,7 +445,7 @@ func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
|
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
|
||||||
|
|
||||||
// Add details about MX records
|
// Add details about MX records
|
||||||
|
|
@ -577,14 +474,14 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = spf.Error
|
check.Message = spf.Error
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
|
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
|
||||||
} else {
|
} else {
|
||||||
// If record exists but is invalid, it's a warning
|
// If record exists but is invalid, it's a warning
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.5
|
check.Score = 0.5
|
||||||
check.Message = "SPF record found but appears invalid"
|
check.Message = "SPF record found but appears invalid"
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
|
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
|
||||||
check.Details = &spf.Record
|
check.Details = &spf.Record
|
||||||
}
|
}
|
||||||
|
|
@ -592,7 +489,7 @@ func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Message = "Valid SPF record found"
|
check.Message = "Valid SPF record found"
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Details = &spf.Record
|
check.Details = &spf.Record
|
||||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||||
}
|
}
|
||||||
|
|
@ -611,7 +508,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
|
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
|
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)
|
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
||||||
check.Details = &details
|
check.Details = &details
|
||||||
|
|
@ -619,7 +516,7 @@ func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Message = "Valid DKIM record found"
|
check.Message = "Valid DKIM record found"
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
|
||||||
check.Details = &details
|
check.Details = &details
|
||||||
check.Advice = api.PtrTo("Your DKIM record is properly published")
|
check.Advice = api.PtrTo("Your DKIM record is properly published")
|
||||||
|
|
@ -639,13 +536,13 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = dmarc.Error
|
check.Message = dmarc.Error
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
|
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
|
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Details = &dmarc.Record
|
check.Details = &dmarc.Record
|
||||||
|
|
||||||
// Provide advice based on policy
|
// Provide advice based on policy
|
||||||
|
|
@ -667,53 +564,3 @@ func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
|
||||||
|
|
||||||
return check
|
return check
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateBIMICheck creates a check for BIMI records
|
|
||||||
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) 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.0
|
|
||||||
check.Message = "No BIMI record found (optional)"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
|
||||||
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients. Requires enforced DMARC policy (p=quarantine or p=reject)")
|
|
||||||
} else {
|
|
||||||
// If record exists but is invalid
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
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 = 0.0 // 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
|
|
||||||
}
|
|
||||||
|
|
@ -631,190 +631,3 @@ func TestAnalyzeDNS_NoDomain(t *testing.T) {
|
||||||
t.Error("Expected error when no domain can be extracted")
|
t.Error("Expected error when no domain can be extracted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractBIMITag(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
record string
|
|
||||||
tag string
|
|
||||||
expectedValue string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Extract logo URL (l tag)",
|
|
||||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
|
||||||
tag: "l",
|
|
||||||
expectedValue: "https://example.com/logo.svg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Extract VMC URL (a tag)",
|
|
||||||
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
|
||||||
tag: "a",
|
|
||||||
expectedValue: "https://example.com/vmc.pem",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Tag not found",
|
|
||||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
|
||||||
tag: "a",
|
|
||||||
expectedValue: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Tag with spaces",
|
|
||||||
record: "v=BIMI1; l= https://example.com/logo.svg ",
|
|
||||||
tag: "l",
|
|
||||||
expectedValue: "https://example.com/logo.svg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty record",
|
|
||||||
record: "",
|
|
||||||
tag: "l",
|
|
||||||
expectedValue: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.extractBIMITag(tt.record, tt.tag)
|
|
||||||
if result != tt.expectedValue {
|
|
||||||
t.Errorf("extractBIMITag(%q, %q) = %q, want %q", tt.record, tt.tag, result, tt.expectedValue)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateBIMI(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
record string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Valid BIMI with logo URL",
|
|
||||||
record: "v=BIMI1; l=https://example.com/logo.svg",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid BIMI with logo and VMC",
|
|
||||||
record: "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem",
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid BIMI - no version",
|
|
||||||
record: "l=https://example.com/logo.svg",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid BIMI - wrong version",
|
|
||||||
record: "v=BIMI2; l=https://example.com/logo.svg",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid BIMI - no logo URL",
|
|
||||||
record: "v=BIMI1",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid BIMI - empty",
|
|
||||||
record: "",
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewDNSAnalyzer(5 * time.Second)
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.validateBIMI(tt.record)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("validateBIMI(%q) = %v, want %v", tt.record, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateBIMICheck(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
bimi *BIMIRecord
|
|
||||||
expectedStatus api.CheckStatus
|
|
||||||
expectedScore float32
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
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.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.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.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.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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -279,7 +279,7 @@ func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
|
||||||
Status: api.CheckStatusWarn,
|
Status: api.CheckStatusWarn,
|
||||||
Score: 1.0,
|
Score: 1.0,
|
||||||
Message: "No public IP addresses found to check",
|
Message: "No public IP addresses found to check",
|
||||||
Severity: api.PtrTo(api.CheckSeverityLow),
|
Severity: api.PtrTo(api.Low),
|
||||||
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
|
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
|
||||||
})
|
})
|
||||||
return checks
|
return checks
|
||||||
|
|
@ -316,22 +316,22 @@ func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
|
||||||
if listedCount == 0 {
|
if listedCount == 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
|
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Advice = api.PtrTo("Your sending IP has a good reputation")
|
check.Advice = api.PtrTo("Your sending IP has a good reputation")
|
||||||
} else if listedCount == 1 {
|
} else if listedCount == 1 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
|
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
|
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
|
||||||
} else if listedCount <= 3 {
|
} else if listedCount <= 3 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
|
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
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")
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,15 +357,15 @@ func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
|
||||||
|
|
||||||
// Determine severity based on which RBL
|
// Determine severity based on which RBL
|
||||||
if strings.Contains(rblCheck.RBL, "spamhaus") {
|
if strings.Contains(rblCheck.RBL, "spamhaus") {
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
|
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
} else if strings.Contains(rblCheck.RBL, "spamcop") {
|
} else if strings.Contains(rblCheck.RBL, "spamcop") {
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
|
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
} else {
|
} else {
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
|
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +419,7 @@ func TestGenerateListingCheck(t *testing.T) {
|
||||||
Response: "127.0.0.2",
|
Response: "127.0.0.2",
|
||||||
},
|
},
|
||||||
expectedStatus: api.CheckStatusFail,
|
expectedStatus: api.CheckStatusFail,
|
||||||
expectedSeverity: api.CheckSeverityCritical,
|
expectedSeverity: api.Critical,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SpamCop listing",
|
name: "SpamCop listing",
|
||||||
|
|
@ -430,7 +430,7 @@ func TestGenerateListingCheck(t *testing.T) {
|
||||||
Response: "127.0.0.2",
|
Response: "127.0.0.2",
|
||||||
},
|
},
|
||||||
expectedStatus: api.CheckStatusFail,
|
expectedStatus: api.CheckStatusFail,
|
||||||
expectedSeverity: api.CheckSeverityHigh,
|
expectedSeverity: api.High,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Other RBL listing",
|
name: "Other RBL listing",
|
||||||
|
|
@ -441,7 +441,7 @@ func TestGenerateListingCheck(t *testing.T) {
|
||||||
Response: "127.0.0.2",
|
Response: "127.0.0.2",
|
||||||
},
|
},
|
||||||
expectedStatus: api.CheckStatusFail,
|
expectedStatus: api.CheckStatusFail,
|
||||||
expectedSeverity: api.CheckSeverityHigh,
|
expectedSeverity: api.High,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +72,8 @@ func (s *DeliverabilityScorer) CalculateScore(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate individual scores
|
// Calculate individual scores
|
||||||
result.AuthScore = s.GetAuthenticationScore(authResults)
|
authAnalyzer := NewAuthenticationAnalyzer()
|
||||||
|
result.AuthScore = authAnalyzer.GetAuthenticationScore(authResults)
|
||||||
|
|
||||||
spamAnalyzer := NewSpamAssassinAnalyzer()
|
spamAnalyzer := NewSpamAssassinAnalyzer()
|
||||||
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
|
result.SpamScore = spamAnalyzer.GetSpamAssassinScore(spamResult)
|
||||||
|
|
@ -350,13 +351,13 @@ func (s *DeliverabilityScorer) generateRequiredHeadersCheck(email *EmailMessage)
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.4
|
check.Score = 0.4
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = "All required headers are present"
|
check.Message = "All required headers are present"
|
||||||
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
|
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
|
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
|
||||||
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
|
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
|
||||||
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
||||||
|
|
@ -385,13 +386,13 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.3
|
check.Score = 0.3
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = "All recommended headers are present"
|
check.Message = "All recommended headers are present"
|
||||||
check.Advice = api.PtrTo("Your email includes all recommended headers")
|
check.Advice = api.PtrTo("Your email includes all recommended headers")
|
||||||
} else if len(missing) < len(recommendedHeaders) {
|
} else if len(missing) < len(recommendedHeaders) {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.15
|
check.Score = 0.15
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
check.Severity = api.PtrTo(api.Low)
|
||||||
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
|
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
|
||||||
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
|
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
|
||||||
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
||||||
|
|
@ -399,7 +400,7 @@ func (s *DeliverabilityScorer) generateRecommendedHeadersCheck(email *EmailMessa
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Message = "Missing all recommended headers"
|
check.Message = "Missing all recommended headers"
|
||||||
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
|
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
|
||||||
}
|
}
|
||||||
|
|
@ -419,20 +420,20 @@ func (s *DeliverabilityScorer) generateMessageIDCheck(email *EmailMessage) api.C
|
||||||
if messageID == "" {
|
if messageID == "" {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
check.Message = "Message-ID header is missing"
|
check.Message = "Message-ID header is missing"
|
||||||
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
|
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
|
||||||
} else if !s.isValidMessageID(messageID) {
|
} else if !s.isValidMessageID(messageID) {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.05
|
check.Score = 0.05
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Message = "Message-ID format is invalid"
|
check.Message = "Message-ID format is invalid"
|
||||||
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
|
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
|
||||||
check.Details = &messageID
|
check.Details = &messageID
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.1
|
check.Score = 0.1
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = "Message-ID is properly formatted"
|
check.Message = "Message-ID is properly formatted"
|
||||||
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
|
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
|
||||||
check.Details = &messageID
|
check.Details = &messageID
|
||||||
|
|
@ -451,13 +452,13 @@ func (s *DeliverabilityScorer) generateMIMEStructureCheck(email *EmailMessage) a
|
||||||
if len(email.Parts) == 0 {
|
if len(email.Parts) == 0 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
check.Severity = api.PtrTo(api.Low)
|
||||||
check.Message = "No MIME parts detected"
|
check.Message = "No MIME parts detected"
|
||||||
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
|
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 0.2
|
check.Score = 0.2
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
|
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
|
||||||
check.Advice = api.PtrTo("Your email has proper MIME structure")
|
check.Advice = api.PtrTo("Your email has proper MIME structure")
|
||||||
|
|
||||||
|
|
@ -503,43 +504,3 @@ func (s *DeliverabilityScorer) GetScoreSummary(result *ScoringResult) string {
|
||||||
|
|
||||||
return summary.String()
|
return summary.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAuthenticationScore calculates the authentication score (0-3 points)
|
|
||||||
func (s *DeliverabilityScorer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
|
|
||||||
var score float32 = 0.0
|
|
||||||
|
|
||||||
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
|
|
||||||
if results.Spf != nil {
|
|
||||||
switch results.Spf.Result {
|
|
||||||
case api.AuthResultResultPass:
|
|
||||||
score += 1.0
|
|
||||||
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
|
|
||||||
score += 0.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DKIM: 1 point for at least one pass
|
|
||||||
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
|
||||||
for _, dkim := range *results.Dkim {
|
|
||||||
if dkim.Result == api.AuthResultResultPass {
|
|
||||||
score += 1.0
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DMARC: 1 point for pass
|
|
||||||
if results.Dmarc != nil {
|
|
||||||
switch results.Dmarc.Result {
|
|
||||||
case api.AuthResultResultPass:
|
|
||||||
score += 1.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap at 3 points maximum
|
|
||||||
if score > 3.0 {
|
|
||||||
score = 3.0
|
|
||||||
}
|
|
||||||
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
@ -86,7 +86,7 @@ func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAss
|
||||||
|
|
||||||
// Parse X-Spam-Report header for detailed test results
|
// Parse X-Spam-Report header for detailed test results
|
||||||
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
if reportHeader, ok := headers["X-Spam-Report"]; ok {
|
||||||
result.RawReport = strings.Replace(reportHeader, " * ", "\n * ", -1)
|
result.RawReport = reportHeader
|
||||||
a.parseSpamReport(reportHeader, result)
|
a.parseSpamReport(reportHeader, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,25 +140,20 @@ func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssass
|
||||||
// Format varies, but typically:
|
// Format varies, but typically:
|
||||||
// * 1.5 TEST_NAME Description of test
|
// * 1.5 TEST_NAME Description of test
|
||||||
// * 0.0 TEST_NAME2 Description
|
// * 0.0 TEST_NAME2 Description
|
||||||
// Note: mail.Header.Get() joins continuation lines, so newlines are removed.
|
|
||||||
// We split on '*' to separate individual tests.
|
|
||||||
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
|
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
|
||||||
// The report header has been joined by mail.Header.Get(), so we split on '*'
|
// Split by lines
|
||||||
// Each segment starting with '*' is either a test line or continuation
|
lines := strings.Split(report, "\n")
|
||||||
segments := strings.Split(report, "*")
|
|
||||||
|
|
||||||
// Regex to match test lines: score TEST_NAME Description
|
// Regex to match test lines: * score TEST_NAME Description
|
||||||
// Format: " 0.0 TEST_NAME Description" or " -0.1 TEST_NAME Description"
|
testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
|
||||||
testRe := regexp.MustCompile(`^\s*(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
|
|
||||||
|
|
||||||
for _, segment := range segments {
|
for _, line := range lines {
|
||||||
segment = strings.TrimSpace(segment)
|
line = strings.TrimSpace(line)
|
||||||
if segment == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match as a test line
|
matches := testRe.FindStringSubmatch(line)
|
||||||
matches := testRe.FindStringSubmatch(segment)
|
|
||||||
if len(matches) > 3 {
|
if len(matches) > 3 {
|
||||||
testName := matches[2]
|
testName := matches[2]
|
||||||
score, _ := strconv.ParseFloat(matches[1], 64)
|
score, _ := strconv.ParseFloat(matches[1], 64)
|
||||||
|
|
@ -222,7 +217,7 @@ func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinRe
|
||||||
Status: api.CheckStatusWarn,
|
Status: api.CheckStatusWarn,
|
||||||
Score: 0.0,
|
Score: 0.0,
|
||||||
Message: "No SpamAssassin headers found",
|
Message: "No SpamAssassin headers found",
|
||||||
Severity: api.PtrTo(api.CheckSeverityMedium),
|
Severity: api.PtrTo(api.Medium),
|
||||||
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
|
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
|
||||||
})
|
})
|
||||||
return checks
|
return checks
|
||||||
|
|
@ -265,27 +260,27 @@ func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult)
|
||||||
if score <= 0 {
|
if score <= 0 {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
|
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
|
||||||
} else if score < required {
|
} else if score < required {
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Advice = api.PtrTo("Your email passes spam filters")
|
check.Advice = api.PtrTo("Your email passes spam filters")
|
||||||
} else if score < required*1.5 {
|
} else if score < required*1.5 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
|
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
|
||||||
} else if score < required*2 {
|
} else if score < required*2 {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
|
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
|
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
|
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -312,10 +307,10 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
|
||||||
// Negative indicator (increases spam score)
|
// Negative indicator (increases spam score)
|
||||||
if detail.Score > 2.0 {
|
if detail.Score > 2.0 {
|
||||||
check.Status = api.CheckStatusFail
|
check.Status = api.CheckStatusFail
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
check.Severity = api.PtrTo(api.High)
|
||||||
} else {
|
} else {
|
||||||
check.Status = api.CheckStatusWarn
|
check.Status = api.CheckStatusWarn
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
}
|
}
|
||||||
check.Score = 0.0
|
check.Score = 0.0
|
||||||
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
|
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
|
||||||
|
|
@ -325,7 +320,7 @@ func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Chec
|
||||||
// Positive indicator (decreases spam score)
|
// Positive indicator (decreases spam score)
|
||||||
check.Status = api.CheckStatusPass
|
check.Status = api.CheckStatusPass
|
||||||
check.Score = 1.0
|
check.Score = 1.0
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
check.Severity = api.PtrTo(api.Info)
|
||||||
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
|
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)
|
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
|
||||||
check.Advice = &advice
|
check.Advice = &advice
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
package analyzer
|
package analyzer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -481,176 +480,6 @@ func TestGenerateTestCheck(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
RCVD_IN_VALIDITY_RPBL_BLOCKED,RCVD_IN_VALIDITY_SAFE_BLOCKED,
|
|
||||||
SPF_HELO_NONE,SPF_PASS autolearn=disabled version=4.0.1
|
|
||||||
X-Spam-Level:
|
|
||||||
X-Spam-Report:
|
|
||||||
* 0.0 RCVD_IN_VALIDITY_SAFE_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
|
|
||||||
* to Validity was blocked. See
|
|
||||||
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
|
||||||
* more information.
|
|
||||||
* [80.67.179.207 listed in sa-accredit.habeas.com]
|
|
||||||
* 0.0 RCVD_IN_VALIDITY_RPBL_BLOCKED RBL: ADMINISTRATOR NOTICE: The query
|
|
||||||
* to Validity was blocked. See
|
|
||||||
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
|
||||||
* more information.
|
|
||||||
* [80.67.179.207 listed in bl.score.senderscore.com]
|
|
||||||
* 0.0 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED RBL: ADMINISTRATOR NOTICE: The
|
|
||||||
* query to Validity was blocked. See
|
|
||||||
* https://knowledge.validity.com/hc/en-us/articles/20961730681243 for
|
|
||||||
* more information.
|
|
||||||
* [80.67.179.207 listed in sa-trusted.bondedsender.org]
|
|
||||||
* -0.0 SPF_PASS SPF: sender matches SPF record
|
|
||||||
* 0.0 SPF_HELO_NONE SPF: HELO does not publish an SPF Record
|
|
||||||
* -0.1 DKIM_VALID Message has at least one valid DKIM or DK signature
|
|
||||||
* 0.1 DKIM_SIGNED Message has a DKIM or DK signature, not necessarily
|
|
||||||
* valid
|
|
||||||
* -0.1 DKIM_VALID_AU Message has a valid DKIM or DK signature from author's
|
|
||||||
* domain
|
|
||||||
Date: Sun, 19 Oct 2025 08:37:30 +0000
|
|
||||||
Message-ID: <aPSjR57mUnCAt7sp@happydomain.org>
|
|
||||||
MIME-Version: 1.0
|
|
||||||
Content-Type: text/plain; charset=utf-8
|
|
||||||
Content-Disposition: inline
|
|
||||||
Content-Transfer-Encoding: 8bit
|
|
||||||
|
|
||||||
BODY`
|
|
||||||
|
|
||||||
// TestAnalyzeRealEmailExample tests the analyzer with the real example email file
|
|
||||||
func TestAnalyzeRealEmailExample(t *testing.T) {
|
|
||||||
// Parse the email using the standard net/mail package
|
|
||||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithSpamassassinHeader))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to parse email: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create analyzer and analyze SpamAssassin headers
|
|
||||||
analyzer := NewSpamAssassinAnalyzer()
|
|
||||||
result := analyzer.AnalyzeSpamAssassin(email)
|
|
||||||
|
|
||||||
// Validate that we got a result
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("Expected SpamAssassin result, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate IsSpam flag (should be false for this email)
|
|
||||||
if result.IsSpam {
|
|
||||||
t.Error("IsSpam should be false for real_example.eml")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate score (should be -0.1)
|
|
||||||
expectedScore := -0.1
|
|
||||||
if result.Score != expectedScore {
|
|
||||||
t.Errorf("Score = %v, want %v", result.Score, expectedScore)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required score (should be 5.0)
|
|
||||||
expectedRequired := 5.0
|
|
||||||
if result.RequiredScore != expectedRequired {
|
|
||||||
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, expectedRequired)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate version
|
|
||||||
if !strings.Contains(result.Version, "SpamAssassin") {
|
|
||||||
t.Errorf("Version should contain 'SpamAssassin', got: %s", result.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that tests were extracted
|
|
||||||
if len(result.Tests) == 0 {
|
|
||||||
t.Error("Expected tests to be extracted, got none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for expected tests from the real email
|
|
||||||
expectedTests := map[string]bool{
|
|
||||||
"DKIM_SIGNED": true,
|
|
||||||
"DKIM_VALID": true,
|
|
||||||
"DKIM_VALID_AU": true,
|
|
||||||
"SPF_PASS": true,
|
|
||||||
"SPF_HELO_NONE": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testName := range result.Tests {
|
|
||||||
if expectedTests[testName] {
|
|
||||||
t.Logf("Found expected test: %s", testName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that test details were parsed from X-Spam-Report
|
|
||||||
if len(result.TestDetails) == 0 {
|
|
||||||
t.Error("Expected test details to be parsed from X-Spam-Report, got none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log what we actually got for debugging
|
|
||||||
t.Logf("Parsed %d test details from X-Spam-Report", len(result.TestDetails))
|
|
||||||
for name, detail := range result.TestDetails {
|
|
||||||
t.Logf(" %s: score=%v, description=%s", name, detail.Score, detail.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define expected test details with their scores
|
|
||||||
expectedTestDetails := map[string]float64{
|
|
||||||
"SPF_PASS": -0.0,
|
|
||||||
"SPF_HELO_NONE": 0.0,
|
|
||||||
"DKIM_VALID": -0.1,
|
|
||||||
"DKIM_SIGNED": 0.1,
|
|
||||||
"DKIM_VALID_AU": -0.1,
|
|
||||||
"RCVD_IN_VALIDITY_SAFE_BLOCKED": 0.0,
|
|
||||||
"RCVD_IN_VALIDITY_RPBL_BLOCKED": 0.0,
|
|
||||||
"RCVD_IN_VALIDITY_CERTIFIED_BLOCKED": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate over expected tests and verify they exist in TestDetails
|
|
||||||
for testName, expectedScore := range expectedTestDetails {
|
|
||||||
detail, ok := result.TestDetails[testName]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("Expected test %s not found in TestDetails", testName)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if detail.Score != expectedScore {
|
|
||||||
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expectedScore)
|
|
||||||
}
|
|
||||||
if detail.Description == "" {
|
|
||||||
t.Errorf("Test %s should have a description", testName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test GetSpamAssassinScore
|
|
||||||
score := analyzer.GetSpamAssassinScore(result)
|
|
||||||
if score != 2.0 {
|
|
||||||
t.Errorf("GetSpamAssassinScore() = %v, want 2.0 (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 != 2.0 {
|
|
||||||
t.Errorf("Main check score = %v, want 2.0", mainCheck.Score)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log all checks for debugging
|
|
||||||
t.Logf("Generated %d checks:", len(checks))
|
|
||||||
for i, check := range checks {
|
|
||||||
t.Logf(" Check %d: %s - %s (score: %.1f, status: %s)",
|
|
||||||
i+1, check.Name, check.Message, check.Score, check.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to compare string slices
|
// Helper function to compare string slices
|
||||||
func stringSliceEqual(a, b []string) bool {
|
func stringSliceEqual(a, b []string) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
|
|
@ -31,9 +31,9 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/analyzer"
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
"git.happydns.org/happyDeliver/internal/api"
|
||||||
"git.happydns.org/happyDeliver/internal/config"
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
"git.happydns.org/happyDeliver/pkg/analyzer"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunAnalyzer runs the standalone email analyzer (from stdin)
|
// RunAnalyzer runs the standalone email analyzer (from stdin)
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,9 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/analyzer"
|
||||||
"git.happydns.org/happyDeliver/internal/config"
|
"git.happydns.org/happyDeliver/internal/config"
|
||||||
"git.happydns.org/happyDeliver/internal/storage"
|
"git.happydns.org/happyDeliver/internal/storage"
|
||||||
"git.happydns.org/happyDeliver/pkg/analyzer"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailReceiver handles incoming emails from the MTA
|
// EmailReceiver handles incoming emails from the MTA
|
||||||
|
|
|
||||||
|
|
@ -1,304 +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.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.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.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 = 1.0
|
|
||||||
check.Message = "SPF validation passed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
||||||
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
|
||||||
case api.AuthResultResultFail:
|
|
||||||
check.Status = api.CheckStatusFail
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "SPF validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
|
||||||
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
|
|
||||||
case api.AuthResultResultSoftfail:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.5
|
|
||||||
check.Message = "SPF validation softfail"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
|
||||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
|
||||||
case api.AuthResultResultNeutral:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.5
|
|
||||||
check.Message = "SPF validation neutral"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
|
||||||
check.Advice = api.PtrTo("Consider tightening your SPF policy")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
|
||||||
check.Advice = api.PtrTo("Review your SPF record configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
if spf.Domain != nil {
|
|
||||||
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 = 1.0
|
|
||||||
check.Message = "DKIM signature is valid"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
||||||
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
|
|
||||||
case api.AuthResultResultFail:
|
|
||||||
check.Status = api.CheckStatusFail
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "DKIM signature validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
|
||||||
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
|
||||||
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
|
|
||||||
}
|
|
||||||
|
|
||||||
var detailsParts []string
|
|
||||||
if dkim.Domain != nil {
|
|
||||||
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
|
|
||||||
}
|
|
||||||
if dkim.Selector != nil {
|
|
||||||
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
|
|
||||||
}
|
|
||||||
if len(detailsParts) > 0 {
|
|
||||||
details := strings.Join(detailsParts, ", ")
|
|
||||||
check.Details = &details
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = 1.0
|
|
||||||
check.Message = "DMARC validation passed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
||||||
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
|
|
||||||
case api.AuthResultResultFail:
|
|
||||||
check.Status = api.CheckStatusFail
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "DMARC validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
|
||||||
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
|
||||||
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
|
|
||||||
}
|
|
||||||
|
|
||||||
if dmarc.Domain != nil {
|
|
||||||
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.0 // BIMI doesn't contribute to score (branding feature)
|
|
||||||
check.Message = "BIMI validation passed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
||||||
check.Advice = api.PtrTo("Your brand logo is properly configured via BIMI")
|
|
||||||
case api.AuthResultResultFail:
|
|
||||||
check.Status = api.CheckStatusInfo
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "BIMI validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
|
||||||
check.Advice = api.PtrTo("BIMI is optional but can improve brand recognition. Ensure DMARC is enforced (p=quarantine or p=reject) and configure a valid BIMI record")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusInfo
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = fmt.Sprintf("BIMI validation result: %s", bimi.Result)
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
|
||||||
check.Advice = api.PtrTo("BIMI is optional. Consider implementing it to display your brand logo in supported email clients")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bimi.Domain != nil {
|
|
||||||
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.0 // ARC doesn't contribute to score (informational for forwarding)
|
|
||||||
check.Message = "ARC chain validation passed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
||||||
check.Advice = api.PtrTo("ARC preserves authentication results through email forwarding. Your email passed through intermediaries while maintaining authentication")
|
|
||||||
case api.ARCResultResultFail:
|
|
||||||
check.Status = api.CheckStatusWarn
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "ARC chain validation failed"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
|
||||||
check.Advice = api.PtrTo("The ARC chain is broken or invalid. This may indicate issues with email forwarding intermediaries")
|
|
||||||
default:
|
|
||||||
check.Status = api.CheckStatusInfo
|
|
||||||
check.Score = 0.0
|
|
||||||
check.Message = "No ARC chain present"
|
|
||||||
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
|
||||||
check.Advice = api.PtrTo("ARC is not present. This is normal for emails sent directly without forwarding through mailing lists or other intermediaries")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build details
|
|
||||||
var detailsParts []string
|
|
||||||
if arc.ChainLength != nil {
|
|
||||||
detailsParts = append(detailsParts, fmt.Sprintf("Chain length: %d", *arc.ChainLength))
|
|
||||||
}
|
|
||||||
if arc.ChainValid != nil {
|
|
||||||
detailsParts = append(detailsParts, fmt.Sprintf("Chain valid: %v", *arc.ChainValid))
|
|
||||||
}
|
|
||||||
if arc.Details != nil {
|
|
||||||
detailsParts = append(detailsParts, *arc.Details)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(detailsParts) > 0 {
|
|
||||||
details := strings.Join(detailsParts, ", ")
|
|
||||||
check.Details = &details
|
|
||||||
}
|
|
||||||
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
|
|
@ -1,846 +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 (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.happydns.org/happyDeliver/internal/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseSPFResult(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
part string
|
|
||||||
expectedResult api.AuthResultResult
|
|
||||||
expectedDomain string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "SPF pass with domain",
|
|
||||||
part: "spf=pass smtp.mailfrom=sender@example.com",
|
|
||||||
expectedResult: api.AuthResultResultPass,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF fail",
|
|
||||||
part: "spf=fail smtp.mailfrom=sender@example.com",
|
|
||||||
expectedResult: api.AuthResultResultFail,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF neutral",
|
|
||||||
part: "spf=neutral smtp.mailfrom=sender@example.com",
|
|
||||||
expectedResult: api.AuthResultResultNeutral,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF softfail",
|
|
||||||
part: "spf=softfail smtp.mailfrom=sender@example.com",
|
|
||||||
expectedResult: api.AuthResultResultSoftfail,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.parseSPFResult(tt.part)
|
|
||||||
|
|
||||||
if result.Result != tt.expectedResult {
|
|
||||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
|
||||||
}
|
|
||||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
|
||||||
var gotDomain string
|
|
||||||
if result.Domain != nil {
|
|
||||||
gotDomain = *result.Domain
|
|
||||||
}
|
|
||||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDKIMResult(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
part string
|
|
||||||
expectedResult api.AuthResultResult
|
|
||||||
expectedDomain string
|
|
||||||
expectedSelector string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "DKIM pass with domain and selector",
|
|
||||||
part: "dkim=pass header.d=example.com header.s=default",
|
|
||||||
expectedResult: api.AuthResultResultPass,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
expectedSelector: "default",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "DKIM fail",
|
|
||||||
part: "dkim=fail header.d=example.com header.s=selector1",
|
|
||||||
expectedResult: api.AuthResultResultFail,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
expectedSelector: "selector1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "DKIM with short form (d= and s=)",
|
|
||||||
part: "dkim=pass d=example.com s=default",
|
|
||||||
expectedResult: api.AuthResultResultPass,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
expectedSelector: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.parseDKIMResult(tt.part)
|
|
||||||
|
|
||||||
if result.Result != tt.expectedResult {
|
|
||||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
|
||||||
}
|
|
||||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
|
||||||
var gotDomain string
|
|
||||||
if result.Domain != nil {
|
|
||||||
gotDomain = *result.Domain
|
|
||||||
}
|
|
||||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
|
||||||
}
|
|
||||||
if result.Selector == nil || *result.Selector != tt.expectedSelector {
|
|
||||||
var gotSelector string
|
|
||||||
if result.Selector != nil {
|
|
||||||
gotSelector = *result.Selector
|
|
||||||
}
|
|
||||||
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDMARCResult(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
part string
|
|
||||||
expectedResult api.AuthResultResult
|
|
||||||
expectedDomain string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "DMARC pass",
|
|
||||||
part: "dmarc=pass action=none header.from=example.com",
|
|
||||||
expectedResult: api.AuthResultResultPass,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "DMARC fail",
|
|
||||||
part: "dmarc=fail action=quarantine header.from=example.com",
|
|
||||||
expectedResult: api.AuthResultResultFail,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.parseDMARCResult(tt.part)
|
|
||||||
|
|
||||||
if result.Result != tt.expectedResult {
|
|
||||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
|
||||||
}
|
|
||||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
|
||||||
var gotDomain string
|
|
||||||
if result.Domain != nil {
|
|
||||||
gotDomain = *result.Domain
|
|
||||||
}
|
|
||||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseBIMIResult(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
part string
|
|
||||||
expectedResult api.AuthResultResult
|
|
||||||
expectedDomain string
|
|
||||||
expectedSelector string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "BIMI pass with domain and selector",
|
|
||||||
part: "bimi=pass header.d=example.com header.selector=default",
|
|
||||||
expectedResult: api.AuthResultResultPass,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
expectedSelector: "default",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "BIMI fail",
|
|
||||||
part: "bimi=fail header.d=example.com header.selector=default",
|
|
||||||
expectedResult: api.AuthResultResultFail,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
expectedSelector: "default",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "BIMI with short form (d= and selector=)",
|
|
||||||
part: "bimi=pass d=example.com selector=v1",
|
|
||||||
expectedResult: api.AuthResultResultPass,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
expectedSelector: "v1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "BIMI none",
|
|
||||||
part: "bimi=none header.d=example.com",
|
|
||||||
expectedResult: api.AuthResultResultNone,
|
|
||||||
expectedDomain: "example.com",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.parseBIMIResult(tt.part)
|
|
||||||
|
|
||||||
if result.Result != tt.expectedResult {
|
|
||||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
|
||||||
}
|
|
||||||
if result.Domain == nil || *result.Domain != tt.expectedDomain {
|
|
||||||
var gotDomain string
|
|
||||||
if result.Domain != nil {
|
|
||||||
gotDomain = *result.Domain
|
|
||||||
}
|
|
||||||
t.Errorf("Domain = %v, want %v", gotDomain, tt.expectedDomain)
|
|
||||||
}
|
|
||||||
if tt.expectedSelector != "" {
|
|
||||||
if result.Selector == nil || *result.Selector != tt.expectedSelector {
|
|
||||||
var gotSelector string
|
|
||||||
if result.Selector != nil {
|
|
||||||
gotSelector = *result.Selector
|
|
||||||
}
|
|
||||||
t.Errorf("Selector = %v, want %v", gotSelector, tt.expectedSelector)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateAuthSPFCheck(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
spf *api.AuthResult
|
|
||||||
expectedStatus api.CheckStatus
|
|
||||||
expectedScore float32
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "SPF pass",
|
|
||||||
spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusPass,
|
|
||||||
expectedScore: 1.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF fail",
|
|
||||||
spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultFail,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusFail,
|
|
||||||
expectedScore: 0.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF softfail",
|
|
||||||
spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultSoftfail,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusWarn,
|
|
||||||
expectedScore: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF neutral",
|
|
||||||
spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultNeutral,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusWarn,
|
|
||||||
expectedScore: 0.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 float32
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "DKIM pass",
|
|
||||||
dkim: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
Selector: api.PtrTo("default"),
|
|
||||||
},
|
|
||||||
index: 0,
|
|
||||||
expectedStatus: api.CheckStatusPass,
|
|
||||||
expectedScore: 1.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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.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.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 float32
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "DMARC pass",
|
|
||||||
dmarc: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusPass,
|
|
||||||
expectedScore: 1.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "DMARC fail",
|
|
||||||
dmarc: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultFail,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusFail,
|
|
||||||
expectedScore: 0.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 float32
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "BIMI pass",
|
|
||||||
bimi: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusPass,
|
|
||||||
expectedScore: 0.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.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "BIMI none",
|
|
||||||
bimi: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultNone,
|
|
||||||
Domain: api.PtrTo("example.com"),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusInfo,
|
|
||||||
expectedScore: 0.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
|
|
||||||
results *api.AuthenticationResults
|
|
||||||
expectedScore float32
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Perfect authentication (SPF + DKIM + DMARC)",
|
|
||||||
results: &api.AuthenticationResults{
|
|
||||||
Spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
},
|
|
||||||
Dkim: &[]api.AuthResult{
|
|
||||||
{Result: api.AuthResultResultPass},
|
|
||||||
},
|
|
||||||
Dmarc: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedScore: 3.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF and DKIM only",
|
|
||||||
results: &api.AuthenticationResults{
|
|
||||||
Spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
},
|
|
||||||
Dkim: &[]api.AuthResult{
|
|
||||||
{Result: api.AuthResultResultPass},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedScore: 2.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF fail, DKIM pass",
|
|
||||||
results: &api.AuthenticationResults{
|
|
||||||
Spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultFail,
|
|
||||||
},
|
|
||||||
Dkim: &[]api.AuthResult{
|
|
||||||
{Result: api.AuthResultResultPass},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedScore: 1.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "SPF softfail",
|
|
||||||
results: &api.AuthenticationResults{
|
|
||||||
Spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultSoftfail,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedScore: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No authentication",
|
|
||||||
results: &api.AuthenticationResults{},
|
|
||||||
expectedScore: 0.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "BIMI doesn't affect score",
|
|
||||||
results: &api.AuthenticationResults{
|
|
||||||
Spf: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
},
|
|
||||||
Bimi: &api.AuthResult{
|
|
||||||
Result: api.AuthResultResultPass,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedScore: 1.0, // Only SPF counted, not BIMI
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scorer := NewDeliverabilityScorer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
score := scorer.GetAuthenticationScore(tt.results)
|
|
||||||
|
|
||||||
if score != tt.expectedScore {
|
|
||||||
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
part string
|
|
||||||
expectedResult api.ARCResultResult
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "ARC pass",
|
|
||||||
part: "arc=pass",
|
|
||||||
expectedResult: api.ARCResultResultPass,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ARC fail",
|
|
||||||
part: "arc=fail",
|
|
||||||
expectedResult: api.ARCResultResultFail,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ARC none",
|
|
||||||
part: "arc=none",
|
|
||||||
expectedResult: api.ARCResultResultNone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := analyzer.parseARCResult(tt.part)
|
|
||||||
|
|
||||||
if result.Result != tt.expectedResult {
|
|
||||||
t.Errorf("Result = %v, want %v", result.Result, tt.expectedResult)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateARCChain(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
arcAuthResults []string
|
|
||||||
arcMessageSig []string
|
|
||||||
arcSeal []string
|
|
||||||
expectedValid bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Empty chain is valid",
|
|
||||||
arcAuthResults: []string{},
|
|
||||||
arcMessageSig: []string{},
|
|
||||||
arcSeal: []string{},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid chain with single hop",
|
|
||||||
arcAuthResults: []string{
|
|
||||||
"i=1; example.com; spf=pass",
|
|
||||||
},
|
|
||||||
arcMessageSig: []string{
|
|
||||||
"i=1; a=rsa-sha256; d=example.com",
|
|
||||||
},
|
|
||||||
arcSeal: []string{
|
|
||||||
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Valid chain with two hops",
|
|
||||||
arcAuthResults: []string{
|
|
||||||
"i=1; example.com; spf=pass",
|
|
||||||
"i=2; relay.com; arc=pass",
|
|
||||||
},
|
|
||||||
arcMessageSig: []string{
|
|
||||||
"i=1; a=rsa-sha256; d=example.com",
|
|
||||||
"i=2; a=rsa-sha256; d=relay.com",
|
|
||||||
},
|
|
||||||
arcSeal: []string{
|
|
||||||
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
|
||||||
"i=2; a=rsa-sha256; s=arc; d=relay.com",
|
|
||||||
},
|
|
||||||
expectedValid: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid chain - missing one header type",
|
|
||||||
arcAuthResults: []string{
|
|
||||||
"i=1; example.com; spf=pass",
|
|
||||||
},
|
|
||||||
arcMessageSig: []string{
|
|
||||||
"i=1; a=rsa-sha256; d=example.com",
|
|
||||||
},
|
|
||||||
arcSeal: []string{},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid chain - non-sequential instances",
|
|
||||||
arcAuthResults: []string{
|
|
||||||
"i=1; example.com; spf=pass",
|
|
||||||
"i=3; relay.com; arc=pass",
|
|
||||||
},
|
|
||||||
arcMessageSig: []string{
|
|
||||||
"i=1; a=rsa-sha256; d=example.com",
|
|
||||||
"i=3; a=rsa-sha256; d=relay.com",
|
|
||||||
},
|
|
||||||
arcSeal: []string{
|
|
||||||
"i=1; a=rsa-sha256; s=arc; d=example.com",
|
|
||||||
"i=3; a=rsa-sha256; s=arc; d=relay.com",
|
|
||||||
},
|
|
||||||
expectedValid: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
analyzer := NewAuthenticationAnalyzer()
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
valid := analyzer.validateARCChain(tt.arcAuthResults, tt.arcMessageSig, tt.arcSeal)
|
|
||||||
|
|
||||||
if valid != tt.expectedValid {
|
|
||||||
t.Errorf("validateARCChain() = %v, want %v", valid, tt.expectedValid)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateARCCheck(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
arc *api.ARCResult
|
|
||||||
expectedStatus api.CheckStatus
|
|
||||||
expectedScore float32
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "ARC pass",
|
|
||||||
arc: &api.ARCResult{
|
|
||||||
Result: api.ARCResultResultPass,
|
|
||||||
ChainLength: api.PtrTo(2),
|
|
||||||
ChainValid: api.PtrTo(true),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusPass,
|
|
||||||
expectedScore: 0.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.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ARC none",
|
|
||||||
arc: &api.ARCResult{
|
|
||||||
Result: api.ARCResultResultNone,
|
|
||||||
ChainLength: api.PtrTo(0),
|
|
||||||
ChainValid: api.PtrTo(true),
|
|
||||||
},
|
|
||||||
expectedStatus: api.CheckStatusInfo,
|
|
||||||
expectedScore: 0.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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -26,19 +26,13 @@
|
||||||
icon: "bi-shield-check",
|
icon: "bi-shield-check",
|
||||||
title: "Authentication",
|
title: "Authentication",
|
||||||
description:
|
description:
|
||||||
"SPF, DKIM, DMARC, and BIMI validation with detailed results and recommendations.",
|
"SPF, DKIM, and DMARC validation with detailed results and recommendations.",
|
||||||
variant: "primary" as const,
|
variant: "primary" as const,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: "bi-patch-check",
|
|
||||||
title: "BIMI Support",
|
|
||||||
description: "Brand Indicators for Message Identification - verify your brand logo configuration.",
|
|
||||||
variant: "info" as const,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: "bi-globe",
|
icon: "bi-globe",
|
||||||
title: "DNS Records",
|
title: "DNS Records",
|
||||||
description: "Verify MX, SPF, DKIM, DMARC, and BIMI records are properly configured.",
|
description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.",
|
||||||
variant: "success" as const,
|
variant: "success" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue