Implement authentication header analysis (DKIM, DMARC, SPF)
This commit is contained in:
parent
de23ae3e82
commit
9ae0bc21b7
4 changed files with 991 additions and 0 deletions
511
internal/analyzer/authentication.go
Normal file
511
internal/analyzer/authentication.go
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthenticationScore calculates the authentication score (0-3 points)
|
||||||
|
func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 {
|
||||||
|
var score float32 = 0.0
|
||||||
|
|
||||||
|
// SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail
|
||||||
|
if results.Spf != nil {
|
||||||
|
switch results.Spf.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
score += 1.0
|
||||||
|
case api.AuthResultResultNeutral, api.AuthResultResultSoftfail:
|
||||||
|
score += 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DKIM: 1 point for at least one pass
|
||||||
|
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||||
|
for _, dkim := range *results.Dkim {
|
||||||
|
if dkim.Result == api.AuthResultResultPass {
|
||||||
|
score += 1.0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARC: 1 point for pass
|
||||||
|
if results.Dmarc != nil {
|
||||||
|
switch results.Dmarc.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
score += 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 3 points maximum
|
||||||
|
if score > 3.0 {
|
||||||
|
score = 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAuthenticationChecks generates check results for authentication
|
||||||
|
func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check {
|
||||||
|
var checks []api.Check
|
||||||
|
|
||||||
|
// SPF check
|
||||||
|
if results.Spf != nil {
|
||||||
|
check := a.generateSPFCheck(results.Spf)
|
||||||
|
checks = append(checks, check)
|
||||||
|
} else {
|
||||||
|
checks = append(checks, api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "SPF Record",
|
||||||
|
Status: api.CheckStatusWarn,
|
||||||
|
Score: 0.0,
|
||||||
|
Message: "No SPF authentication result found",
|
||||||
|
Severity: api.PtrTo(api.Medium),
|
||||||
|
Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DKIM check
|
||||||
|
if results.Dkim != nil && len(*results.Dkim) > 0 {
|
||||||
|
for i, dkim := range *results.Dkim {
|
||||||
|
check := a.generateDKIMCheck(&dkim, i)
|
||||||
|
checks = append(checks, check)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks = append(checks, api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "DKIM Signature",
|
||||||
|
Status: api.CheckStatusWarn,
|
||||||
|
Score: 0.0,
|
||||||
|
Message: "No DKIM signature found",
|
||||||
|
Severity: api.PtrTo(api.Medium),
|
||||||
|
Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMARC check
|
||||||
|
if results.Dmarc != nil {
|
||||||
|
check := a.generateDMARCCheck(results.Dmarc)
|
||||||
|
checks = append(checks, check)
|
||||||
|
} else {
|
||||||
|
checks = append(checks, api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "DMARC Policy",
|
||||||
|
Status: api.CheckStatusWarn,
|
||||||
|
Score: 0.0,
|
||||||
|
Message: "No DMARC authentication result found",
|
||||||
|
Severity: api.PtrTo(api.Medium),
|
||||||
|
Advice: api.PtrTo("Implement DMARC policy for your domain"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "SPF Record",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch spf.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 1.0
|
||||||
|
check.Message = "SPF validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.Info)
|
||||||
|
check.Advice = api.PtrTo("Your SPF record is properly configured")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "SPF validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.Critical)
|
||||||
|
check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server")
|
||||||
|
case api.AuthResultResultSoftfail:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.5
|
||||||
|
check.Message = "SPF validation softfail"
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||||
|
case api.AuthResultResultNeutral:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.5
|
||||||
|
check.Message = "SPF validation neutral"
|
||||||
|
check.Severity = api.PtrTo(api.Low)
|
||||||
|
check.Advice = api.PtrTo("Consider tightening your SPF policy")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result)
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Review your SPF record configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
if spf.Domain != nil {
|
||||||
|
details := fmt.Sprintf("Domain: %s", *spf.Domain)
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: fmt.Sprintf("DKIM Signature #%d", index+1),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dkim.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 1.0
|
||||||
|
check.Message = "DKIM signature is valid"
|
||||||
|
check.Severity = api.PtrTo(api.Info)
|
||||||
|
check.Advice = api.PtrTo("Your DKIM signature is properly configured")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "DKIM signature validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.High)
|
||||||
|
check.Advice = api.PtrTo("Check your DKIM keys and signing configuration")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result)
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsParts []string
|
||||||
|
if dkim.Domain != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain))
|
||||||
|
}
|
||||||
|
if dkim.Selector != nil {
|
||||||
|
detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector))
|
||||||
|
}
|
||||||
|
if len(detailsParts) > 0 {
|
||||||
|
details := strings.Join(detailsParts, ", ")
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check {
|
||||||
|
check := api.Check{
|
||||||
|
Category: api.Authentication,
|
||||||
|
Name: "DMARC Policy",
|
||||||
|
}
|
||||||
|
|
||||||
|
switch dmarc.Result {
|
||||||
|
case api.AuthResultResultPass:
|
||||||
|
check.Status = api.CheckStatusPass
|
||||||
|
check.Score = 1.0
|
||||||
|
check.Message = "DMARC validation passed"
|
||||||
|
check.Severity = api.PtrTo(api.Info)
|
||||||
|
check.Advice = api.PtrTo("Your DMARC policy is properly aligned")
|
||||||
|
case api.AuthResultResultFail:
|
||||||
|
check.Status = api.CheckStatusFail
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = "DMARC validation failed"
|
||||||
|
check.Severity = api.PtrTo(api.High)
|
||||||
|
check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain")
|
||||||
|
default:
|
||||||
|
check.Status = api.CheckStatusWarn
|
||||||
|
check.Score = 0.0
|
||||||
|
check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result)
|
||||||
|
check.Severity = api.PtrTo(api.Medium)
|
||||||
|
check.Advice = api.PtrTo("Configure DMARC policy for your domain")
|
||||||
|
}
|
||||||
|
|
||||||
|
if dmarc.Domain != nil {
|
||||||
|
details := fmt.Sprintf("Domain: %s", *dmarc.Domain)
|
||||||
|
check.Details = &details
|
||||||
|
}
|
||||||
|
|
||||||
|
return check
|
||||||
|
}
|
||||||
277
internal/analyzer/parser.go
Normal file
277
internal/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
internal/analyzer/parser_test.go
Normal file
176
internal/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")
|
||||||
|
}
|
||||||
|
}
|
||||||
27
internal/api/helpers.go
Normal file
27
internal/api/helpers.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// 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 api
|
||||||
|
|
||||||
|
// PtrTo returns a pointer to the provided value
|
||||||
|
func PtrTo[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue