Expose analyzer
This commit is contained in:
parent
01569d1b21
commit
fedb80f7d4
20 changed files with 2 additions and 2 deletions
87
pkg/analyzer/analyzer.go
Normal file
87
pkg/analyzer/analyzer.go
Normal 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)
|
||||
}
|
||||
507
pkg/analyzer/authentication.go
Normal file
507
pkg/analyzer/authentication.go
Normal 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, "-")
|
||||
}
|
||||
304
pkg/analyzer/authentication_checks.go
Normal file
304
pkg/analyzer/authentication_checks.go
Normal 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
|
||||
}
|
||||
846
pkg/analyzer/authentication_test.go
Normal file
846
pkg/analyzer/authentication_test.go
Normal 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
830
pkg/analyzer/content.go
Normal 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
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
719
pkg/analyzer/dns.go
Normal 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
820
pkg/analyzer/dns_test.go
Normal 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
277
pkg/analyzer/parser.go
Normal 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
176
pkg/analyzer/parser_test.go
Normal 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
408
pkg/analyzer/rbl.go
Normal 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
629
pkg/analyzer/rbl_test.go
Normal 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
348
pkg/analyzer/report.go
Normal 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
501
pkg/analyzer/report_test.go
Normal 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
545
pkg/analyzer/scoring.go
Normal 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
|
||||
}
|
||||
762
pkg/analyzer/scoring_test.go
Normal file
762
pkg/analyzer/scoring_test.go
Normal 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
|
||||
}
|
||||
340
pkg/analyzer/spamassassin.go
Normal file
340
pkg/analyzer/spamassassin.go
Normal 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
|
||||
}
|
||||
494
pkg/analyzer/spamassassin_test.go
Normal file
494
pkg/analyzer/spamassassin_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue