303 lines
9.1 KiB
Go
303 lines
9.1 KiB
Go
// 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"
|
|
)
|
|
|
|
// HeaderAnalyzer analyzes email header quality and structure
|
|
type HeaderAnalyzer struct{}
|
|
|
|
// NewHeaderAnalyzer creates a new header analyzer
|
|
func NewHeaderAnalyzer() *HeaderAnalyzer {
|
|
return &HeaderAnalyzer{}
|
|
}
|
|
|
|
// calculateHeaderScore evaluates email structural quality
|
|
func (h *HeaderAnalyzer) calculateHeaderScore(email *EmailMessage) int {
|
|
if email == nil {
|
|
return 0
|
|
}
|
|
|
|
score := 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 (40 points)
|
|
if presentHeaders == requiredHeaders {
|
|
score += 40
|
|
} else {
|
|
score += int(40 * (float32(presentHeaders) / float32(requiredHeaders)))
|
|
}
|
|
|
|
// Check recommended headers (30 points)
|
|
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
|
|
recommendedPresent := 0
|
|
for _, header := range recommendedHeaders {
|
|
if email.HasHeader(header) && email.GetHeaderValue(header) != "" {
|
|
recommendedPresent++
|
|
}
|
|
}
|
|
score += int(30 * (float32(recommendedPresent) / float32(len(recommendedHeaders))))
|
|
|
|
// Check for proper MIME structure (20 points)
|
|
if len(email.Parts) > 0 {
|
|
score += 20
|
|
}
|
|
|
|
// Check Message-ID format (10 point)
|
|
if messageID := email.GetHeaderValue("Message-ID"); messageID != "" {
|
|
if h.isValidMessageID(messageID) {
|
|
score += 10
|
|
}
|
|
}
|
|
|
|
// Ensure score doesn't exceed 100
|
|
if score > 100 {
|
|
score = 100
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
// isValidMessageID checks if a Message-ID has proper format
|
|
func (h *HeaderAnalyzer) 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
|
|
}
|
|
|
|
// GenerateHeaderChecks creates checks for email header quality
|
|
func (h *HeaderAnalyzer) GenerateHeaderChecks(email *EmailMessage) []api.Check {
|
|
var checks []api.Check
|
|
|
|
if email == nil {
|
|
return checks
|
|
}
|
|
|
|
// Required headers check
|
|
checks = append(checks, h.generateRequiredHeadersCheck(email))
|
|
|
|
// Recommended headers check
|
|
checks = append(checks, h.generateRecommendedHeadersCheck(email))
|
|
|
|
// Message-ID check
|
|
checks = append(checks, h.generateMessageIDCheck(email))
|
|
|
|
// MIME structure check
|
|
checks = append(checks, h.generateMIMEStructureCheck(email))
|
|
|
|
return checks
|
|
}
|
|
|
|
// generateRequiredHeadersCheck checks for required RFC 5322 headers
|
|
func (h *HeaderAnalyzer) 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 = 4.0
|
|
check.Grade = ScoreToCheckGrade((4.0 / 10.0) * 100)
|
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
check.Message = "All required headers are present"
|
|
check.Advice = api.PtrTo("Your email has proper RFC 5322 headers")
|
|
} else {
|
|
check.Status = api.CheckStatusFail
|
|
check.Score = 0.0
|
|
check.Grade = ScoreToCheckGrade(0.0)
|
|
check.Severity = api.PtrTo(api.CheckSeverityCritical)
|
|
check.Message = fmt.Sprintf("Missing required header(s): %s", strings.Join(missing, ", "))
|
|
check.Advice = api.PtrTo("Add all required headers to ensure email deliverability")
|
|
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
|
check.Details = &details
|
|
}
|
|
|
|
return check
|
|
}
|
|
|
|
// generateRecommendedHeadersCheck checks for recommended headers
|
|
func (h *HeaderAnalyzer) generateRecommendedHeadersCheck(email *EmailMessage) api.Check {
|
|
check := api.Check{
|
|
Category: api.Headers,
|
|
Name: "Recommended Headers",
|
|
}
|
|
|
|
recommendedHeaders := []string{"Subject", "To", "Reply-To"}
|
|
missing := []string{}
|
|
|
|
for _, header := range recommendedHeaders {
|
|
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
|
missing = append(missing, header)
|
|
}
|
|
}
|
|
|
|
if len(missing) == 0 {
|
|
check.Status = api.CheckStatusPass
|
|
check.Score = 30
|
|
check.Grade = ScoreToCheckGrade((3.0 / 10.0) * 100)
|
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
check.Message = "All recommended headers are present"
|
|
check.Advice = api.PtrTo("Your email includes all recommended headers")
|
|
} else if len(missing) < len(recommendedHeaders) {
|
|
check.Status = api.CheckStatusWarn
|
|
check.Score = 15
|
|
check.Grade = ScoreToCheckGrade((1.5 / 10.0) * 100)
|
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
|
check.Message = fmt.Sprintf("Missing some recommended header(s): %s", strings.Join(missing, ", "))
|
|
check.Advice = api.PtrTo("Consider adding recommended headers for better deliverability")
|
|
details := fmt.Sprintf("Missing: %s", strings.Join(missing, ", "))
|
|
check.Details = &details
|
|
} else {
|
|
check.Status = api.CheckStatusWarn
|
|
check.Score = 0
|
|
check.Grade = ScoreToCheckGrade(0.0)
|
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
|
check.Message = "Missing all recommended headers"
|
|
check.Advice = api.PtrTo("Add recommended headers (Subject, To, Reply-To) for better email presentation")
|
|
}
|
|
|
|
return check
|
|
}
|
|
|
|
// generateMessageIDCheck validates Message-ID header
|
|
func (h *HeaderAnalyzer) generateMessageIDCheck(email *EmailMessage) api.Check {
|
|
check := api.Check{
|
|
Category: api.Headers,
|
|
Name: "Message-ID Format",
|
|
}
|
|
|
|
messageID := email.GetHeaderValue("Message-ID")
|
|
|
|
if messageID == "" {
|
|
check.Status = api.CheckStatusFail
|
|
check.Score = 0
|
|
check.Grade = ScoreToCheckGrade(0.0)
|
|
check.Severity = api.PtrTo(api.CheckSeverityHigh)
|
|
check.Message = "Message-ID header is missing"
|
|
check.Advice = api.PtrTo("Add a unique Message-ID header to your email")
|
|
} else if !h.isValidMessageID(messageID) {
|
|
check.Status = api.CheckStatusWarn
|
|
check.Score = 5
|
|
check.Grade = ScoreToCheckGrade((0.5 / 10.0) * 100)
|
|
check.Severity = api.PtrTo(api.CheckSeverityMedium)
|
|
check.Message = "Message-ID format is invalid"
|
|
check.Advice = api.PtrTo("Use proper Message-ID format: <unique-id@domain.com>")
|
|
check.Details = &messageID
|
|
} else {
|
|
check.Status = api.CheckStatusPass
|
|
check.Score = 10
|
|
check.Grade = ScoreToCheckGrade((1.0 / 10.0) * 100)
|
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
check.Message = "Message-ID is properly formatted"
|
|
check.Advice = api.PtrTo("Your Message-ID follows RFC 5322 standards")
|
|
check.Details = &messageID
|
|
}
|
|
|
|
return check
|
|
}
|
|
|
|
// generateMIMEStructureCheck validates MIME structure
|
|
func (h *HeaderAnalyzer) generateMIMEStructureCheck(email *EmailMessage) api.Check {
|
|
check := api.Check{
|
|
Category: api.Headers,
|
|
Name: "MIME Structure",
|
|
}
|
|
|
|
if len(email.Parts) == 0 {
|
|
check.Status = api.CheckStatusWarn
|
|
check.Score = 0.0
|
|
check.Grade = ScoreToCheckGrade(0.0)
|
|
check.Severity = api.PtrTo(api.CheckSeverityLow)
|
|
check.Message = "No MIME parts detected"
|
|
check.Advice = api.PtrTo("Consider using multipart MIME for better compatibility")
|
|
} else {
|
|
check.Status = api.CheckStatusPass
|
|
check.Score = 2.0
|
|
check.Grade = ScoreToCheckGrade((2.0 / 10.0) * 100)
|
|
check.Severity = api.PtrTo(api.CheckSeverityInfo)
|
|
check.Message = fmt.Sprintf("Proper MIME structure with %d part(s)", len(email.Parts))
|
|
check.Advice = api.PtrTo("Your email has proper MIME structure")
|
|
|
|
// Add details about parts
|
|
partTypes := []string{}
|
|
for _, part := range email.Parts {
|
|
if part.ContentType != "" {
|
|
partTypes = append(partTypes, part.ContentType)
|
|
}
|
|
}
|
|
if len(partTypes) > 0 {
|
|
details := fmt.Sprintf("Parts: %s", strings.Join(partTypes, ", "))
|
|
check.Details = &details
|
|
}
|
|
}
|
|
|
|
return check
|
|
}
|