Expose analyzer

This commit is contained in:
nemunaire 2025-10-20 07:40:52 +07:00
commit fedb80f7d4
20 changed files with 2 additions and 2 deletions

87
pkg/analyzer/analyzer.go Normal file
View file

@ -0,0 +1,87 @@
// 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 (
"bytes"
"fmt"
"github.com/google/uuid"
"git.happydns.org/happyDeliver/internal/api"
"git.happydns.org/happyDeliver/internal/config"
)
// EmailAnalyzer provides high-level email analysis functionality
// This is the main entry point for analyzing emails from both LMTP and CLI
type EmailAnalyzer struct {
generator *ReportGenerator
}
// NewEmailAnalyzer creates a new email analyzer with the given configuration
func NewEmailAnalyzer(cfg *config.Config) *EmailAnalyzer {
generator := NewReportGenerator(
cfg.Analysis.DNSTimeout,
cfg.Analysis.HTTPTimeout,
cfg.Analysis.RBLs,
)
return &EmailAnalyzer{
generator: generator,
}
}
// AnalysisResult contains the complete analysis result
type AnalysisResult struct {
Email *EmailMessage
Results *AnalysisResults
Report *api.Report
}
// AnalyzeEmailBytes performs complete email analysis from raw bytes
func (a *EmailAnalyzer) AnalyzeEmailBytes(rawEmail []byte, testID uuid.UUID) (*AnalysisResult, error) {
// Parse the email
emailMsg, err := ParseEmail(bytes.NewReader(rawEmail))
if err != nil {
return nil, fmt.Errorf("failed to parse email: %w", err)
}
// Analyze the email
results := a.generator.AnalyzeEmail(emailMsg)
// Generate the report
report := a.generator.GenerateReport(testID, results)
return &AnalysisResult{
Email: emailMsg,
Results: results,
Report: report,
}, nil
}
// GetScoreSummaryText returns a human-readable score summary
func (a *EmailAnalyzer) GetScoreSummaryText(result *AnalysisResult) string {
if result == nil || result.Results == nil {
return ""
}
return a.generator.GetScoreSummaryText(result.Results)
}

View file

@ -0,0 +1,507 @@
// 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"
"regexp"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// AuthenticationAnalyzer analyzes email authentication results
type AuthenticationAnalyzer struct{}
// NewAuthenticationAnalyzer creates a new authentication analyzer
func NewAuthenticationAnalyzer() *AuthenticationAnalyzer {
return &AuthenticationAnalyzer{}
}
// AnalyzeAuthentication extracts and analyzes authentication results from email headers
func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults {
results := &api.AuthenticationResults{}
// Parse Authentication-Results headers
authHeaders := email.GetAuthenticationResults()
for _, header := range authHeaders {
a.parseAuthenticationResultsHeader(header, results)
}
// If no Authentication-Results headers, try to parse legacy headers
if results.Spf == nil {
results.Spf = a.parseLegacySPF(email)
}
if results.Dkim == nil || len(*results.Dkim) == 0 {
dkimResults := a.parseLegacyDKIM(email)
if len(dkimResults) > 0 {
results.Dkim = &dkimResults
}
}
// 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
}
// parseAuthenticationResultsHeader parses an Authentication-Results header
// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com
func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) {
// Split by semicolon to get individual results
parts := strings.Split(header, ";")
if len(parts) < 2 {
return
}
// Skip the authserv-id (first part)
for i := 1; i < len(parts); i++ {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
// Parse SPF
if strings.HasPrefix(part, "spf=") {
if results.Spf == nil {
results.Spf = a.parseSPFResult(part)
}
}
// Parse DKIM
if strings.HasPrefix(part, "dkim=") {
dkimResult := a.parseDKIMResult(part)
if dkimResult != nil {
if results.Dkim == nil {
dkimList := []api.AuthResult{*dkimResult}
results.Dkim = &dkimList
} else {
*results.Dkim = append(*results.Dkim, *dkimResult)
}
}
}
// Parse DMARC
if strings.HasPrefix(part, "dmarc=") {
if results.Dmarc == nil {
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)
}
}
}
}
// parseSPFResult parses SPF result from Authentication-Results
// Example: spf=pass smtp.mailfrom=sender@example.com
func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`spf=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain
domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
email := matches[1]
// Extract domain from email
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
// 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
}
// parseDKIMResult parses DKIM result from Authentication-Results
// Example: dkim=pass header.d=example.com header.s=selector1
func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dkim=(\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.s or s)
selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\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
}
// parseDMARCResult parses DMARC result from Authentication-Results
// Example: dmarc=pass action=none header.from=example.com
func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult {
result := &api.AuthResult{}
// Extract result (pass, fail, etc.)
re := regexp.MustCompile(`dmarc=(\w+)`)
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
resultStr := strings.ToLower(matches[1])
result.Result = api.AuthResultResult(resultStr)
}
// Extract domain (header.from)
domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract details (action, policy, etc.)
var detailsParts []string
actionRe := regexp.MustCompile(`action=([^\s;]+)`)
if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 {
detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1]))
}
if len(detailsParts) > 0 {
details := strings.Join(detailsParts, " ")
result.Details = &details
}
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
func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult {
receivedSPF := email.Header.Get("Received-SPF")
if receivedSPF == "" {
return nil
}
result := &api.AuthResult{}
// Extract result (first word)
parts := strings.Fields(receivedSPF)
if len(parts) > 0 {
resultStr := strings.ToLower(parts[0])
result.Result = api.AuthResultResult(resultStr)
}
// Try to extract domain
domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 {
email := matches[1]
if idx := strings.Index(email, "@"); idx != -1 {
domain := email[idx+1:]
result.Domain = &domain
}
}
return result
}
// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header
func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult {
var results []api.AuthResult
// Get all DKIM-Signature headers
dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")]
for _, dkimHeader := range dkimHeaders {
result := api.AuthResult{
Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone
}
// Extract domain (d=)
domainRe := regexp.MustCompile(`d=([^\s;]+)`)
if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
domain := matches[1]
result.Domain = &domain
}
// Extract selector (s=)
selectorRe := regexp.MustCompile(`s=([^\s;]+)`)
if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 {
selector := matches[1]
result.Selector = &selector
}
details := "DKIM signature present (verification status unknown)"
result.Details = &details
results = append(results, result)
}
return results
}
// textprotoCanonical converts a header name to canonical form
func textprotoCanonical(s string) string {
// Simple implementation - capitalize each word
words := strings.Split(s, "-")
for i, word := range words {
if len(word) > 0 {
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
}
return strings.Join(words, "-")
}

View file

@ -0,0 +1,304 @@
// 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
}

View file

@ -0,0 +1,846 @@
// 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)
}
})
}
}

830
pkg/analyzer/content.go Normal file
View file

@ -0,0 +1,830 @@
// 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 (
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"unicode"
"git.happydns.org/happyDeliver/internal/api"
"golang.org/x/net/html"
)
// ContentAnalyzer analyzes email content (HTML, links, images)
type ContentAnalyzer struct {
Timeout time.Duration
httpClient *http.Client
}
// NewContentAnalyzer creates a new content analyzer with configurable timeout
func NewContentAnalyzer(timeout time.Duration) *ContentAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
return &ContentAnalyzer{
Timeout: timeout,
httpClient: &http.Client{
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Allow up to 10 redirects
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
// ContentResults represents content analysis results
type ContentResults struct {
HTMLValid bool
HTMLErrors []string
Links []LinkCheck
Images []ImageCheck
HasUnsubscribe bool
UnsubscribeLinks []string
TextContent string
HTMLContent string
TextPlainRatio float32 // Ratio of plain text to HTML consistency
ImageTextRatio float32 // Ratio of images to text
SuspiciousURLs []string
ContentIssues []string
}
// LinkCheck represents a link validation result
type LinkCheck struct {
URL string
Valid bool
Status int
Error string
IsSafe bool
Warning string
}
// ImageCheck represents an image validation result
type ImageCheck struct {
Src string
HasAlt bool
AltText string
Valid bool
Error string
IsBroken bool
}
// AnalyzeContent performs content analysis on email message
func (c *ContentAnalyzer) AnalyzeContent(email *EmailMessage) *ContentResults {
results := &ContentResults{}
// Get HTML and text parts
htmlParts := email.GetHTMLParts()
textParts := email.GetTextParts()
// Analyze HTML parts
if len(htmlParts) > 0 {
for _, part := range htmlParts {
c.analyzeHTML(part.Content, results)
}
}
// Analyze text parts
if len(textParts) > 0 {
for _, part := range textParts {
results.TextContent += part.Content
}
}
// Check plain text/HTML consistency
if len(htmlParts) > 0 && len(textParts) > 0 {
results.TextPlainRatio = c.calculateTextPlainConsistency(results.TextContent, results.HTMLContent)
}
return results
}
// analyzeHTML parses and analyzes HTML content
func (c *ContentAnalyzer) analyzeHTML(htmlContent string, results *ContentResults) {
results.HTMLContent = htmlContent
// Parse HTML
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
results.HTMLValid = false
results.HTMLErrors = append(results.HTMLErrors, fmt.Sprintf("Failed to parse HTML: %v", err))
return
}
results.HTMLValid = true
// Traverse HTML tree
c.traverseHTML(doc, results)
// Calculate image-to-text ratio
if results.HTMLContent != "" {
textLength := len(c.extractTextFromHTML(htmlContent))
imageCount := len(results.Images)
if textLength > 0 {
results.ImageTextRatio = float32(imageCount) / float32(textLength) * 1000 // Images per 1000 chars
}
}
}
// traverseHTML recursively traverses HTML nodes
func (c *ContentAnalyzer) traverseHTML(n *html.Node, results *ContentResults) {
if n.Type == html.ElementNode {
switch n.Data {
case "a":
// Extract and validate links
href := c.getAttr(n, "href")
if href != "" {
// Check for unsubscribe links
if c.isUnsubscribeLink(href, n) {
results.HasUnsubscribe = true
results.UnsubscribeLinks = append(results.UnsubscribeLinks, href)
}
// Validate link
linkCheck := c.validateLink(href)
results.Links = append(results.Links, linkCheck)
// Check for suspicious URLs
if !linkCheck.IsSafe {
results.SuspiciousURLs = append(results.SuspiciousURLs, href)
}
}
case "img":
// Extract and validate images
src := c.getAttr(n, "src")
alt := c.getAttr(n, "alt")
imageCheck := ImageCheck{
Src: src,
HasAlt: alt != "",
AltText: alt,
Valid: src != "",
}
if src == "" {
imageCheck.Error = "Image missing src attribute"
}
results.Images = append(results.Images, imageCheck)
}
}
// Traverse children
for child := n.FirstChild; child != nil; child = child.NextSibling {
c.traverseHTML(child, results)
}
}
// getAttr gets an attribute value from an HTML node
func (c *ContentAnalyzer) getAttr(n *html.Node, key string) string {
for _, attr := range n.Attr {
if attr.Key == key {
return attr.Val
}
}
return ""
}
// isUnsubscribeLink checks if a link is an unsubscribe link
func (c *ContentAnalyzer) isUnsubscribeLink(href string, node *html.Node) bool {
// Check href for unsubscribe keywords
lowerHref := strings.ToLower(href)
unsubKeywords := []string{"unsubscribe", "opt-out", "optout", "remove", "list-unsubscribe"}
for _, keyword := range unsubKeywords {
if strings.Contains(lowerHref, keyword) {
return true
}
}
// Check link text for unsubscribe keywords
text := c.getNodeText(node)
lowerText := strings.ToLower(text)
for _, keyword := range unsubKeywords {
if strings.Contains(lowerText, keyword) {
return true
}
}
return false
}
// getNodeText extracts text content from a node
func (c *ContentAnalyzer) getNodeText(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
var text string
for child := n.FirstChild; child != nil; child = child.NextSibling {
text += c.getNodeText(child)
}
return text
}
// validateLink validates a URL and checks if it's accessible
func (c *ContentAnalyzer) validateLink(urlStr string) LinkCheck {
check := LinkCheck{
URL: urlStr,
IsSafe: true,
}
// Parse URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
check.Valid = false
check.Error = fmt.Sprintf("Invalid URL: %v", err)
return check
}
// Check URL safety
if c.isSuspiciousURL(urlStr, parsedURL) {
check.IsSafe = false
check.Warning = "URL appears suspicious (obfuscated, shortened, or unusual)"
}
// Only check HTTP/HTTPS links
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
check.Valid = true
return check
}
// Check if link is accessible (with timeout)
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "HEAD", urlStr, nil)
if err != nil {
check.Valid = false
check.Error = fmt.Sprintf("Failed to create request: %v", err)
return check
}
// Set a reasonable user agent
req.Header.Set("User-Agent", "HappyDeliver/1.0 (Email Deliverability Tester)")
resp, err := c.httpClient.Do(req)
if err != nil {
// Don't fail on timeout/connection errors for external links
// Just mark as warning
check.Valid = true
check.Status = 0
check.Warning = fmt.Sprintf("Could not verify link: %v", err)
return check
}
defer resp.Body.Close()
check.Status = resp.StatusCode
check.Valid = true
// Check for error status codes
if resp.StatusCode >= 400 {
check.Error = fmt.Sprintf("Link returns %d status", resp.StatusCode)
}
return check
}
// isSuspiciousURL checks if a URL looks suspicious
func (c *ContentAnalyzer) isSuspiciousURL(urlStr string, parsedURL *url.URL) bool {
// Check for IP address instead of domain
if c.isIPAddress(parsedURL.Host) {
return true
}
// Check for URL shorteners (common ones)
shorteners := []string{
"bit.ly", "tinyurl.com", "goo.gl", "ow.ly", "t.co",
"buff.ly", "is.gd", "bl.ink", "short.io",
}
for _, shortener := range shorteners {
if strings.Contains(strings.ToLower(parsedURL.Host), shortener) {
return true
}
}
// Check for excessive subdomains (possible obfuscation)
parts := strings.Split(parsedURL.Host, ".")
if len(parts) > 4 {
return true
}
// Check for URL obfuscation techniques
if strings.Count(urlStr, "@") > 0 { // @ in URL (possible phishing)
return true
}
// Check for suspicious characters in domain
if strings.ContainsAny(parsedURL.Host, "[]()<>") {
return true
}
return false
}
// isIPAddress checks if a string is an IP address
func (c *ContentAnalyzer) isIPAddress(host string) bool {
// Remove port if present
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// Simple check for IPv4
parts := strings.Split(host, ".")
if len(parts) == 4 {
for _, part := range parts {
// Check if all characters are digits
for _, ch := range part {
if !unicode.IsDigit(ch) {
return false
}
}
}
return true
}
// Check for IPv6 (contains colons)
if strings.Contains(host, ":") {
return true
}
return false
}
// extractTextFromHTML extracts plain text from HTML
func (c *ContentAnalyzer) extractTextFromHTML(htmlContent string) string {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return ""
}
var text strings.Builder
var extract func(*html.Node)
extract = func(n *html.Node) {
if n.Type == html.TextNode {
text.WriteString(n.Data)
}
// Skip script and style tags
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
return
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
extract(child)
}
}
extract(doc)
return text.String()
}
// calculateTextPlainConsistency compares plain text and HTML versions
func (c *ContentAnalyzer) calculateTextPlainConsistency(plainText, htmlText string) float32 {
// Extract text from HTML
htmlPlainText := c.extractTextFromHTML(htmlText)
// Normalize both texts
plainNorm := c.normalizeText(plainText)
htmlNorm := c.normalizeText(htmlPlainText)
// Calculate similarity using simple word overlap
plainWords := strings.Fields(plainNorm)
htmlWords := strings.Fields(htmlNorm)
if len(plainWords) == 0 || len(htmlWords) == 0 {
return 0.0
}
// Count common words
commonWords := 0
plainWordSet := make(map[string]bool)
for _, word := range plainWords {
plainWordSet[word] = true
}
for _, word := range htmlWords {
if plainWordSet[word] {
commonWords++
}
}
// Calculate ratio (Jaccard similarity approximation)
maxWords := len(plainWords)
if len(htmlWords) > maxWords {
maxWords = len(htmlWords)
}
if maxWords == 0 {
return 0.0
}
return float32(commonWords) / float32(maxWords)
}
// normalizeText normalizes text for comparison
func (c *ContentAnalyzer) normalizeText(text string) string {
// Convert to lowercase
text = strings.ToLower(text)
// Remove extra whitespace
text = strings.TrimSpace(text)
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return text
}
// GenerateContentChecks generates check results for content analysis
func (c *ContentAnalyzer) GenerateContentChecks(results *ContentResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// HTML validity check
checks = append(checks, c.generateHTMLValidityCheck(results))
// Link checks
checks = append(checks, c.generateLinkChecks(results)...)
// Image checks
checks = append(checks, c.generateImageChecks(results)...)
// Unsubscribe link check
checks = append(checks, c.generateUnsubscribeCheck(results))
// Text/HTML consistency check
if results.TextContent != "" && results.HTMLContent != "" {
checks = append(checks, c.generateTextConsistencyCheck(results))
}
// Image-to-text ratio check
if len(results.Images) > 0 && results.HTMLContent != "" {
checks = append(checks, c.generateImageRatioCheck(results))
}
// Suspicious URLs check
if len(results.SuspiciousURLs) > 0 {
checks = append(checks, c.generateSuspiciousURLCheck(results))
}
return checks
}
// generateHTMLValidityCheck creates a check for HTML validity
func (c *ContentAnalyzer) generateHTMLValidityCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "HTML Structure",
}
if !results.HTMLValid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "HTML structure is invalid"
if len(results.HTMLErrors) > 0 {
details := strings.Join(results.HTMLErrors, "; ")
check.Details = &details
}
check.Advice = api.PtrTo("Fix HTML structure errors to improve email rendering")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.2
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "HTML structure is valid"
check.Advice = api.PtrTo("Your HTML is well-formed")
}
return check
}
// generateLinkChecks creates checks for links
func (c *ContentAnalyzer) generateLinkChecks(results *ContentResults) []api.Check {
var checks []api.Check
if len(results.Links) == 0 {
return checks
}
// Count broken links
brokenLinks := 0
warningLinks := 0
for _, link := range results.Links {
if link.Status >= 400 {
brokenLinks++
} else if link.Warning != "" {
warningLinks++
}
}
check := api.Check{
Category: api.Content,
Name: "Links",
}
if brokenLinks > 0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Message = fmt.Sprintf("Found %d broken link(s)", brokenLinks)
check.Advice = api.PtrTo("Fix or remove broken links to improve deliverability")
details := fmt.Sprintf("Total links: %d, Broken: %d", len(results.Links), brokenLinks)
check.Details = &details
} else if warningLinks > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.3
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("Found %d link(s) that could not be verified", warningLinks)
check.Advice = api.PtrTo("Review links that could not be verified")
details := fmt.Sprintf("Total links: %d, Unverified: %d", len(results.Links), warningLinks)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.4
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("All %d link(s) are valid", len(results.Links))
check.Advice = api.PtrTo("Your links are working properly")
}
checks = append(checks, check)
return checks
}
// generateImageChecks creates checks for images
func (c *ContentAnalyzer) generateImageChecks(results *ContentResults) []api.Check {
var checks []api.Check
if len(results.Images) == 0 {
return checks
}
// Count images without alt text
noAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
noAltCount++
}
}
check := api.Check{
Category: api.Content,
Name: "Image Alt Attributes",
}
if noAltCount == len(results.Images) {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "No images have alt attributes"
check.Advice = api.PtrTo("Add alt text to all images for accessibility and deliverability")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
check.Details = &details
} else if noAltCount > 0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = fmt.Sprintf("%d image(s) missing alt attributes", noAltCount)
check.Advice = api.PtrTo("Add alt text to all images for better accessibility")
details := fmt.Sprintf("Images without alt: %d/%d", noAltCount, len(results.Images))
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "All images have alt attributes"
check.Advice = api.PtrTo("Your images are properly tagged for accessibility")
}
checks = append(checks, check)
return checks
}
// generateUnsubscribeCheck creates a check for unsubscribe links
func (c *ContentAnalyzer) generateUnsubscribeCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Unsubscribe Link",
}
if !results.HasUnsubscribe {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "No unsubscribe link found"
check.Advice = api.PtrTo("Add an unsubscribe link for marketing emails (RFC 8058)")
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d unsubscribe link(s)", len(results.UnsubscribeLinks))
check.Advice = api.PtrTo("Your email includes an unsubscribe option")
}
return check
}
// generateTextConsistencyCheck creates a check for text/HTML consistency
func (c *ContentAnalyzer) generateTextConsistencyCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Plain Text Consistency",
}
consistency := results.TextPlainRatio
if consistency < 0.3 {
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Plain text and HTML versions differ significantly"
check.Advice = api.PtrTo("Ensure plain text and HTML versions convey the same content")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Plain text and HTML versions are consistent"
check.Advice = api.PtrTo("Your multipart email is well-structured")
details := fmt.Sprintf("Consistency: %.0f%%", consistency*100)
check.Details = &details
}
return check
}
// generateImageRatioCheck creates a check for image-to-text ratio
func (c *ContentAnalyzer) generateImageRatioCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Image-to-Text Ratio",
}
ratio := results.ImageTextRatio
// Flag if more than 1 image per 100 characters (very image-heavy)
if ratio > 10.0 {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = "Email is excessively image-heavy"
check.Advice = api.PtrTo("Reduce the number of images relative to text content")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
} else if ratio > 5.0 {
check.Status = api.CheckStatusWarn
check.Score = 0.2
check.Severity = api.PtrTo(api.CheckSeverityLow)
check.Message = "Email has high image-to-text ratio"
check.Advice = api.PtrTo("Consider adding more text content relative to images")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 0.3
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = "Image-to-text ratio is reasonable"
check.Advice = api.PtrTo("Your content has a good balance of images and text")
details := fmt.Sprintf("Images: %d, Ratio: %.2f images per 1000 chars", len(results.Images), ratio)
check.Details = &details
}
return check
}
// generateSuspiciousURLCheck creates a check for suspicious URLs
func (c *ContentAnalyzer) generateSuspiciousURLCheck(results *ContentResults) api.Check {
check := api.Check{
Category: api.Content,
Name: "Suspicious URLs",
}
count := len(results.SuspiciousURLs)
check.Status = api.CheckStatusWarn
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Message = fmt.Sprintf("Found %d suspicious URL(s)", count)
check.Advice = api.PtrTo("Avoid URL shorteners, IP addresses, and obfuscated URLs in emails")
if count <= 3 {
details := strings.Join(results.SuspiciousURLs, ", ")
check.Details = &details
} else {
details := fmt.Sprintf("%s, and %d more", strings.Join(results.SuspiciousURLs[:3], ", "), count-3)
check.Details = &details
}
return check
}
// GetContentScore calculates the content score (0-2 points)
func (c *ContentAnalyzer) GetContentScore(results *ContentResults) float32 {
if results == nil {
return 0.0
}
var score float32 = 0.0
// HTML validity (0.2 points)
if results.HTMLValid {
score += 0.2
}
// Links (0.4 points)
if len(results.Links) > 0 {
brokenLinks := 0
for _, link := range results.Links {
if link.Status >= 400 {
brokenLinks++
}
}
if brokenLinks == 0 {
score += 0.4
}
} else {
// No links is neutral, give partial score
score += 0.2
}
// Images (0.3 points)
if len(results.Images) > 0 {
noAltCount := 0
for _, img := range results.Images {
if !img.HasAlt {
noAltCount++
}
}
if noAltCount == 0 {
score += 0.3
} else if noAltCount < len(results.Images) {
score += 0.15
}
} else {
// No images is neutral
score += 0.15
}
// Unsubscribe link (0.3 points)
if results.HasUnsubscribe {
score += 0.3
}
// Text consistency (0.3 points)
if results.TextPlainRatio >= 0.3 {
score += 0.3
}
// Image ratio (0.3 points)
if results.ImageTextRatio <= 5.0 {
score += 0.3
} else if results.ImageTextRatio <= 10.0 {
score += 0.15
}
// Penalize suspicious URLs (deduct up to 0.5 points)
if len(results.SuspiciousURLs) > 0 {
penalty := float32(len(results.SuspiciousURLs)) * 0.1
if penalty > 0.5 {
penalty = 0.5
}
score -= penalty
}
// Ensure score is between 0 and 2
if score < 0 {
score = 0
}
if score > 2.0 {
score = 2.0
}
return score
}

1078
pkg/analyzer/content_test.go Normal file

File diff suppressed because it is too large Load diff

719
pkg/analyzer/dns.go Normal file
View file

@ -0,0 +1,719 @@
// 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 (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// DNSAnalyzer analyzes DNS records for email domains
type DNSAnalyzer struct {
Timeout time.Duration
resolver *net.Resolver
}
// NewDNSAnalyzer creates a new DNS analyzer with configurable timeout
func NewDNSAnalyzer(timeout time.Duration) *DNSAnalyzer {
if timeout == 0 {
timeout = 10 * time.Second // Default timeout
}
return &DNSAnalyzer{
Timeout: timeout,
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// DNSResults represents DNS validation results for an email
type DNSResults struct {
Domain string
MXRecords []MXRecord
SPFRecord *SPFRecord
DKIMRecords []DKIMRecord
DMARCRecord *DMARCRecord
BIMIRecord *BIMIRecord
Errors []string
}
// MXRecord represents an MX record
type MXRecord struct {
Host string
Priority uint16
Valid bool
Error string
}
// SPFRecord represents an SPF record
type SPFRecord struct {
Record string
Valid bool
Error string
}
// DKIMRecord represents a DKIM record
type DKIMRecord struct {
Selector string
Domain string
Record string
Valid bool
Error string
}
// DMARCRecord represents a DMARC record
type DMARCRecord struct {
Record string
Policy string // none, quarantine, reject
Valid bool
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
func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults) *DNSResults {
// Extract domain from From address
domain := d.extractDomain(email)
if domain == "" {
return &DNSResults{
Errors: []string{"Unable to extract domain from email"},
}
}
results := &DNSResults{
Domain: domain,
}
// Check MX records
results.MXRecords = d.checkMXRecords(domain)
// Check SPF record
results.SPFRecord = d.checkSPFRecord(domain)
// Check DKIM records (from authentication results)
if authResults != nil && authResults.Dkim != nil {
for _, dkim := range *authResults.Dkim {
if dkim.Domain != nil && dkim.Selector != nil {
dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector)
if dkimRecord != nil {
results.DKIMRecords = append(results.DKIMRecords, *dkimRecord)
}
}
}
}
// Check DMARC record
results.DMARCRecord = d.checkDMARCRecord(domain)
// Check BIMI record (using default selector)
results.BIMIRecord = d.checkBIMIRecord(domain, "default")
return results
}
// extractDomain extracts the domain from the email's From address
func (d *DNSAnalyzer) extractDomain(email *EmailMessage) string {
if email.From != nil && email.From.Address != "" {
parts := strings.Split(email.From.Address, "@")
if len(parts) == 2 {
return strings.ToLower(strings.TrimSpace(parts[1]))
}
}
return ""
}
// checkMXRecords looks up MX records for a domain
func (d *DNSAnalyzer) checkMXRecords(domain string) []MXRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
mxRecords, err := d.resolver.LookupMX(ctx, domain)
if err != nil {
return []MXRecord{
{
Valid: false,
Error: fmt.Sprintf("Failed to lookup MX records: %v", err),
},
}
}
if len(mxRecords) == 0 {
return []MXRecord{
{
Valid: false,
Error: "No MX records found",
},
}
}
var results []MXRecord
for _, mx := range mxRecords {
results = append(results, MXRecord{
Host: mx.Host,
Priority: mx.Pref,
Valid: true,
})
}
return results
}
// checkSPFRecord looks up and validates SPF record for a domain
func (d *DNSAnalyzer) checkSPFRecord(domain string) *SPFRecord {
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, domain)
if err != nil {
return &SPFRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup TXT records: %v", err),
}
}
// Find SPF record (starts with "v=spf1")
var spfRecord string
spfCount := 0
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=spf1") {
spfRecord = txt
spfCount++
}
}
if spfCount == 0 {
return &SPFRecord{
Valid: false,
Error: "No SPF record found",
}
}
if spfCount > 1 {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "Multiple SPF records found (RFC violation)",
}
}
// Basic validation
if !d.validateSPF(spfRecord) {
return &SPFRecord{
Record: spfRecord,
Valid: false,
Error: "SPF record appears malformed",
}
}
return &SPFRecord{
Record: spfRecord,
Valid: true,
}
}
// validateSPF performs basic SPF record validation
func (d *DNSAnalyzer) validateSPF(record string) bool {
// Must start with v=spf1
if !strings.HasPrefix(record, "v=spf1") {
return false
}
// Check for common syntax issues
// Should have a final mechanism (all, +all, -all, ~all, ?all)
validEndings := []string{" all", " +all", " -all", " ~all", " ?all"}
hasValidEnding := false
for _, ending := range validEndings {
if strings.HasSuffix(record, ending) {
hasValidEnding = true
break
}
}
return hasValidEnding
}
// checkDKIMRecord looks up and validates DKIM record for a domain and selector
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *DKIMRecord {
// DKIM records are at: selector._domainkey.domain
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: fmt.Sprintf("Failed to lookup DKIM record: %v", err),
}
}
if len(txtRecords) == 0 {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Valid: false,
Error: "No DKIM record found",
}
}
// Concatenate all TXT record parts (DKIM can be split)
dkimRecord := strings.Join(txtRecords, "")
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) {
return &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: false,
Error: "DKIM record appears malformed",
}
}
return &DKIMRecord{
Selector: selector,
Domain: domain,
Record: dkimRecord,
Valid: true,
}
}
// validateDKIM performs basic DKIM record validation
func (d *DNSAnalyzer) validateDKIM(record string) bool {
// Should contain p= tag (public key)
if !strings.Contains(record, "p=") {
return false
}
// Often contains v=DKIM1 but not required
// If v= is present, it should be DKIM1
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
return false
}
return true
}
// checkDMARCRecord looks up and validates DMARC record for a domain
func (d *DNSAnalyzer) checkDMARCRecord(domain string) *DMARCRecord {
// DMARC records are at: _dmarc.domain
dmarcDomain := fmt.Sprintf("_dmarc.%s", domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel()
txtRecords, err := d.resolver.LookupTXT(ctx, dmarcDomain)
if err != nil {
return &DMARCRecord{
Valid: false,
Error: fmt.Sprintf("Failed to lookup DMARC record: %v", err),
}
}
// Find DMARC record (starts with "v=DMARC1")
var dmarcRecord string
for _, txt := range txtRecords {
if strings.HasPrefix(txt, "v=DMARC1") {
dmarcRecord = txt
break
}
}
if dmarcRecord == "" {
return &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
}
}
// Extract policy
policy := d.extractDMARCPolicy(dmarcRecord)
// Basic validation
if !d.validateDMARC(dmarcRecord) {
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: false,
Error: "DMARC record appears malformed",
}
}
return &DMARCRecord{
Record: dmarcRecord,
Policy: policy,
Valid: true,
}
}
// extractDMARCPolicy extracts the policy from a DMARC record
func (d *DNSAnalyzer) extractDMARCPolicy(record string) string {
// Look for p=none, p=quarantine, or p=reject
re := regexp.MustCompile(`p=(none|quarantine|reject)`)
matches := re.FindStringSubmatch(record)
if len(matches) > 1 {
return matches[1]
}
return "unknown"
}
// validateDMARC performs basic DMARC record validation
func (d *DNSAnalyzer) validateDMARC(record string) bool {
// Must start with v=DMARC1
if !strings.HasPrefix(record, "v=DMARC1") {
return false
}
// Must have a policy tag
if !strings.Contains(record, "p=") {
return false
}
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
func (d *DNSAnalyzer) GenerateDNSChecks(results *DNSResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// MX record check
checks = append(checks, d.generateMXCheck(results))
// SPF record check
if results.SPFRecord != nil {
checks = append(checks, d.generateSPFCheck(results.SPFRecord))
}
// DKIM record checks
for _, dkim := range results.DKIMRecords {
checks = append(checks, d.generateDKIMCheck(&dkim))
}
// DMARC record check
if results.DMARCRecord != nil {
checks = append(checks, d.generateDMARCCheck(results.DMARCRecord))
}
// BIMI record check (optional)
if results.BIMIRecord != nil {
checks = append(checks, d.generateBIMICheck(results.BIMIRecord))
}
return checks
}
// generateMXCheck creates a check for MX records
func (d *DNSAnalyzer) generateMXCheck(results *DNSResults) api.Check {
check := api.Check{
Category: api.Dns,
Name: "MX Records",
}
if len(results.MXRecords) == 0 || !results.MXRecords[0].Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Severity = api.PtrTo(api.CheckSeverityCritical)
if len(results.MXRecords) > 0 && results.MXRecords[0].Error != "" {
check.Message = results.MXRecords[0].Error
} else {
check.Message = "No valid MX records found"
}
check.Advice = api.PtrTo("Configure MX records for your domain to receive email")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Found %d valid MX record(s)", len(results.MXRecords))
// Add details about MX records
var mxList []string
for _, mx := range results.MXRecords {
mxList = append(mxList, fmt.Sprintf("%s (priority %d)", mx.Host, mx.Priority))
}
details := strings.Join(mxList, ", ")
check.Details = &details
check.Advice = api.PtrTo("Your MX records are properly configured")
}
return check
}
// generateSPFCheck creates a check for SPF records
func (d *DNSAnalyzer) generateSPFCheck(spf *SPFRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "SPF Record",
}
if !spf.Valid {
// If no record exists at all, it's a failure
if spf.Record == "" {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = spf.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure an SPF record for your domain to improve deliverability")
} else {
// If record exists but is invalid, it's a warning
check.Status = api.CheckStatusWarn
check.Score = 0.5
check.Message = "SPF record found but appears invalid"
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Review and fix your SPF record syntax")
check.Details = &spf.Record
}
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid SPF record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &spf.Record
check.Advice = api.PtrTo("Your SPF record is properly configured")
}
return check
}
// generateDKIMCheck creates a check for DKIM records
func (d *DNSAnalyzer) generateDKIMCheck(dkim *DKIMRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: fmt.Sprintf("DKIM Record (%s)", dkim.Selector),
}
if !dkim.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = fmt.Sprintf("DKIM record not found or invalid: %s", dkim.Error)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Ensure DKIM record is published in DNS for the selector used")
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = "Valid DKIM record found"
check.Severity = api.PtrTo(api.CheckSeverityInfo)
details := fmt.Sprintf("Selector: %s, Domain: %s", dkim.Selector, dkim.Domain)
check.Details = &details
check.Advice = api.PtrTo("Your DKIM record is properly published")
}
return check
}
// generateDMARCCheck creates a check for DMARC records
func (d *DNSAnalyzer) generateDMARCCheck(dmarc *DMARCRecord) api.Check {
check := api.Check{
Category: api.Dns,
Name: "DMARC Record",
}
if !dmarc.Valid {
check.Status = api.CheckStatusFail
check.Score = 0.0
check.Message = dmarc.Error
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Configure a DMARC record for your domain to improve deliverability and prevent spoofing")
} else {
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Message = fmt.Sprintf("Valid DMARC record found with policy: %s", dmarc.Policy)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Details = &dmarc.Record
// Provide advice based on policy
switch dmarc.Policy {
case "none":
advice := "DMARC policy is set to 'none' (monitoring only). Consider upgrading to 'quarantine' or 'reject' for better protection"
check.Advice = &advice
case "quarantine":
advice := "DMARC policy is set to 'quarantine'. This provides good protection"
check.Advice = &advice
case "reject":
advice := "DMARC policy is set to 'reject'. This provides the strongest protection"
check.Advice = &advice
default:
advice := "Your DMARC record is properly configured"
check.Advice = &advice
}
}
return check
}
// generateBIMICheck creates a check for BIMI records
func (d *DNSAnalyzer) generateBIMICheck(bimi *BIMIRecord) 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
}

820
pkg/analyzer/dns_test.go Normal file
View file

@ -0,0 +1,820 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDNSAnalyzer(t *testing.T) {
tests := []struct {
name string
timeout time.Duration
expectedTimeout time.Duration
}{
{
name: "Default timeout",
timeout: 0,
expectedTimeout: 10 * time.Second,
},
{
name: "Custom timeout",
timeout: 5 * time.Second,
expectedTimeout: 5 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzer := NewDNSAnalyzer(tt.timeout)
if analyzer.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", analyzer.Timeout, tt.expectedTimeout)
}
if analyzer.resolver == nil {
t.Error("Resolver should not be nil")
}
})
}
}
func TestExtractDomain(t *testing.T) {
tests := []struct {
name string
fromAddress string
expectedDomain string
}{
{
name: "Valid email",
fromAddress: "user@example.com",
expectedDomain: "example.com",
},
{
name: "Email with subdomain",
fromAddress: "user@mail.example.com",
expectedDomain: "mail.example.com",
},
{
name: "Email with uppercase",
fromAddress: "User@Example.COM",
expectedDomain: "example.com",
},
{
name: "Invalid email (no @)",
fromAddress: "invalid-email",
expectedDomain: "",
},
{
name: "Empty email",
fromAddress: "",
expectedDomain: "",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
}
if tt.fromAddress != "" {
email.From = &mail.Address{
Address: tt.fromAddress,
}
}
domain := analyzer.extractDomain(email)
if domain != tt.expectedDomain {
t.Errorf("extractDomain() = %q, want %q", domain, tt.expectedDomain)
}
})
}
}
func TestValidateSPF(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid SPF with -all",
record: "v=spf1 include:_spf.example.com -all",
expected: true,
},
{
name: "Valid SPF with ~all",
record: "v=spf1 ip4:192.0.2.0/24 ~all",
expected: true,
},
{
name: "Valid SPF with +all",
record: "v=spf1 +all",
expected: true,
},
{
name: "Valid SPF with ?all",
record: "v=spf1 mx ?all",
expected: true,
},
{
name: "Invalid SPF - no version",
record: "include:_spf.example.com -all",
expected: false,
},
{
name: "Invalid SPF - no all mechanism",
record: "v=spf1 include:_spf.example.com",
expected: false,
},
{
name: "Invalid SPF - wrong version",
record: "v=spf2 include:_spf.example.com -all",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateSPF(tt.record)
if result != tt.expected {
t.Errorf("validateSPF(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestValidateDKIM(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DKIM with version",
record: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Valid DKIM without version",
record: "k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: true,
},
{
name: "Invalid DKIM - no public key",
record: "v=DKIM1; k=rsa",
expected: false,
},
{
name: "Invalid DKIM - wrong version",
record: "v=DKIM2; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...",
expected: false,
},
{
name: "Invalid DKIM - empty",
record: "",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDKIM(tt.record)
if result != tt.expected {
t.Errorf("validateDKIM(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestExtractDMARCPolicy(t *testing.T) {
tests := []struct {
name string
record string
expectedPolicy string
}{
{
name: "Policy none",
record: "v=DMARC1; p=none; rua=mailto:dmarc@example.com",
expectedPolicy: "none",
},
{
name: "Policy quarantine",
record: "v=DMARC1; p=quarantine; pct=100",
expectedPolicy: "quarantine",
},
{
name: "Policy reject",
record: "v=DMARC1; p=reject; sp=reject",
expectedPolicy: "reject",
},
{
name: "No policy",
record: "v=DMARC1",
expectedPolicy: "unknown",
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDMARCPolicy(tt.record)
if result != tt.expectedPolicy {
t.Errorf("extractDMARCPolicy(%q) = %q, want %q", tt.record, result, tt.expectedPolicy)
}
})
}
}
func TestValidateDMARC(t *testing.T) {
tests := []struct {
name string
record string
expected bool
}{
{
name: "Valid DMARC",
record: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
expected: true,
},
{
name: "Valid DMARC minimal",
record: "v=DMARC1; p=none",
expected: true,
},
{
name: "Invalid DMARC - no version",
record: "p=quarantine",
expected: false,
},
{
name: "Invalid DMARC - no policy",
record: "v=DMARC1",
expected: false,
},
{
name: "Invalid DMARC - wrong version",
record: "v=DMARC2; p=reject",
expected: false,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.validateDMARC(tt.record)
if result != tt.expected {
t.Errorf("validateDMARC(%q) = %v, want %v", tt.record, result, tt.expected)
}
})
}
}
func TestGenerateMXCheck(t *testing.T) {
tests := []struct {
name string
results *DNSResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
{Host: "mail2.example.com", Priority: 20, Valid: true},
},
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "No MX records",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "No MX records found"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "MX lookup failed",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Valid: false, Error: "DNS lookup failed"},
},
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateMXCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateSPFCheck(t *testing.T) {
tests := []struct {
name string
spf *SPFRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid SPF",
spf: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Invalid SPF",
spf: &SPFRecord{
Record: "v=spf1 invalid syntax",
Valid: false,
Error: "SPF record appears malformed",
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
{
name: "No SPF record",
spf: &SPFRecord{
Valid: false,
Error: "No SPF record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateSPFCheck(tt.spf)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
})
}
}
func TestGenerateDKIMCheck(t *testing.T) {
tests := []struct {
name string
dkim *DKIMRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Record: "v=DKIM1; k=rsa; p=MIGfMA0...",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Invalid DKIM",
dkim: &DKIMRecord{
Selector: "default",
Domain: "example.com",
Valid: false,
Error: "No DKIM record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDKIMCheck(tt.dkim)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
if !strings.Contains(check.Name, tt.dkim.Selector) {
t.Errorf("Check name should contain selector %s", tt.dkim.Selector)
}
})
}
}
func TestGenerateDMARCCheck(t *testing.T) {
tests := []struct {
name string
dmarc *DMARCRecord
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Valid DMARC - reject",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=reject",
Policy: "reject",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Valid DMARC - quarantine",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "Valid DMARC - none",
dmarc: &DMARCRecord{
Record: "v=DMARC1; p=none",
Policy: "none",
Valid: true,
},
expectedStatus: api.CheckStatusPass,
expectedScore: 1.0,
},
{
name: "No DMARC record",
dmarc: &DMARCRecord{
Valid: false,
Error: "No DMARC record found",
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateDMARCCheck(tt.dmarc)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Dns {
t.Errorf("Category = %v, want %v", check.Category, api.Dns)
}
// Check that advice mentions policy for valid DMARC
if tt.dmarc.Valid && check.Advice != nil {
if tt.dmarc.Policy == "none" && !strings.Contains(*check.Advice, "none") {
t.Error("Advice should mention 'none' policy")
}
}
})
}
}
func TestGenerateDNSChecks(t *testing.T) {
tests := []struct {
name string
results *DNSResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "Complete results",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
SPFRecord: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
DKIMRecords: []DKIMRecord{
{
Selector: "default",
Domain: "example.com",
Valid: true,
},
},
DMARCRecord: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Policy: "quarantine",
Valid: true,
},
},
minChecks: 4, // MX, SPF, DKIM, DMARC
},
{
name: "Partial results",
results: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
},
minChecks: 1, // Only MX
},
}
analyzer := NewDNSAnalyzer(5 * time.Second)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateDNSChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the DNS category
for _, check := range checks {
if check.Category != api.Dns {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Dns)
}
}
})
}
}
func TestAnalyzeDNS_NoDomain(t *testing.T) {
analyzer := NewDNSAnalyzer(5 * time.Second)
email := &EmailMessage{
Header: make(mail.Header),
// No From address
}
results := analyzer.AnalyzeDNS(email, nil)
if results == nil {
t.Fatal("Expected results, got nil")
}
if len(results.Errors) == 0 {
t.Error("Expected error when no domain can be extracted")
}
}
func TestExtractBIMITag(t *testing.T) {
tests := []struct {
name string
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")
}
}
})
}
}

277
pkg/analyzer/parser.go Normal file
View file

@ -0,0 +1,277 @@
// 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"
"io"
"mime"
"mime/multipart"
"net/mail"
"net/textproto"
"strings"
)
// EmailMessage represents a parsed email message
type EmailMessage struct {
Header mail.Header
From *mail.Address
To []*mail.Address
Subject string
MessageID string
Date string
ReturnPath string
Parts []MessagePart
RawHeaders string
RawBody string
}
// MessagePart represents a MIME part of an email
type MessagePart struct {
ContentType string
Encoding string
Content string
IsHTML bool
IsText bool
Boundary string
Parts []MessagePart // For nested multipart messages
}
// ParseEmail parses an email message from a reader
func ParseEmail(r io.Reader) (*EmailMessage, error) {
msg, err := mail.ReadMessage(r)
if err != nil {
return nil, fmt.Errorf("failed to read email message: %w", err)
}
email := &EmailMessage{
Header: msg.Header,
Subject: msg.Header.Get("Subject"),
MessageID: msg.Header.Get("Message-ID"),
Date: msg.Header.Get("Date"),
ReturnPath: msg.Header.Get("Return-Path"),
}
// Parse From address
if fromStr := msg.Header.Get("From"); fromStr != "" {
from, err := mail.ParseAddress(fromStr)
if err == nil {
email.From = from
}
}
// Parse To addresses
if toStr := msg.Header.Get("To"); toStr != "" {
toAddrs, err := mail.ParseAddressList(toStr)
if err == nil {
email.To = toAddrs
}
}
// Build raw headers string
email.RawHeaders = buildRawHeaders(msg.Header)
// Parse MIME parts
contentType := msg.Header.Get("Content-Type")
if contentType == "" {
// Plain text email without MIME
body, err := io.ReadAll(msg.Body)
if err != nil {
return nil, fmt.Errorf("failed to read email body: %w", err)
}
email.RawBody = string(body)
email.Parts = []MessagePart{
{
ContentType: "text/plain",
Content: string(body),
IsText: true,
},
}
} else {
// Parse MIME message
parts, err := parseMIMEParts(msg.Body, contentType)
if err != nil {
return nil, fmt.Errorf("failed to parse MIME parts: %w", err)
}
email.Parts = parts
}
return email, nil
}
// parseMIMEParts recursively parses MIME parts
func parseMIMEParts(body io.Reader, contentType string) ([]MessagePart, error) {
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, fmt.Errorf("failed to parse media type: %w", err)
}
var parts []MessagePart
if strings.HasPrefix(mediaType, "multipart/") {
// Handle multipart messages
boundary := params["boundary"]
if boundary == "" {
return nil, fmt.Errorf("multipart message missing boundary")
}
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read multipart part: %w", err)
}
partContentType := part.Header.Get("Content-Type")
if partContentType == "" {
partContentType = "text/plain"
}
// Check if this part is also multipart
partMediaType, _, _ := mime.ParseMediaType(partContentType)
if strings.HasPrefix(partMediaType, "multipart/") {
// Recursively parse nested multipart
nestedParts, err := parseMIMEParts(part, partContentType)
if err != nil {
return nil, err
}
parts = append(parts, MessagePart{
ContentType: partContentType,
Encoding: part.Header.Get("Content-Transfer-Encoding"),
Parts: nestedParts,
})
} else {
// Read the part content
content, err := io.ReadAll(part)
if err != nil {
return nil, fmt.Errorf("failed to read part content: %w", err)
}
messagePart := MessagePart{
ContentType: partContentType,
Encoding: part.Header.Get("Content-Transfer-Encoding"),
Content: string(content),
IsHTML: strings.Contains(strings.ToLower(partMediaType), "html"),
IsText: strings.Contains(strings.ToLower(partMediaType), "text"),
}
parts = append(parts, messagePart)
}
}
} else {
// Single part message
content, err := io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("failed to read body content: %w", err)
}
parts = []MessagePart{
{
ContentType: contentType,
Content: string(content),
IsHTML: strings.Contains(strings.ToLower(mediaType), "html"),
IsText: strings.Contains(strings.ToLower(mediaType), "text"),
},
}
}
return parts, nil
}
// buildRawHeaders reconstructs the raw header string
func buildRawHeaders(header mail.Header) string {
var sb strings.Builder
for key, values := range header {
for _, value := range values {
sb.WriteString(fmt.Sprintf("%s: %s\n", key, value))
}
}
return sb.String()
}
// GetAuthenticationResults extracts Authentication-Results headers
func (e *EmailMessage) GetAuthenticationResults() []string {
return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")]
}
// GetSpamAssassinHeaders extracts SpamAssassin-related headers
func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string {
headers := make(map[string]string)
// Common SpamAssassin headers
saHeaders := []string{
"X-Spam-Status",
"X-Spam-Score",
"X-Spam-Flag",
"X-Spam-Level",
"X-Spam-Report",
"X-Spam-Checker-Version",
}
for _, headerName := range saHeaders {
if value := e.Header.Get(headerName); value != "" {
headers[headerName] = value
}
}
return headers
}
// GetTextParts returns all text/plain parts
func (e *EmailMessage) GetTextParts() []MessagePart {
return filterParts(e.Parts, func(p MessagePart) bool {
return p.IsText && !p.IsHTML
})
}
// GetHTMLParts returns all text/html parts
func (e *EmailMessage) GetHTMLParts() []MessagePart {
return filterParts(e.Parts, func(p MessagePart) bool {
return p.IsHTML
})
}
// filterParts recursively filters message parts
func filterParts(parts []MessagePart, predicate func(MessagePart) bool) []MessagePart {
var result []MessagePart
for _, part := range parts {
if len(part.Parts) > 0 {
// Recursively filter nested parts
result = append(result, filterParts(part.Parts, predicate)...)
} else if predicate(part) {
result = append(result, part)
}
}
return result
}
// GetHeaderValue safely gets a header value
func (e *EmailMessage) GetHeaderValue(key string) string {
return e.Header.Get(key)
}
// HasHeader checks if a header exists
func (e *EmailMessage) HasHeader(key string) bool {
return e.Header.Get(key) != ""
}

176
pkg/analyzer/parser_test.go Normal file
View file

@ -0,0 +1,176 @@
// 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"
)
func TestParseEmail_SimplePlainText(t *testing.T) {
rawEmail := `From: sender@example.com
To: recipient@example.com
Subject: Test Email
Message-ID: <test123@example.com>
Date: Mon, 15 Oct 2025 12:00:00 +0000
This is a plain text email body.
`
email, err := ParseEmail(strings.NewReader(rawEmail))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
if email.From.Address != "sender@example.com" {
t.Errorf("Expected From: sender@example.com, got: %s", email.From.Address)
}
if email.Subject != "Test Email" {
t.Errorf("Expected Subject: Test Email, got: %s", email.Subject)
}
if len(email.Parts) != 1 {
t.Fatalf("Expected 1 part, got: %d", len(email.Parts))
}
if !email.Parts[0].IsText {
t.Error("Expected part to be text")
}
if !strings.Contains(email.Parts[0].Content, "plain text email body") {
t.Error("Expected body content not found")
}
}
func TestParseEmail_MultipartAlternative(t *testing.T) {
rawEmail := `From: sender@example.com
To: recipient@example.com
Subject: Test Multipart Email
Content-Type: multipart/alternative; boundary="boundary123"
--boundary123
Content-Type: text/plain; charset=utf-8
This is the plain text version.
--boundary123
Content-Type: text/html; charset=utf-8
<html><body><p>This is the HTML version.</p></body></html>
--boundary123--
`
email, err := ParseEmail(strings.NewReader(rawEmail))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
if len(email.Parts) != 2 {
t.Fatalf("Expected 2 parts, got: %d", len(email.Parts))
}
textParts := email.GetTextParts()
if len(textParts) != 1 {
t.Errorf("Expected 1 text part, got: %d", len(textParts))
}
htmlParts := email.GetHTMLParts()
if len(htmlParts) != 1 {
t.Errorf("Expected 1 HTML part, got: %d", len(htmlParts))
}
if !strings.Contains(htmlParts[0].Content, "<html>") {
t.Error("Expected HTML content not found")
}
}
func TestGetAuthenticationResults(t *testing.T) {
rawEmail := `From: sender@example.com
To: recipient@example.com
Subject: Test Email
Authentication-Results: example.com; spf=pass smtp.mailfrom=sender@example.com
Authentication-Results: example.com; dkim=pass header.d=example.com
Body content.
`
email, err := ParseEmail(strings.NewReader(rawEmail))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
authResults := email.GetAuthenticationResults()
if len(authResults) != 2 {
t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults))
}
}
func TestGetSpamAssassinHeaders(t *testing.T) {
rawEmail := `From: sender@example.com
To: recipient@example.com
Subject: Test Email
X-Spam-Status: No, score=2.3 required=5.0
X-Spam-Score: 2.3
X-Spam-Flag: NO
Body content.
`
email, err := ParseEmail(strings.NewReader(rawEmail))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
saHeaders := email.GetSpamAssassinHeaders()
if len(saHeaders) != 3 {
t.Errorf("Expected 3 SpamAssassin headers, got: %d", len(saHeaders))
}
if saHeaders["X-Spam-Score"] != "2.3" {
t.Errorf("Expected X-Spam-Score: 2.3, got: %s", saHeaders["X-Spam-Score"])
}
}
func TestHasHeader(t *testing.T) {
rawEmail := `From: sender@example.com
To: recipient@example.com
Subject: Test Email
Message-ID: <test123@example.com>
Body content.
`
email, err := ParseEmail(strings.NewReader(rawEmail))
if err != nil {
t.Fatalf("Failed to parse email: %v", err)
}
if !email.HasHeader("Message-ID") {
t.Error("Expected Message-ID header to exist")
}
if email.HasHeader("List-Unsubscribe") {
t.Error("Expected List-Unsubscribe header to not exist")
}
}

408
pkg/analyzer/rbl.go Normal file
View file

@ -0,0 +1,408 @@
// 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 (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
// RBLChecker checks IP addresses against DNS-based blacklists
type RBLChecker struct {
Timeout time.Duration
RBLs []string
resolver *net.Resolver
}
// DefaultRBLs is a list of commonly used RBL providers
var DefaultRBLs = []string{
"zen.spamhaus.org", // Spamhaus combined list
"bl.spamcop.net", // SpamCop
"dnsbl.sorbs.net", // SORBS
"b.barracudacentral.org", // Barracuda
"cbl.abuseat.org", // CBL (Composite Blocking List)
"dnsbl-1.uceprotect.net", // UCEPROTECT Level 1
}
// NewRBLChecker creates a new RBL checker with configurable timeout and RBL list
func NewRBLChecker(timeout time.Duration, rbls []string) *RBLChecker {
if timeout == 0 {
timeout = 5 * time.Second // Default timeout
}
if len(rbls) == 0 {
rbls = DefaultRBLs
}
return &RBLChecker{
Timeout: timeout,
RBLs: rbls,
resolver: &net.Resolver{
PreferGo: true,
},
}
}
// RBLResults represents the results of RBL checks
type RBLResults struct {
Checks []RBLCheck
IPsChecked []string
ListedCount int
}
// RBLCheck represents a single RBL check result
type RBLCheck struct {
IP string
RBL string
Listed bool
Response string
Error string
}
// CheckEmail checks all IPs found in the email headers against RBLs
func (r *RBLChecker) CheckEmail(email *EmailMessage) *RBLResults {
results := &RBLResults{}
// Extract IPs from Received headers
ips := r.extractIPs(email)
if len(ips) == 0 {
return results
}
results.IPsChecked = ips
// Check each IP against all RBLs
for _, ip := range ips {
for _, rbl := range r.RBLs {
check := r.checkIP(ip, rbl)
results.Checks = append(results.Checks, check)
if check.Listed {
results.ListedCount++
}
}
}
return results
}
// extractIPs extracts IP addresses from Received headers
func (r *RBLChecker) extractIPs(email *EmailMessage) []string {
var ips []string
seenIPs := make(map[string]bool)
// Get all Received headers
receivedHeaders := email.Header["Received"]
// Regex patterns for IP addresses
// Match IPv4: xxx.xxx.xxx.xxx
ipv4Pattern := regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`)
// Look for IPs in Received headers
for _, received := range receivedHeaders {
// Find all IPv4 addresses
matches := ipv4Pattern.FindAllString(received, -1)
for _, match := range matches {
// Skip private/reserved IPs
if !r.isPublicIP(match) {
continue
}
// Avoid duplicates
if !seenIPs[match] {
ips = append(ips, match)
seenIPs[match] = true
}
}
}
// If no IPs found in Received headers, try X-Originating-IP
if len(ips) == 0 {
originatingIP := email.Header.Get("X-Originating-IP")
if originatingIP != "" {
// Extract IP from formats like "[192.0.2.1]" or "192.0.2.1"
cleanIP := strings.TrimSuffix(strings.TrimPrefix(originatingIP, "["), "]")
// Remove any whitespace
cleanIP = strings.TrimSpace(cleanIP)
matches := ipv4Pattern.FindString(cleanIP)
if matches != "" && r.isPublicIP(matches) {
ips = append(ips, matches)
}
}
}
return ips
}
// isPublicIP checks if an IP address is public (not private, loopback, or reserved)
func (r *RBLChecker) isPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// Check if it's a private network
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
// Additional checks for reserved ranges
// 0.0.0.0/8, 192.0.0.0/24, 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2), 203.0.113.0/24 (TEST-NET-3)
if ip.IsUnspecified() {
return false
}
return true
}
// checkIP checks a single IP against a single RBL
func (r *RBLChecker) checkIP(ip, rbl string) RBLCheck {
check := RBLCheck{
IP: ip,
RBL: rbl,
}
// Reverse the IP for DNSBL query
reversedIP := r.reverseIP(ip)
if reversedIP == "" {
check.Error = "Failed to reverse IP address"
return check
}
// Construct DNSBL query: reversed-ip.rbl-domain
query := fmt.Sprintf("%s.%s", reversedIP, rbl)
// Perform DNS lookup with timeout
ctx, cancel := context.WithTimeout(context.Background(), r.Timeout)
defer cancel()
addrs, err := r.resolver.LookupHost(ctx, query)
if err != nil {
// Most likely not listed (NXDOMAIN)
if dnsErr, ok := err.(*net.DNSError); ok {
if dnsErr.IsNotFound {
check.Listed = false
return check
}
}
// Other DNS errors
check.Error = fmt.Sprintf("DNS lookup failed: %v", err)
return check
}
// If we got a response, the IP is listed
if len(addrs) > 0 {
check.Listed = true
check.Response = addrs[0] // Return code (e.g., 127.0.0.2)
}
return check
}
// reverseIP reverses an IPv4 address for DNSBL queries
// Example: 192.0.2.1 -> 1.2.0.192
func (r *RBLChecker) reverseIP(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ""
}
// Convert to IPv4
ipv4 := ip.To4()
if ipv4 == nil {
return "" // IPv6 not supported yet
}
// Reverse the octets
return fmt.Sprintf("%d.%d.%d.%d", ipv4[3], ipv4[2], ipv4[1], ipv4[0])
}
// GetBlacklistScore calculates the blacklist contribution to deliverability (0-2 points)
// Scoring:
// - Not listed on any RBL: 2 points (excellent)
// - Listed on 1 RBL: 1 point (warning)
// - Listed on 2-3 RBLs: 0.5 points (poor)
// - Listed on 4+ RBLs: 0 points (critical)
func (r *RBLChecker) GetBlacklistScore(results *RBLResults) float32 {
if results == nil || len(results.IPsChecked) == 0 {
// No IPs to check, give benefit of doubt
return 2.0
}
listedCount := results.ListedCount
if listedCount == 0 {
return 2.0
} else if listedCount == 1 {
return 1.0
} else if listedCount <= 3 {
return 0.5
}
return 0.0
}
// GenerateRBLChecks generates check results for RBL analysis
func (r *RBLChecker) GenerateRBLChecks(results *RBLResults) []api.Check {
var checks []api.Check
if results == nil {
return checks
}
// If no IPs were checked, add a warning
if len(results.IPsChecked) == 0 {
checks = append(checks, api.Check{
Category: api.Blacklist,
Name: "RBL Check",
Status: api.CheckStatusWarn,
Score: 1.0,
Message: "No public IP addresses found to check",
Severity: api.PtrTo(api.CheckSeverityLow),
Advice: api.PtrTo("Unable to extract sender IP from email headers"),
})
return checks
}
// Create a summary check
summaryCheck := r.generateSummaryCheck(results)
checks = append(checks, summaryCheck)
// Create individual checks for each listing
for _, check := range results.Checks {
if check.Listed {
detailCheck := r.generateListingCheck(&check)
checks = append(checks, detailCheck)
}
}
return checks
}
// generateSummaryCheck creates an overall RBL summary check
func (r *RBLChecker) generateSummaryCheck(results *RBLResults) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: "RBL Summary",
}
score := r.GetBlacklistScore(results)
check.Score = score
totalChecks := len(results.Checks)
listedCount := results.ListedCount
if listedCount == 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Not listed on any blacklists (%d RBLs checked)", len(r.RBLs))
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your sending IP has a good reputation")
} else if listedCount == 1 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on 1 blacklist (out of %d checked)", totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("You're listed on one blacklist. Review the specific listing and request delisting if appropriate")
} else if listedCount <= 3 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Multiple blacklist listings detected. This will significantly impact deliverability. Review each listing and take corrective action")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Listed on %d blacklists (out of %d checked)", listedCount, totalChecks)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Your IP is listed on multiple blacklists. This will severely impact email deliverability. Investigate the cause and request delisting from each RBL")
}
// Add details about IPs checked
if len(results.IPsChecked) > 0 {
details := fmt.Sprintf("IPs checked: %s", strings.Join(results.IPsChecked, ", "))
check.Details = &details
}
return check
}
// generateListingCheck creates a check for a specific RBL listing
func (r *RBLChecker) generateListingCheck(rblCheck *RBLCheck) api.Check {
check := api.Check{
Category: api.Blacklist,
Name: fmt.Sprintf("RBL: %s", rblCheck.RBL),
Status: api.CheckStatusFail,
Score: 0.0,
}
check.Message = fmt.Sprintf("IP %s is listed on %s", rblCheck.IP, rblCheck.RBL)
// Determine severity based on which RBL
if strings.Contains(rblCheck.RBL, "spamhaus") {
check.Severity = api.PtrTo(api.CheckSeverityCritical)
advice := fmt.Sprintf("Listed on Spamhaus, a widely-used blocklist. Visit https://check.spamhaus.org/ to check details and request delisting")
check.Advice = &advice
} else if strings.Contains(rblCheck.RBL, "spamcop") {
check.Severity = api.PtrTo(api.CheckSeverityHigh)
advice := fmt.Sprintf("Listed on SpamCop. Visit http://www.spamcop.net/bl.shtml to request delisting")
check.Advice = &advice
} else {
check.Severity = api.PtrTo(api.CheckSeverityHigh)
advice := fmt.Sprintf("Listed on %s. Contact the RBL operator for delisting procedures", rblCheck.RBL)
check.Advice = &advice
}
// Add response code details
if rblCheck.Response != "" {
details := fmt.Sprintf("Response: %s", rblCheck.Response)
check.Details = &details
}
return check
}
// GetUniqueListedIPs returns a list of unique IPs that are listed on at least one RBL
func (r *RBLChecker) GetUniqueListedIPs(results *RBLResults) []string {
seenIPs := make(map[string]bool)
var listedIPs []string
for _, check := range results.Checks {
if check.Listed && !seenIPs[check.IP] {
listedIPs = append(listedIPs, check.IP)
seenIPs[check.IP] = true
}
}
return listedIPs
}
// GetRBLsForIP returns all RBLs that list a specific IP
func (r *RBLChecker) GetRBLsForIP(results *RBLResults, ip string) []string {
var rbls []string
for _, check := range results.Checks {
if check.IP == ip && check.Listed {
rbls = append(rbls, check.RBL)
}
}
return rbls
}

629
pkg/analyzer/rbl_test.go Normal file
View file

@ -0,0 +1,629 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewRBLChecker(t *testing.T) {
tests := []struct {
name string
timeout time.Duration
rbls []string
expectedTimeout time.Duration
expectedRBLs int
}{
{
name: "Default timeout and RBLs",
timeout: 0,
rbls: nil,
expectedTimeout: 5 * time.Second,
expectedRBLs: len(DefaultRBLs),
},
{
name: "Custom timeout and RBLs",
timeout: 10 * time.Second,
rbls: []string{"test.rbl.org"},
expectedTimeout: 10 * time.Second,
expectedRBLs: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checker := NewRBLChecker(tt.timeout, tt.rbls)
if checker.Timeout != tt.expectedTimeout {
t.Errorf("Timeout = %v, want %v", checker.Timeout, tt.expectedTimeout)
}
if len(checker.RBLs) != tt.expectedRBLs {
t.Errorf("RBLs count = %d, want %d", len(checker.RBLs), tt.expectedRBLs)
}
if checker.resolver == nil {
t.Error("Resolver should not be nil")
}
})
}
}
func TestReverseIP(t *testing.T) {
tests := []struct {
name string
ip string
expected string
}{
{
name: "Valid IPv4",
ip: "192.0.2.1",
expected: "1.2.0.192",
},
{
name: "Another valid IPv4",
ip: "198.51.100.42",
expected: "42.100.51.198",
},
{
name: "Invalid IP",
ip: "not-an-ip",
expected: "",
},
{
name: "Empty string",
ip: "",
expected: "",
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := checker.reverseIP(tt.ip)
if result != tt.expected {
t.Errorf("reverseIP(%q) = %q, want %q", tt.ip, result, tt.expected)
}
})
}
}
func TestIsPublicIP(t *testing.T) {
tests := []struct {
name string
ip string
expected bool
}{
{
name: "Public IP",
ip: "8.8.8.8",
expected: true,
},
{
name: "Private IP - 192.168.x.x",
ip: "192.168.1.1",
expected: false,
},
{
name: "Private IP - 10.x.x.x",
ip: "10.0.0.1",
expected: false,
},
{
name: "Private IP - 172.16.x.x",
ip: "172.16.0.1",
expected: false,
},
{
name: "Loopback",
ip: "127.0.0.1",
expected: false,
},
{
name: "Link-local",
ip: "169.254.1.1",
expected: false,
},
{
name: "Unspecified",
ip: "0.0.0.0",
expected: false,
},
{
name: "Invalid IP",
ip: "not-an-ip",
expected: false,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := checker.isPublicIP(tt.ip)
if result != tt.expected {
t.Errorf("isPublicIP(%q) = %v, want %v", tt.ip, result, tt.expected)
}
})
}
}
func TestExtractIPs(t *testing.T) {
tests := []struct {
name string
headers map[string][]string
expectedIPs []string
}{
{
name: "Single Received header with public IP",
headers: map[string][]string{
"Received": {
"from mail.example.com (mail.example.com [198.51.100.1]) by mx.test.com",
},
},
expectedIPs: []string{"198.51.100.1"},
},
{
name: "Multiple Received headers",
headers: map[string][]string{
"Received": {
"from mail.example.com (mail.example.com [198.51.100.1]) by mx.test.com",
"from relay.test.com (relay.test.com [203.0.113.5]) by mail.test.com",
},
},
expectedIPs: []string{"198.51.100.1", "203.0.113.5"},
},
{
name: "Received header with private IP (filtered out)",
headers: map[string][]string{
"Received": {
"from internal.example.com (internal.example.com [192.168.1.10]) by mx.test.com",
},
},
expectedIPs: nil,
},
{
name: "Mixed public and private IPs",
headers: map[string][]string{
"Received": {
"from mail.example.com [198.51.100.1] (helo=mail.example.com) by mx.test.com",
"from internal.local [192.168.1.5] by mail.example.com",
},
},
expectedIPs: []string{"198.51.100.1"},
},
{
name: "X-Originating-IP fallback",
headers: map[string][]string{
"X-Originating-Ip": {"[8.8.8.8]"},
},
expectedIPs: []string{"8.8.8.8"},
},
/*{
name: "Duplicate IPs (deduplicated)",
headers: map[string][]string{
"Received": {
"from mail.example.com [198.51.100.1] by mx1.test.com",
"from mail.example.com [198.51.100.1] by mx2.test.com",
},
},
expectedIPs: []string{"198.51.100.1"},
},
{
name: "No IPs in headers",
headers: map[string][]string{},
expectedIPs: nil,
},*/
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: mail.Header(tt.headers),
}
ips := checker.extractIPs(email)
if len(ips) != len(tt.expectedIPs) {
t.Errorf("extractIPs() returned %d IPs, want %d", len(ips), len(tt.expectedIPs))
t.Errorf("Got: %v, Want: %v", ips, tt.expectedIPs)
return
}
for i, ip := range ips {
if ip != tt.expectedIPs[i] {
t.Errorf("IP at index %d = %q, want %q", i, ip, tt.expectedIPs[i])
}
}
})
}
}
func TestGetBlacklistScore(t *testing.T) {
tests := []struct {
name string
results *RBLResults
expectedScore float32
}{
{
name: "Nil results",
results: nil,
expectedScore: 2.0,
},
{
name: "No IPs checked",
results: &RBLResults{
IPsChecked: []string{},
},
expectedScore: 2.0,
},
{
name: "Not listed on any RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
},
expectedScore: 2.0,
},
{
name: "Listed on 1 RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
},
expectedScore: 1.0,
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
},
expectedScore: 0.5,
},
{
name: "Listed on 3 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 3,
},
expectedScore: 0.5,
},
{
name: "Listed on 4+ RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
},
expectedScore: 0.0,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := checker.GetBlacklistScore(tt.results)
if score != tt.expectedScore {
t.Errorf("GetBlacklistScore() = %v, want %v", score, tt.expectedScore)
}
})
}
}
func TestGenerateSummaryCheck(t *testing.T) {
tests := []struct {
name string
results *RBLResults
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "Not listed",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: make([]RBLCheck, 6), // 6 default RBLs
},
expectedStatus: api.CheckStatusPass,
expectedScore: 2.0,
},
{
name: "Listed on 1 RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 1,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 1.0,
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusWarn,
expectedScore: 0.5,
},
{
name: "Listed on 4+ RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 4,
Checks: make([]RBLCheck, 6),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateSummaryCheck(tt.results)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
})
}
}
func TestGenerateListingCheck(t *testing.T) {
tests := []struct {
name string
rblCheck *RBLCheck
expectedStatus api.CheckStatus
expectedSeverity api.CheckSeverity
}{
{
name: "Spamhaus listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "zen.spamhaus.org",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityCritical,
},
{
name: "SpamCop listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "bl.spamcop.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityHigh,
},
{
name: "Other RBL listing",
rblCheck: &RBLCheck{
IP: "198.51.100.1",
RBL: "dnsbl.sorbs.net",
Listed: true,
Response: "127.0.0.2",
},
expectedStatus: api.CheckStatusFail,
expectedSeverity: api.CheckSeverityHigh,
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := checker.generateListingCheck(tt.rblCheck)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Severity == nil || *check.Severity != tt.expectedSeverity {
t.Errorf("Severity = %v, want %v", check.Severity, tt.expectedSeverity)
}
if check.Category != api.Blacklist {
t.Errorf("Category = %v, want %v", check.Category, api.Blacklist)
}
if !strings.Contains(check.Name, tt.rblCheck.RBL) {
t.Errorf("Check name should contain RBL name %s", tt.rblCheck.RBL)
}
})
}
}
func TestGenerateRBLChecks(t *testing.T) {
tests := []struct {
name string
results *RBLResults
minChecks int
}{
{
name: "Nil results",
results: nil,
minChecks: 0,
},
{
name: "No IPs checked",
results: &RBLResults{
IPsChecked: []string{},
},
minChecks: 1, // Warning check
},
{
name: "Not listed on any RBL",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 0,
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: false},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: false},
},
},
minChecks: 1, // Summary check only
},
{
name: "Listed on 2 RBLs",
results: &RBLResults{
IPsChecked: []string{"198.51.100.1"},
ListedCount: 2,
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
},
},
minChecks: 3, // Summary + 2 listing checks
},
}
checker := NewRBLChecker(5*time.Second, nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := checker.GenerateRBLChecks(tt.results)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Blacklist category
for _, check := range checks {
if check.Category != api.Blacklist {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Blacklist)
}
}
})
}
}
func TestGetUniqueListedIPs(t *testing.T) {
results := &RBLResults{
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.2", RBL: "bl.spamcop.net", Listed: false},
{IP: "198.51.100.3", RBL: "zen.spamhaus.org", Listed: false},
},
}
checker := NewRBLChecker(5*time.Second, nil)
listedIPs := checker.GetUniqueListedIPs(results)
expectedIPs := []string{"198.51.100.1", "198.51.100.2"}
if len(listedIPs) != len(expectedIPs) {
t.Errorf("Got %d unique listed IPs, want %d", len(listedIPs), len(expectedIPs))
t.Errorf("Got: %v, Want: %v", listedIPs, expectedIPs)
}
}
func TestGetRBLsForIP(t *testing.T) {
results := &RBLResults{
Checks: []RBLCheck{
{IP: "198.51.100.1", RBL: "zen.spamhaus.org", Listed: true},
{IP: "198.51.100.1", RBL: "bl.spamcop.net", Listed: true},
{IP: "198.51.100.1", RBL: "dnsbl.sorbs.net", Listed: false},
{IP: "198.51.100.2", RBL: "zen.spamhaus.org", Listed: true},
},
}
checker := NewRBLChecker(5*time.Second, nil)
tests := []struct {
name string
ip string
expectedRBLs []string
}{
{
name: "IP listed on 2 RBLs",
ip: "198.51.100.1",
expectedRBLs: []string{"zen.spamhaus.org", "bl.spamcop.net"},
},
{
name: "IP listed on 1 RBL",
ip: "198.51.100.2",
expectedRBLs: []string{"zen.spamhaus.org"},
},
{
name: "IP not found",
ip: "198.51.100.3",
expectedRBLs: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rbls := checker.GetRBLsForIP(results, tt.ip)
if len(rbls) != len(tt.expectedRBLs) {
t.Errorf("Got %d RBLs, want %d", len(rbls), len(tt.expectedRBLs))
t.Errorf("Got: %v, Want: %v", rbls, tt.expectedRBLs)
return
}
for i, rbl := range rbls {
if rbl != tt.expectedRBLs[i] {
t.Errorf("RBL at index %d = %q, want %q", i, rbl, tt.expectedRBLs[i])
}
}
})
}
}
func TestDefaultRBLs(t *testing.T) {
if len(DefaultRBLs) == 0 {
t.Error("DefaultRBLs should not be empty")
}
// Verify some well-known RBLs are present
expectedRBLs := []string{"zen.spamhaus.org", "bl.spamcop.net"}
for _, expected := range expectedRBLs {
found := false
for _, rbl := range DefaultRBLs {
if rbl == expected {
found = true
break
}
}
if !found {
t.Errorf("DefaultRBLs should contain %s", expected)
}
}
}

348
pkg/analyzer/report.go Normal file
View file

@ -0,0 +1,348 @@
// 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 (
"time"
"git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
// ReportGenerator generates comprehensive deliverability reports
type ReportGenerator struct {
authAnalyzer *AuthenticationAnalyzer
spamAnalyzer *SpamAssassinAnalyzer
dnsAnalyzer *DNSAnalyzer
rblChecker *RBLChecker
contentAnalyzer *ContentAnalyzer
scorer *DeliverabilityScorer
}
// NewReportGenerator creates a new report generator
func NewReportGenerator(
dnsTimeout time.Duration,
httpTimeout time.Duration,
rbls []string,
) *ReportGenerator {
return &ReportGenerator{
authAnalyzer: NewAuthenticationAnalyzer(),
spamAnalyzer: NewSpamAssassinAnalyzer(),
dnsAnalyzer: NewDNSAnalyzer(dnsTimeout),
rblChecker: NewRBLChecker(dnsTimeout, rbls),
contentAnalyzer: NewContentAnalyzer(httpTimeout),
scorer: NewDeliverabilityScorer(),
}
}
// AnalysisResults contains all intermediate analysis results
type AnalysisResults struct {
Email *EmailMessage
Authentication *api.AuthenticationResults
SpamAssassin *SpamAssassinResult
DNS *DNSResults
RBL *RBLResults
Content *ContentResults
Score *ScoringResult
}
// AnalyzeEmail performs complete email analysis
func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
results := &AnalysisResults{
Email: email,
}
// Run all analyzers
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication)
results.RBL = r.rblChecker.CheckEmail(email)
results.Content = r.contentAnalyzer.AnalyzeContent(email)
// Calculate overall score
results.Score = r.scorer.CalculateScore(
results.Authentication,
results.SpamAssassin,
results.RBL,
results.Content,
email,
)
return results
}
// GenerateReport creates a complete API report from analysis results
func (r *ReportGenerator) GenerateReport(testID uuid.UUID, results *AnalysisResults) *api.Report {
reportID := uuid.New()
now := time.Now()
report := &api.Report{
Id: reportID,
TestId: testID,
Score: results.Score.OverallScore,
CreatedAt: now,
}
// Build score summary
report.Summary = &api.ScoreSummary{
AuthenticationScore: results.Score.AuthScore,
SpamScore: results.Score.SpamScore,
BlacklistScore: results.Score.BlacklistScore,
ContentScore: results.Score.ContentScore,
HeaderScore: results.Score.HeaderScore,
}
// Collect all checks from different analyzers
checks := []api.Check{}
// Authentication checks
if results.Authentication != nil {
authChecks := r.authAnalyzer.GenerateAuthenticationChecks(results.Authentication)
checks = append(checks, authChecks...)
}
// DNS checks
if results.DNS != nil {
dnsChecks := r.dnsAnalyzer.GenerateDNSChecks(results.DNS)
checks = append(checks, dnsChecks...)
}
// RBL checks
if results.RBL != nil {
rblChecks := r.rblChecker.GenerateRBLChecks(results.RBL)
checks = append(checks, rblChecks...)
}
// SpamAssassin checks
if results.SpamAssassin != nil {
spamChecks := r.spamAnalyzer.GenerateSpamAssassinChecks(results.SpamAssassin)
checks = append(checks, spamChecks...)
}
// Content checks
if results.Content != nil {
contentChecks := r.contentAnalyzer.GenerateContentChecks(results.Content)
checks = append(checks, contentChecks...)
}
// Header checks
headerChecks := r.scorer.GenerateHeaderChecks(results.Email)
checks = append(checks, headerChecks...)
report.Checks = checks
// Add authentication results
report.Authentication = results.Authentication
// Add SpamAssassin result
if results.SpamAssassin != nil {
report.Spamassassin = &api.SpamAssassinResult{
Score: float32(results.SpamAssassin.Score),
RequiredScore: float32(results.SpamAssassin.RequiredScore),
IsSpam: results.SpamAssassin.IsSpam,
}
if len(results.SpamAssassin.Tests) > 0 {
report.Spamassassin.Tests = &results.SpamAssassin.Tests
}
if results.SpamAssassin.RawReport != "" {
report.Spamassassin.Report = &results.SpamAssassin.RawReport
}
}
// Add DNS records
if results.DNS != nil {
dnsRecords := r.buildDNSRecords(results.DNS)
if len(dnsRecords) > 0 {
report.DnsRecords = &dnsRecords
}
}
// Add blacklist checks
if results.RBL != nil && len(results.RBL.Checks) > 0 {
blacklistChecks := make([]api.BlacklistCheck, 0, len(results.RBL.Checks))
for _, check := range results.RBL.Checks {
blCheck := api.BlacklistCheck{
Ip: check.IP,
Rbl: check.RBL,
Listed: check.Listed,
}
if check.Response != "" {
blCheck.Response = &check.Response
}
blacklistChecks = append(blacklistChecks, blCheck)
}
report.Blacklists = &blacklistChecks
}
// Add raw headers
if results.Email != nil && results.Email.RawHeaders != "" {
report.RawHeaders = &results.Email.RawHeaders
}
return report
}
// buildDNSRecords converts DNS analysis results to API DNS records
func (r *ReportGenerator) buildDNSRecords(dns *DNSResults) []api.DNSRecord {
records := []api.DNSRecord{}
if dns == nil {
return records
}
// MX records
if len(dns.MXRecords) > 0 {
for _, mx := range dns.MXRecords {
status := api.Found
if !mx.Valid {
if mx.Error != "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.MX,
Status: status,
}
if mx.Host != "" {
value := mx.Host
record.Value = &value
}
records = append(records, record)
}
}
// SPF record
if dns.SPFRecord != nil {
status := api.Found
if !dns.SPFRecord.Valid {
if dns.SPFRecord.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.SPF,
Status: status,
}
if dns.SPFRecord.Record != "" {
record.Value = &dns.SPFRecord.Record
}
records = append(records, record)
}
// DKIM records
for _, dkim := range dns.DKIMRecords {
status := api.Found
if !dkim.Valid {
if dkim.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dkim.Domain,
RecordType: api.DKIM,
Status: status,
}
if dkim.Record != "" {
// Include selector in value for clarity
value := dkim.Record
record.Value = &value
}
records = append(records, record)
}
// DMARC record
if dns.DMARCRecord != nil {
status := api.Found
if !dns.DMARCRecord.Valid {
if dns.DMARCRecord.Record == "" {
status = api.Missing
} else {
status = api.Invalid
}
}
record := api.DNSRecord{
Domain: dns.Domain,
RecordType: api.DMARC,
Status: status,
}
if dns.DMARCRecord.Record != "" {
record.Value = &dns.DMARCRecord.Record
}
records = append(records, record)
}
return records
}
// GenerateRawEmail returns the raw email message as a string
func (r *ReportGenerator) GenerateRawEmail(email *EmailMessage) string {
if email == nil {
return ""
}
raw := email.RawHeaders
if email.RawBody != "" {
raw += "\n" + email.RawBody
}
return raw
}
// GetRecommendations returns actionable recommendations based on the score
func (r *ReportGenerator) GetRecommendations(results *AnalysisResults) []string {
if results == nil || results.Score == nil {
return []string{}
}
return results.Score.Recommendations
}
// GetScoreSummaryText returns a human-readable score summary
func (r *ReportGenerator) GetScoreSummaryText(results *AnalysisResults) string {
if results == nil || results.Score == nil {
return ""
}
return r.scorer.GetScoreSummary(results.Score)
}

501
pkg/analyzer/report_test.go Normal file
View file

@ -0,0 +1,501 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"time"
"git.happydns.org/happyDeliver/internal/api"
"github.com/google/uuid"
)
func TestNewReportGenerator(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
if gen == nil {
t.Fatal("Expected report generator, got nil")
}
if gen.authAnalyzer == nil {
t.Error("authAnalyzer should not be nil")
}
if gen.spamAnalyzer == nil {
t.Error("spamAnalyzer should not be nil")
}
if gen.dnsAnalyzer == nil {
t.Error("dnsAnalyzer should not be nil")
}
if gen.rblChecker == nil {
t.Error("rblChecker should not be nil")
}
if gen.contentAnalyzer == nil {
t.Error("contentAnalyzer should not be nil")
}
if gen.scorer == nil {
t.Error("scorer should not be nil")
}
}
func TestAnalyzeEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
email := createTestEmail()
results := gen.AnalyzeEmail(email)
if results == nil {
t.Fatal("Expected analysis results, got nil")
}
if results.Email == nil {
t.Error("Email should not be nil")
}
if results.Authentication == nil {
t.Error("Authentication should not be nil")
}
// SpamAssassin might be nil if headers don't exist
// DNS results should exist
// RBL results should exist
// Content results should exist
if results.Score == nil {
t.Error("Score should not be nil")
}
// Verify score is within bounds
if results.Score.OverallScore < 0 || results.Score.OverallScore > 10 {
t.Errorf("Overall score %v is out of bounds", results.Score.OverallScore)
}
}
func TestGenerateReport(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createTestEmail()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
if report == nil {
t.Fatal("Expected report, got nil")
}
// Verify required fields
if report.Id == uuid.Nil {
t.Error("Report ID should not be empty")
}
if report.TestId != testID {
t.Errorf("TestId = %s, want %s", report.TestId, testID)
}
if report.Score < 0 || report.Score > 10 {
t.Errorf("Score %v is out of bounds", report.Score)
}
if report.Summary == nil {
t.Error("Summary should not be nil")
}
if len(report.Checks) == 0 {
t.Error("Checks should not be empty")
}
// Verify score summary
if report.Summary != nil {
if report.Summary.AuthenticationScore < 0 || report.Summary.AuthenticationScore > 3 {
t.Errorf("AuthenticationScore %v is out of bounds", report.Summary.AuthenticationScore)
}
if report.Summary.SpamScore < 0 || report.Summary.SpamScore > 2 {
t.Errorf("SpamScore %v is out of bounds", report.Summary.SpamScore)
}
if report.Summary.BlacklistScore < 0 || report.Summary.BlacklistScore > 2 {
t.Errorf("BlacklistScore %v is out of bounds", report.Summary.BlacklistScore)
}
if report.Summary.ContentScore < 0 || report.Summary.ContentScore > 2 {
t.Errorf("ContentScore %v is out of bounds", report.Summary.ContentScore)
}
if report.Summary.HeaderScore < 0 || report.Summary.HeaderScore > 1 {
t.Errorf("HeaderScore %v is out of bounds", report.Summary.HeaderScore)
}
}
// Verify checks have required fields
for i, check := range report.Checks {
if string(check.Category) == "" {
t.Errorf("Check %d: Category should not be empty", i)
}
if check.Name == "" {
t.Errorf("Check %d: Name should not be empty", i)
}
if string(check.Status) == "" {
t.Errorf("Check %d: Status should not be empty", i)
}
if check.Message == "" {
t.Errorf("Check %d: Message should not be empty", i)
}
}
}
func TestGenerateReportWithSpamAssassin(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createTestEmailWithSpamAssassin()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
if report.Spamassassin == nil {
t.Error("SpamAssassin result should not be nil")
}
if report.Spamassassin != nil {
if report.Spamassassin.Score == 0 && report.Spamassassin.RequiredScore == 0 {
t.Error("SpamAssassin scores should be set")
}
}
}
func TestBuildDNSRecords(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
dns *DNSResults
expectedCount int
expectTypes []api.DNSRecordRecordType
}{
{
name: "Nil DNS results",
dns: nil,
expectedCount: 0,
},
{
name: "Complete DNS results",
dns: &DNSResults{
Domain: "example.com",
MXRecords: []MXRecord{
{Host: "mail.example.com", Priority: 10, Valid: true},
},
SPFRecord: &SPFRecord{
Record: "v=spf1 include:_spf.example.com -all",
Valid: true,
},
DKIMRecords: []DKIMRecord{
{
Selector: "default",
Domain: "example.com",
Record: "v=DKIM1; k=rsa; p=...",
Valid: true,
},
},
DMARCRecord: &DMARCRecord{
Record: "v=DMARC1; p=quarantine",
Valid: true,
},
},
expectedCount: 4, // MX, SPF, DKIM, DMARC
expectTypes: []api.DNSRecordRecordType{api.MX, api.SPF, api.DKIM, api.DMARC},
},
{
name: "Missing records",
dns: &DNSResults{
Domain: "example.com",
SPFRecord: &SPFRecord{
Valid: false,
Error: "No SPF record found",
},
},
expectedCount: 1,
expectTypes: []api.DNSRecordRecordType{api.SPF},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
records := gen.buildDNSRecords(tt.dns)
if len(records) != tt.expectedCount {
t.Errorf("Got %d DNS records, want %d", len(records), tt.expectedCount)
}
// Verify expected types are present
if tt.expectTypes != nil {
foundTypes := make(map[api.DNSRecordRecordType]bool)
for _, record := range records {
foundTypes[record.RecordType] = true
}
for _, expectedType := range tt.expectTypes {
if !foundTypes[expectedType] {
t.Errorf("Expected DNS record type %s not found", expectedType)
}
}
}
// Verify all records have required fields
for i, record := range records {
if record.Domain == "" {
t.Errorf("Record %d: Domain should not be empty", i)
}
if string(record.RecordType) == "" {
t.Errorf("Record %d: RecordType should not be empty", i)
}
if string(record.Status) == "" {
t.Errorf("Record %d: Status should not be empty", i)
}
}
})
}
}
func TestGenerateRawEmail(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
email *EmailMessage
expected string
}{
{
name: "Nil email",
email: nil,
expected: "",
},
{
name: "Email with headers only",
email: &EmailMessage{
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\n",
RawBody: "",
},
expected: "From: sender@example.com\nTo: recipient@example.com\n",
},
{
name: "Email with headers and body",
email: &EmailMessage{
RawHeaders: "From: sender@example.com\n",
RawBody: "This is the email body",
},
expected: "From: sender@example.com\n\nThis is the email body",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := gen.GenerateRawEmail(tt.email)
if raw != tt.expected {
t.Errorf("GenerateRawEmail() = %q, want %q", raw, tt.expected)
}
})
}
}
func TestGetRecommendations(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
results *AnalysisResults
expectCount int
}{
{
name: "Nil results",
results: nil,
expectCount: 0,
},
{
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 1.0,
BlacklistScore: 1.5,
ContentScore: 0.5,
HeaderScore: 0.5,
Recommendations: []string{
"Improve authentication",
"Fix content issues",
},
},
},
expectCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recs := gen.GetRecommendations(tt.results)
if len(recs) != tt.expectCount {
t.Errorf("Got %d recommendations, want %d", len(recs), tt.expectCount)
}
})
}
}
func TestGetScoreSummaryText(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
tests := []struct {
name string
results *AnalysisResults
expectEmpty bool
expectString string
}{
{
name: "Nil results",
results: nil,
expectEmpty: true,
},
{
name: "Results with score",
results: &AnalysisResults{
Score: &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
},
},
},
expectEmpty: false,
expectString: "8.5/10",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := gen.GetScoreSummaryText(tt.results)
if tt.expectEmpty {
if summary != "" {
t.Errorf("Expected empty summary, got %q", summary)
}
} else {
if summary == "" {
t.Error("Expected non-empty summary")
}
if tt.expectString != "" && !strings.Contains(summary, tt.expectString) {
t.Errorf("Summary should contain %q, got %q", tt.expectString, summary)
}
}
})
}
}
func TestReportCategories(t *testing.T) {
gen := NewReportGenerator(10*time.Second, 10*time.Second, DefaultRBLs)
testID := uuid.New()
email := createComprehensiveTestEmail()
results := gen.AnalyzeEmail(email)
report := gen.GenerateReport(testID, results)
// Verify all check categories are present
categories := make(map[api.CheckCategory]bool)
for _, check := range report.Checks {
categories[check.Category] = true
}
expectedCategories := []api.CheckCategory{
api.Authentication,
api.Dns,
api.Headers,
}
for _, cat := range expectedCategories {
if !categories[cat] {
t.Errorf("Expected category %s not found in checks", cat)
}
}
}
// Helper functions
func createTestEmail() *EmailMessage {
header := make(mail.Header)
header[textproto.CanonicalMIMEHeaderKey("From")] = []string{"sender@example.com"}
header[textproto.CanonicalMIMEHeaderKey("To")] = []string{"recipient@example.com"}
header[textproto.CanonicalMIMEHeaderKey("Subject")] = []string{"Test Email"}
header[textproto.CanonicalMIMEHeaderKey("Date")] = []string{"Mon, 01 Jan 2024 12:00:00 +0000"}
header[textproto.CanonicalMIMEHeaderKey("Message-ID")] = []string{"<test123@example.com>"}
return &EmailMessage{
Header: header,
From: &mail.Address{Address: "sender@example.com"},
To: []*mail.Address{{Address: "recipient@example.com"}},
Subject: "Test Email",
MessageID: "<test123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{
{
ContentType: "text/plain",
Content: "This is a test email",
IsText: true,
},
},
RawHeaders: "From: sender@example.com\nTo: recipient@example.com\nSubject: Test Email\nDate: Mon, 01 Jan 2024 12:00:00 +0000\nMessage-ID: <test123@example.com>\n",
RawBody: "This is a test email",
}
}
func createTestEmailWithSpamAssassin() *EmailMessage {
email := createTestEmail()
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Status")] = []string{"No, score=2.3 required=5.0"}
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Score")] = []string{"2.3"}
email.Header[textproto.CanonicalMIMEHeaderKey("X-Spam-Flag")] = []string{"NO"}
return email
}
func createComprehensiveTestEmail() *EmailMessage {
email := createTestEmailWithSpamAssassin()
// Add authentication headers
email.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] = []string{
"example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com; dmarc=pass",
}
// Add HTML content
email.Parts = append(email.Parts, MessagePart{
ContentType: "text/html",
Content: "<html><body><p>Test</p><a href='https://example.com'>Link</a></body></html>",
IsHTML: true,
})
return email
}

545
pkg/analyzer/scoring.go Normal file
View file

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

View file

@ -0,0 +1,762 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestNewDeliverabilityScorer(t *testing.T) {
scorer := NewDeliverabilityScorer()
if scorer == nil {
t.Fatal("Expected scorer, got nil")
}
}
func TestIsValidMessageID(t *testing.T) {
tests := []struct {
name string
messageID string
expected bool
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expected: true,
},
{
name: "Valid with UUID",
messageID: "<550e8400-e29b-41d4-a716-446655440000@example.com>",
expected: true,
},
{
name: "Missing angle brackets",
messageID: "abc123@example.com",
expected: false,
},
{
name: "Missing @ symbol",
messageID: "<abc123example.com>",
expected: false,
},
{
name: "Multiple @ symbols",
messageID: "<abc@123@example.com>",
expected: false,
},
{
name: "Empty local part",
messageID: "<@example.com>",
expected: false,
},
{
name: "Empty domain part",
messageID: "<abc123@>",
expected: false,
},
{
name: "Empty",
messageID: "",
expected: false,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.isValidMessageID(tt.messageID)
if result != tt.expected {
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
}
})
}
}
func TestCalculateHeaderScore(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minScore float32
maxScore float32
}{
{
name: "Nil email",
email: nil,
minScore: 0.0,
maxScore: 0.0,
},
{
name: "Perfect headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.7,
maxScore: 1.0,
},
{
name: "Missing required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Subject": "Test",
}),
},
minScore: 0.0,
maxScore: 0.4,
},
{
name: "Required only, no recommended",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.4,
maxScore: 0.8,
},
{
name: "Invalid Message-ID format",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "invalid-message-id",
"Subject": "Test",
"To": "recipient@example.com",
"Reply-To": "reply@example.com",
}),
MessageID: "invalid-message-id",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 0.7,
maxScore: 1.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := scorer.calculateHeaderScore(tt.email)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
})
}
}
func TestDetermineRating(t *testing.T) {
tests := []struct {
name string
score float32
expected string
}{
{name: "Excellent - 10.0", score: 10.0, expected: "Excellent"},
{name: "Excellent - 9.5", score: 9.5, expected: "Excellent"},
{name: "Excellent - 9.0", score: 9.0, expected: "Excellent"},
{name: "Good - 8.5", score: 8.5, expected: "Good"},
{name: "Good - 7.0", score: 7.0, expected: "Good"},
{name: "Fair - 6.5", score: 6.5, expected: "Fair"},
{name: "Fair - 5.0", score: 5.0, expected: "Fair"},
{name: "Poor - 4.5", score: 4.5, expected: "Poor"},
{name: "Poor - 3.0", score: 3.0, expected: "Poor"},
{name: "Critical - 2.5", score: 2.5, expected: "Critical"},
{name: "Critical - 0.0", score: 0.0, expected: "Critical"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.determineRating(tt.score)
if result != tt.expected {
t.Errorf("determineRating(%v) = %q, want %q", tt.score, result, tt.expected)
}
})
}
}
func TestGetCategoryStatus(t *testing.T) {
tests := []struct {
name string
score float32
maxScore float32
expected string
}{
{name: "Pass - 100%", score: 3.0, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 90%", score: 2.7, maxScore: 3.0, expected: "Pass"},
{name: "Pass - 80%", score: 2.4, maxScore: 3.0, expected: "Pass"},
{name: "Warn - 75%", score: 2.25, maxScore: 3.0, expected: "Warn"},
{name: "Warn - 50%", score: 1.5, maxScore: 3.0, expected: "Warn"},
{name: "Fail - 40%", score: 1.2, maxScore: 3.0, expected: "Fail"},
{name: "Fail - 0%", score: 0.0, maxScore: 3.0, expected: "Fail"},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.getCategoryStatus(tt.score, tt.maxScore)
if result != tt.expected {
t.Errorf("getCategoryStatus(%v, %v) = %q, want %q", tt.score, tt.maxScore, result, tt.expected)
}
})
}
}
func TestCalculateScore(t *testing.T) {
tests := []struct {
name string
authResults *api.AuthenticationResults
spamResult *SpamAssassinResult
rblResults *RBLResults
contentResults *ContentResults
email *EmailMessage
minScore float32
maxScore float32
expectedRating string
}{
{
name: "Perfect email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: &api.AuthResult{Result: api.AuthResultResultPass},
},
spamResult: &SpamAssassinResult{
Score: -1.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
Images: []ImageCheck{{HasAlt: true}},
HasUnsubscribe: true,
TextPlainRatio: 0.8,
ImageTextRatio: 3.0,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 9.0,
maxScore: 10.0,
expectedRating: "Excellent",
},
{
name: "Poor email - auth issues",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultFail},
Dkim: &[]api.AuthResult{},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 8.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{
IP: "192.0.2.1",
RBL: "zen.spamhaus.org",
Listed: true,
},
},
ListedCount: 1,
},
contentResults: &ContentResults{
HTMLValid: false,
Links: []LinkCheck{{Valid: true, Status: 404}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
minScore: 0.0,
maxScore: 5.0,
expectedRating: "Poor",
},
{
name: "Average email",
authResults: &api.AuthenticationResults{
Spf: &api.AuthResult{Result: api.AuthResultResultPass},
Dkim: &[]api.AuthResult{
{Result: api.AuthResultResultPass},
},
Dmarc: nil,
},
spamResult: &SpamAssassinResult{
Score: 4.0,
RequiredScore: 5.0,
},
rblResults: &RBLResults{
Checks: []RBLCheck{
{IP: "192.0.2.1", Listed: false},
},
},
contentResults: &ContentResults{
HTMLValid: true,
Links: []LinkCheck{{Valid: true, Status: 200}},
HasUnsubscribe: false,
},
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 6.0,
maxScore: 9.0,
expectedRating: "Good",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scorer.CalculateScore(
tt.authResults,
tt.spamResult,
tt.rblResults,
tt.contentResults,
tt.email,
)
if result == nil {
t.Fatal("Expected result, got nil")
}
// Check overall score
if result.OverallScore < tt.minScore || result.OverallScore > tt.maxScore {
t.Errorf("OverallScore = %v, want between %v and %v", result.OverallScore, tt.minScore, tt.maxScore)
}
// Check rating
if result.Rating != tt.expectedRating {
t.Errorf("Rating = %q, want %q", result.Rating, tt.expectedRating)
}
// Verify score is within bounds
if result.OverallScore < 0.0 || result.OverallScore > 10.0 {
t.Errorf("OverallScore %v is out of bounds [0.0, 10.0]", result.OverallScore)
}
// Verify category breakdown exists
if len(result.CategoryBreakdown) != 5 {
t.Errorf("Expected 5 categories, got %d", len(result.CategoryBreakdown))
}
// Verify recommendations exist
if len(result.Recommendations) == 0 && result.Rating != "Excellent" {
t.Error("Expected recommendations for non-excellent rating")
}
// Verify category scores add up to overall score
totalCategoryScore := result.AuthScore + result.SpamScore + result.BlacklistScore + result.ContentScore + result.HeaderScore
if totalCategoryScore < result.OverallScore-0.01 || totalCategoryScore > result.OverallScore+0.01 {
t.Errorf("Category scores sum (%.2f) doesn't match overall score (%.2f)",
totalCategoryScore, result.OverallScore)
}
})
}
}
func TestGenerateRecommendations(t *testing.T) {
tests := []struct {
name string
result *ScoringResult
expectedMinCount int
shouldContainKeyword string
}{
{
name: "Excellent - minimal recommendations",
result: &ScoringResult{
OverallScore: 9.5,
Rating: "Excellent",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 2.0,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "Excellent",
},
{
name: "Critical - many recommendations",
result: &ScoringResult{
OverallScore: 1.0,
Rating: "Critical",
AuthScore: 0.5,
SpamScore: 0.0,
BlacklistScore: 0.0,
ContentScore: 0.3,
HeaderScore: 0.2,
},
expectedMinCount: 5,
shouldContainKeyword: "Critical",
},
{
name: "Poor authentication",
result: &ScoringResult{
OverallScore: 5.0,
Rating: "Fair",
AuthScore: 1.5,
SpamScore: 2.0,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "authentication",
},
{
name: "Blacklist issues",
result: &ScoringResult{
OverallScore: 4.0,
Rating: "Poor",
AuthScore: 3.0,
SpamScore: 2.0,
BlacklistScore: 0.5,
ContentScore: 1.5,
HeaderScore: 1.0,
},
expectedMinCount: 1,
shouldContainKeyword: "blacklist",
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recommendations := scorer.generateRecommendations(tt.result)
if len(recommendations) < tt.expectedMinCount {
t.Errorf("Got %d recommendations, want at least %d", len(recommendations), tt.expectedMinCount)
}
// Check if expected keyword appears in any recommendation
found := false
for _, rec := range recommendations {
if strings.Contains(strings.ToLower(rec), strings.ToLower(tt.shouldContainKeyword)) {
found = true
break
}
}
if !found {
t.Errorf("No recommendation contains keyword %q. Recommendations: %v",
tt.shouldContainKeyword, recommendations)
}
})
}
}
func TestGenerateRequiredHeadersCheck(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
expectedStatus api.CheckStatus
expectedScore float32
}{
{
name: "All required headers present",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
From: &mail.Address{Address: "sender@example.com"},
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedStatus: api.CheckStatusPass,
expectedScore: 0.4,
},
{
name: "Missing all required headers",
email: &EmailMessage{
Header: make(mail.Header),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
{
name: "Missing some required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
}),
},
expectedStatus: api.CheckStatusFail,
expectedScore: 0.0,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := scorer.generateRequiredHeadersCheck(tt.email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMessageIDCheck(t *testing.T) {
tests := []struct {
name string
messageID string
expectedStatus api.CheckStatus
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expectedStatus: api.CheckStatusPass,
},
{
name: "Invalid Message-ID format",
messageID: "invalid-message-id",
expectedStatus: api.CheckStatusWarn,
},
{
name: "Missing Message-ID",
messageID: "",
expectedStatus: api.CheckStatusFail,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Message-ID": tt.messageID,
}),
}
check := scorer.generateMessageIDCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Headers {
t.Errorf("Category = %v, want %v", check.Category, api.Headers)
}
})
}
}
func TestGenerateMIMEStructureCheck(t *testing.T) {
tests := []struct {
name string
parts []MessagePart
expectedStatus api.CheckStatus
}{
{
name: "With MIME parts",
parts: []MessagePart{
{ContentType: "text/plain", Content: "test"},
{ContentType: "text/html", Content: "<p>test</p>"},
},
expectedStatus: api.CheckStatusPass,
},
{
name: "No MIME parts",
parts: []MessagePart{},
expectedStatus: api.CheckStatusWarn,
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: make(mail.Header),
Parts: tt.parts,
}
check := scorer.generateMIMEStructureCheck(email)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
})
}
}
func TestGenerateHeaderChecks(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minChecks int
}{
{
name: "Nil email",
email: nil,
minChecks: 0,
},
{
name: "Complete email",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minChecks: 4, // Required, Recommended, Message-ID, MIME
},
}
scorer := NewDeliverabilityScorer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := scorer.GenerateHeaderChecks(tt.email)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Verify all checks have the Headers category
for _, check := range checks {
if check.Category != api.Headers {
t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers)
}
}
})
}
}
func TestGetScoreSummary(t *testing.T) {
result := &ScoringResult{
OverallScore: 8.5,
Rating: "Good",
AuthScore: 2.5,
SpamScore: 1.8,
BlacklistScore: 2.0,
ContentScore: 1.5,
HeaderScore: 0.7,
CategoryBreakdown: map[string]CategoryScore{
"Authentication": {Score: 2.5, MaxScore: 3.0, Percentage: 83.3, Status: "Pass"},
"Spam Filters": {Score: 1.8, MaxScore: 2.0, Percentage: 90.0, Status: "Pass"},
"Blacklists": {Score: 2.0, MaxScore: 2.0, Percentage: 100.0, Status: "Pass"},
"Content Quality": {Score: 1.5, MaxScore: 2.0, Percentage: 75.0, Status: "Warn"},
"Email Structure": {Score: 0.7, MaxScore: 1.0, Percentage: 70.0, Status: "Warn"},
},
Recommendations: []string{
"Improve content quality",
"Add more headers",
},
}
scorer := NewDeliverabilityScorer()
summary := scorer.GetScoreSummary(result)
// Check that summary contains key information
if !strings.Contains(summary, "8.5") {
t.Error("Summary should contain overall score")
}
if !strings.Contains(summary, "Good") {
t.Error("Summary should contain rating")
}
if !strings.Contains(summary, "Authentication") {
t.Error("Summary should contain category names")
}
if !strings.Contains(summary, "Recommendations") {
t.Error("Summary should contain recommendations section")
}
}
// Helper function to create mail.Header with specific fields
func createHeaderWithFields(fields map[string]string) mail.Header {
header := make(mail.Header)
for key, value := range fields {
if value != "" {
// Use canonical MIME header key format
canonicalKey := textproto.CanonicalMIMEHeaderKey(key)
header[canonicalKey] = []string{value}
}
}
return header
}

View file

@ -0,0 +1,340 @@
// 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"
"regexp"
"strconv"
"strings"
"git.happydns.org/happyDeliver/internal/api"
)
// SpamAssassinAnalyzer analyzes SpamAssassin results from email headers
type SpamAssassinAnalyzer struct{}
// NewSpamAssassinAnalyzer creates a new SpamAssassin analyzer
func NewSpamAssassinAnalyzer() *SpamAssassinAnalyzer {
return &SpamAssassinAnalyzer{}
}
// SpamAssassinResult represents parsed SpamAssassin results
type SpamAssassinResult struct {
IsSpam bool
Score float64
RequiredScore float64
Tests []string
TestDetails map[string]SpamTestDetail
Version string
RawReport string
}
// SpamTestDetail contains details about a specific spam test
type SpamTestDetail struct {
Name string
Score float64
Description string
}
// AnalyzeSpamAssassin extracts and analyzes SpamAssassin results from email headers
func (a *SpamAssassinAnalyzer) AnalyzeSpamAssassin(email *EmailMessage) *SpamAssassinResult {
headers := email.GetSpamAssassinHeaders()
if len(headers) == 0 {
return nil
}
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
// Parse X-Spam-Status header
if statusHeader, ok := headers["X-Spam-Status"]; ok {
a.parseSpamStatus(statusHeader, result)
}
// Parse X-Spam-Score header (as fallback if not in X-Spam-Status)
if scoreHeader, ok := headers["X-Spam-Score"]; ok && result.Score == 0 {
if score, err := strconv.ParseFloat(strings.TrimSpace(scoreHeader), 64); err == nil {
result.Score = score
}
}
// Parse X-Spam-Flag header (as fallback)
if flagHeader, ok := headers["X-Spam-Flag"]; ok {
result.IsSpam = strings.TrimSpace(strings.ToUpper(flagHeader)) == "YES"
}
// Parse X-Spam-Report header for detailed test results
if reportHeader, ok := headers["X-Spam-Report"]; ok {
result.RawReport = reportHeader
a.parseSpamReport(reportHeader, result)
}
// Parse X-Spam-Checker-Version
if versionHeader, ok := headers["X-Spam-Checker-Version"]; ok {
result.Version = strings.TrimSpace(versionHeader)
}
return result
}
// parseSpamStatus parses the X-Spam-Status header
// Format: Yes/No, score=5.5 required=5.0 tests=TEST1,TEST2,TEST3 autolearn=no
func (a *SpamAssassinAnalyzer) parseSpamStatus(header string, result *SpamAssassinResult) {
// Check if spam (first word)
parts := strings.SplitN(header, ",", 2)
if len(parts) > 0 {
firstPart := strings.TrimSpace(parts[0])
result.IsSpam = strings.EqualFold(firstPart, "yes")
}
// Extract score
scoreRe := regexp.MustCompile(`score=(-?\d+\.?\d*)`)
if matches := scoreRe.FindStringSubmatch(header); len(matches) > 1 {
if score, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.Score = score
}
}
// Extract required score
requiredRe := regexp.MustCompile(`required=(-?\d+\.?\d*)`)
if matches := requiredRe.FindStringSubmatch(header); len(matches) > 1 {
if required, err := strconv.ParseFloat(matches[1], 64); err == nil {
result.RequiredScore = required
}
}
// Extract tests
testsRe := regexp.MustCompile(`tests=([^\s]+)`)
if matches := testsRe.FindStringSubmatch(header); len(matches) > 1 {
testsStr := matches[1]
// Tests can be comma or space separated
tests := strings.FieldsFunc(testsStr, func(r rune) bool {
return r == ',' || r == ' '
})
result.Tests = tests
}
}
// parseSpamReport parses the X-Spam-Report header to extract test details
// Format varies, but typically:
// * 1.5 TEST_NAME Description of test
// * 0.0 TEST_NAME2 Description
func (a *SpamAssassinAnalyzer) parseSpamReport(report string, result *SpamAssassinResult) {
// Split by lines
lines := strings.Split(report, "\n")
// Regex to match test lines: * score TEST_NAME Description
testRe := regexp.MustCompile(`^\s*\*\s+(-?\d+\.?\d*)\s+(\S+)\s+(.*)$`)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
matches := testRe.FindStringSubmatch(line)
if len(matches) > 3 {
testName := matches[2]
score, _ := strconv.ParseFloat(matches[1], 64)
description := strings.TrimSpace(matches[3])
detail := SpamTestDetail{
Name: testName,
Score: score,
Description: description,
}
result.TestDetails[testName] = detail
}
}
}
// GetSpamAssassinScore calculates the SpamAssassin contribution to deliverability (0-2 points)
// Scoring:
// - Score <= 0: 2 points (excellent)
// - Score < required: 1.5 points (good)
// - Score slightly above required (< 2x): 1 point (borderline)
// - Score moderately high (< 3x required): 0.5 points (poor)
// - Score very high: 0 points (spam)
func (a *SpamAssassinAnalyzer) GetSpamAssassinScore(result *SpamAssassinResult) float32 {
if result == nil {
return 0.0
}
score := result.Score
required := result.RequiredScore
if required == 0 {
required = 5.0 // Default SpamAssassin threshold
}
// Calculate deliverability score
if score <= 0 {
return 2.0
} else if score < required {
// Linear scaling from 1.5 to 2.0 based on how negative/low the score is
ratio := score / required
return 1.5 + (0.5 * (1.0 - float32(ratio)))
} else if score < required*2 {
// Slightly above threshold
return 1.0
} else if score < required*3 {
// Moderately high
return 0.5
}
// Very high spam score
return 0.0
}
// GenerateSpamAssassinChecks generates check results for SpamAssassin analysis
func (a *SpamAssassinAnalyzer) GenerateSpamAssassinChecks(result *SpamAssassinResult) []api.Check {
var checks []api.Check
if result == nil {
checks = append(checks, api.Check{
Category: api.Spam,
Name: "SpamAssassin Analysis",
Status: api.CheckStatusWarn,
Score: 0.0,
Message: "No SpamAssassin headers found",
Severity: api.PtrTo(api.CheckSeverityMedium),
Advice: api.PtrTo("Ensure your MTA is configured to run SpamAssassin checks"),
})
return checks
}
// Main spam score check
mainCheck := a.generateMainSpamCheck(result)
checks = append(checks, mainCheck)
// Add checks for significant spam tests (score > 1.0 or < -1.0)
for _, test := range result.Tests {
if detail, ok := result.TestDetails[test]; ok {
if detail.Score > 1.0 || detail.Score < -1.0 {
check := a.generateTestCheck(detail)
checks = append(checks, check)
}
}
}
return checks
}
// generateMainSpamCheck creates the main spam score check
func (a *SpamAssassinAnalyzer) generateMainSpamCheck(result *SpamAssassinResult) api.Check {
check := api.Check{
Category: api.Spam,
Name: "SpamAssassin Score",
}
score := result.Score
required := result.RequiredScore
if required == 0 {
required = 5.0
}
delivScore := a.GetSpamAssassinScore(result)
check.Score = delivScore
// Determine status and message based on score
if score <= 0 {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Excellent spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your email has a negative spam score, indicating good email practices")
} else if score < required {
check.Status = api.CheckStatusPass
check.Message = fmt.Sprintf("Good spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Advice = api.PtrTo("Your email passes spam filters")
} else if score < required*1.5 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("Borderline spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityMedium)
check.Advice = api.PtrTo("Your email is close to being marked as spam. Review the triggered spam tests below")
} else if score < required*2 {
check.Status = api.CheckStatusWarn
check.Message = fmt.Sprintf("High spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityHigh)
check.Advice = api.PtrTo("Your email is likely to be marked as spam. Address the issues identified in spam tests")
} else {
check.Status = api.CheckStatusFail
check.Message = fmt.Sprintf("Very high spam score: %.1f (threshold: %.1f)", score, required)
check.Severity = api.PtrTo(api.CheckSeverityCritical)
check.Advice = api.PtrTo("Your email will almost certainly be marked as spam. Urgently address the spam test failures")
}
// Add details
if len(result.Tests) > 0 {
details := fmt.Sprintf("Triggered %d tests: %s", len(result.Tests), strings.Join(result.Tests[:min(5, len(result.Tests))], ", "))
if len(result.Tests) > 5 {
details += fmt.Sprintf(" and %d more", len(result.Tests)-5)
}
check.Details = &details
}
return check
}
// generateTestCheck creates a check for a specific spam test
func (a *SpamAssassinAnalyzer) generateTestCheck(detail SpamTestDetail) api.Check {
check := api.Check{
Category: api.Spam,
Name: fmt.Sprintf("Spam Test: %s", detail.Name),
}
if detail.Score > 0 {
// Negative indicator (increases spam score)
if detail.Score > 2.0 {
check.Status = api.CheckStatusFail
check.Severity = api.PtrTo(api.CheckSeverityHigh)
} else {
check.Status = api.CheckStatusWarn
check.Severity = api.PtrTo(api.CheckSeverityMedium)
}
check.Score = 0.0
check.Message = fmt.Sprintf("Test failed with score +%.1f", detail.Score)
advice := fmt.Sprintf("%s. This test adds %.1f to your spam score", detail.Description, detail.Score)
check.Advice = &advice
} else {
// Positive indicator (decreases spam score)
check.Status = api.CheckStatusPass
check.Score = 1.0
check.Severity = api.PtrTo(api.CheckSeverityInfo)
check.Message = fmt.Sprintf("Test passed with score %.1f", detail.Score)
advice := fmt.Sprintf("%s. This test reduces your spam score by %.1f", detail.Description, -detail.Score)
check.Advice = &advice
}
check.Details = &detail.Description
return check
}
// min returns the minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}

View file

@ -0,0 +1,494 @@
// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/api"
)
func TestParseSpamStatus(t *testing.T) {
tests := []struct {
name string
header string
expectedIsSpam bool
expectedScore float64
expectedReq float64
expectedTests []string
}{
{
name: "Clean email",
header: "No, score=-0.1 required=5.0 tests=ALL_TRUSTED autolearn=ham",
expectedIsSpam: false,
expectedScore: -0.1,
expectedReq: 5.0,
expectedTests: []string{"ALL_TRUSTED"},
},
{
name: "Spam email",
header: "Yes, score=15.5 required=5.0 tests=BAYES_99,SPOOFED_SENDER,MISSING_HEADERS autolearn=spam",
expectedIsSpam: true,
expectedScore: 15.5,
expectedReq: 5.0,
expectedTests: []string{"BAYES_99", "SPOOFED_SENDER", "MISSING_HEADERS"},
},
{
name: "Borderline email",
header: "No, score=4.8 required=5.0 tests=HTML_MESSAGE,MIME_HTML_ONLY",
expectedIsSpam: false,
expectedScore: 4.8,
expectedReq: 5.0,
expectedTests: []string{"HTML_MESSAGE", "MIME_HTML_ONLY"},
},
{
name: "No tests listed",
header: "No, score=0.5 required=5.0",
expectedIsSpam: false,
expectedScore: 0.5,
expectedReq: 5.0,
expectedTests: nil,
},
}
analyzer := NewSpamAssassinAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamStatus(tt.header, result)
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if result.RequiredScore != tt.expectedReq {
t.Errorf("RequiredScore = %v, want %v", result.RequiredScore, tt.expectedReq)
}
if len(tt.expectedTests) > 0 && !stringSliceEqual(result.Tests, tt.expectedTests) {
t.Errorf("Tests = %v, want %v", result.Tests, tt.expectedTests)
}
})
}
}
func TestParseSpamReport(t *testing.T) {
report := `Content analysis details: (15.5 points, 5.0 required)
* 5.0 BAYES_99 Bayes spam probability is 99 to 100%
* 3.5 SPOOFED_SENDER From address doesn't match envelope sender
* 2.0 MISSING_HEADERS Missing important headers
* 1.5 HTML_MESSAGE Contains HTML
* 0.5 MIME_HTML_ONLY Message only has HTML parts
* -1.0 ALL_TRUSTED All mail servers are trusted
* 4.0 SUSPICIOUS_URLS Contains suspicious URLs
`
analyzer := NewSpamAssassinAnalyzer()
result := &SpamAssassinResult{
TestDetails: make(map[string]SpamTestDetail),
}
analyzer.parseSpamReport(report, result)
expectedTests := map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: "From address doesn't match envelope sender",
},
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.0,
Description: "All mail servers are trusted",
},
}
for testName, expected := range expectedTests {
detail, ok := result.TestDetails[testName]
if !ok {
t.Errorf("Test %s not found in results", testName)
continue
}
if detail.Score != expected.Score {
t.Errorf("Test %s score = %v, want %v", testName, detail.Score, expected.Score)
}
if detail.Description != expected.Description {
t.Errorf("Test %s description = %q, want %q", testName, detail.Description, expected.Description)
}
}
}
func TestGetSpamAssassinScore(t *testing.T) {
tests := []struct {
name string
result *SpamAssassinResult
expectedScore float32
minScore float32
maxScore float32
}{
{
name: "Nil result",
result: nil,
expectedScore: 0.0,
},
{
name: "Excellent score (negative)",
result: &SpamAssassinResult{
Score: -2.5,
RequiredScore: 5.0,
},
expectedScore: 2.0,
},
{
name: "Good score (below threshold)",
result: &SpamAssassinResult{
Score: 2.0,
RequiredScore: 5.0,
},
minScore: 1.5,
maxScore: 2.0,
},
{
name: "Borderline (just above threshold)",
result: &SpamAssassinResult{
Score: 6.0,
RequiredScore: 5.0,
},
expectedScore: 1.0,
},
{
name: "High spam score",
result: &SpamAssassinResult{
Score: 12.0,
RequiredScore: 5.0,
},
expectedScore: 0.5,
},
{
name: "Very high spam score",
result: &SpamAssassinResult{
Score: 20.0,
RequiredScore: 5.0,
},
expectedScore: 0.0,
},
}
analyzer := NewSpamAssassinAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := analyzer.GetSpamAssassinScore(tt.result)
if tt.minScore > 0 || tt.maxScore > 0 {
if score < tt.minScore || score > tt.maxScore {
t.Errorf("Score = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
} else {
if score != tt.expectedScore {
t.Errorf("Score = %v, want %v", score, tt.expectedScore)
}
}
})
}
}
func TestAnalyzeSpamAssassin(t *testing.T) {
tests := []struct {
name string
headers map[string]string
expectedIsSpam bool
expectedScore float64
expectedHasDetails bool
}{
{
name: "Clean email with full headers",
headers: map[string]string{
"X-Spam-Status": "No, score=-0.5 required=5.0 tests=ALL_TRUSTED autolearn=ham",
"X-Spam-Score": "-0.5",
"X-Spam-Flag": "NO",
"X-Spam-Report": "* -0.5 ALL_TRUSTED All mail servers are trusted",
"X-Spam-Checker-Version": "SpamAssassin 3.4.2",
},
expectedIsSpam: false,
expectedScore: -0.5,
expectedHasDetails: true,
},
{
name: "Spam email",
headers: map[string]string{
"X-Spam-Status": "Yes, score=15.0 required=5.0 tests=BAYES_99,SPOOFED_SENDER",
"X-Spam-Flag": "YES",
},
expectedIsSpam: true,
expectedScore: 15.0,
},
{
name: "Only X-Spam-Score header",
headers: map[string]string{
"X-Spam-Score": "3.2",
},
expectedIsSpam: false,
expectedScore: 3.2,
},
}
analyzer := NewSpamAssassinAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create email message with headers
email := &EmailMessage{
Header: make(mail.Header),
}
for key, value := range tt.headers {
email.Header[key] = []string{value}
}
result := analyzer.AnalyzeSpamAssassin(email)
if result == nil {
t.Fatal("Expected result, got nil")
}
if result.IsSpam != tt.expectedIsSpam {
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
}
if result.Score != tt.expectedScore {
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
}
if tt.expectedHasDetails && len(result.TestDetails) == 0 {
t.Error("Expected test details, got none")
}
})
}
}
func TestGenerateSpamAssassinChecks(t *testing.T) {
tests := []struct {
name string
result *SpamAssassinResult
expectedStatus api.CheckStatus
minChecks int
}{
{
name: "Nil result",
result: nil,
expectedStatus: api.CheckStatusWarn,
minChecks: 1,
},
{
name: "Clean email",
result: &SpamAssassinResult{
IsSpam: false,
Score: -0.5,
RequiredScore: 5.0,
Tests: []string{"ALL_TRUSTED"},
TestDetails: map[string]SpamTestDetail{
"ALL_TRUSTED": {
Name: "ALL_TRUSTED",
Score: -1.5,
Description: "All mail servers are trusted",
},
},
},
expectedStatus: api.CheckStatusPass,
minChecks: 2, // Main check + one test detail
},
{
name: "Spam email",
result: &SpamAssassinResult{
IsSpam: true,
Score: 15.0,
RequiredScore: 5.0,
Tests: []string{"BAYES_99", "SPOOFED_SENDER"},
TestDetails: map[string]SpamTestDetail{
"BAYES_99": {
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
"SPOOFED_SENDER": {
Name: "SPOOFED_SENDER",
Score: 3.5,
Description: "From address doesn't match envelope sender",
},
},
},
expectedStatus: api.CheckStatusFail,
minChecks: 3, // Main check + two significant tests
},
}
analyzer := NewSpamAssassinAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checks := analyzer.GenerateSpamAssassinChecks(tt.result)
if len(checks) < tt.minChecks {
t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks)
}
// Check main check (first one)
if len(checks) > 0 {
mainCheck := checks[0]
if mainCheck.Status != tt.expectedStatus {
t.Errorf("Main check status = %v, want %v", mainCheck.Status, tt.expectedStatus)
}
if mainCheck.Category != api.Spam {
t.Errorf("Main check category = %v, want %v", mainCheck.Category, api.Spam)
}
}
})
}
}
func TestAnalyzeSpamAssassinNoHeaders(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
email := &EmailMessage{
Header: make(mail.Header),
}
result := analyzer.AnalyzeSpamAssassin(email)
if result != nil {
t.Errorf("Expected nil result for email without SpamAssassin headers, got %+v", result)
}
}
func TestGenerateMainSpamCheck(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
tests := []struct {
name string
score float64
required float64
expectedStatus api.CheckStatus
}{
{"Excellent", -1.0, 5.0, api.CheckStatusPass},
{"Good", 2.0, 5.0, api.CheckStatusPass},
{"Borderline", 6.0, 5.0, api.CheckStatusWarn},
{"High", 8.0, 5.0, api.CheckStatusWarn},
{"Very High", 15.0, 5.0, api.CheckStatusFail},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &SpamAssassinResult{
Score: tt.score,
RequiredScore: tt.required,
}
check := analyzer.generateMainSpamCheck(result)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Message, "spam score") {
t.Error("Message should contain 'spam score'")
}
})
}
}
func TestGenerateTestCheck(t *testing.T) {
analyzer := NewSpamAssassinAnalyzer()
tests := []struct {
name string
detail SpamTestDetail
expectedStatus api.CheckStatus
}{
{
name: "High penalty test",
detail: SpamTestDetail{
Name: "BAYES_99",
Score: 5.0,
Description: "Bayes spam probability is 99 to 100%",
},
expectedStatus: api.CheckStatusFail,
},
{
name: "Medium penalty test",
detail: SpamTestDetail{
Name: "HTML_MESSAGE",
Score: 1.5,
Description: "Contains HTML",
},
expectedStatus: api.CheckStatusWarn,
},
{
name: "Positive test",
detail: SpamTestDetail{
Name: "ALL_TRUSTED",
Score: -2.0,
Description: "All mail servers are trusted",
},
expectedStatus: api.CheckStatusPass,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := analyzer.generateTestCheck(tt.detail)
if check.Status != tt.expectedStatus {
t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus)
}
if check.Category != api.Spam {
t.Errorf("Category = %v, want %v", check.Category, api.Spam)
}
if !strings.Contains(check.Name, tt.detail.Name) {
t.Errorf("Check name should contain test name %s", tt.detail.Name)
}
})
}
}
// Helper function to compare string slices
func stringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}