All checks were successful
continuous-integration/drone/push Build is passing
Flag emails where the Subject starts with a Re:/Fwd: prefix (in ~17 languages) but neither References nor In-Reply-To is present, a common spam/phishing technique to falsely imply an ongoing conversation.
787 lines
24 KiB
Go
787 lines
24 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"
|
||
"net"
|
||
"net/mail"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"golang.org/x/net/publicsuffix"
|
||
|
||
"git.happydns.org/happyDeliver/internal/model"
|
||
"git.happydns.org/happyDeliver/internal/utils"
|
||
)
|
||
|
||
// 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 from header analysis
|
||
func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *model.HeaderAnalysis) (int, rune) {
|
||
if analysis == nil || analysis.Headers == nil {
|
||
return 0, ' '
|
||
}
|
||
|
||
score := 0
|
||
maxGrade := 6
|
||
headers := *analysis.Headers
|
||
|
||
// RP and From alignment (25 points)
|
||
if analysis.DomainAlignment.Aligned == nil || !*analysis.DomainAlignment.RelaxedAligned {
|
||
// Bad domain alignment, cap grade to C
|
||
maxGrade -= 2
|
||
} else if *analysis.DomainAlignment.Aligned {
|
||
score += 25
|
||
} else if *analysis.DomainAlignment.RelaxedAligned {
|
||
score += 20
|
||
}
|
||
|
||
// Check required headers (RFC 5322) - 30 points
|
||
requiredHeaders := []string{"from", "date", "message-id"}
|
||
requiredCount := len(requiredHeaders)
|
||
presentRequired := 0
|
||
|
||
for _, headerName := range requiredHeaders {
|
||
if check, exists := headers[headerName]; exists && check.Present {
|
||
presentRequired++
|
||
}
|
||
}
|
||
|
||
if presentRequired == requiredCount {
|
||
score += 30
|
||
} else {
|
||
score += int(30 * (float32(presentRequired) / float32(requiredCount)))
|
||
maxGrade = 1
|
||
}
|
||
|
||
// Check recommended headers (15 points)
|
||
recommendedHeaders := []string{"subject", "to"}
|
||
|
||
// Add reply-to when from is a no-reply address
|
||
if h.isNoReplyAddress(headers["from"]) {
|
||
recommendedHeaders = append(recommendedHeaders, "reply-to")
|
||
}
|
||
|
||
recommendedCount := len(recommendedHeaders)
|
||
presentRecommended := 0
|
||
|
||
for _, headerName := range recommendedHeaders {
|
||
if check, exists := headers[headerName]; exists && check.Present {
|
||
presentRecommended++
|
||
}
|
||
}
|
||
score += presentRecommended * 15 / recommendedCount
|
||
|
||
if presentRecommended < recommendedCount {
|
||
maxGrade -= 1
|
||
}
|
||
|
||
// Check for proper MIME structure (20 points)
|
||
if analysis.HasMimeStructure != nil && *analysis.HasMimeStructure {
|
||
score += 20
|
||
} else {
|
||
maxGrade -= 1
|
||
}
|
||
|
||
// Check MIME-Version header (-5 points if present but not "1.0")
|
||
if check, exists := headers["mime-version"]; exists && check.Present {
|
||
if check.Valid != nil && !*check.Valid {
|
||
score -= 5
|
||
}
|
||
}
|
||
|
||
// Check Message-ID format (10 points)
|
||
if check, exists := headers["message-id"]; exists && check.Present {
|
||
// If Valid is set and true, award points
|
||
if check.Valid != nil && *check.Valid {
|
||
score += 10
|
||
} else {
|
||
maxGrade -= 1
|
||
}
|
||
} else {
|
||
maxGrade -= 1
|
||
}
|
||
|
||
// Ensure score doesn't exceed 100
|
||
if score > 100 {
|
||
score = 100
|
||
}
|
||
grade := 'A' + max(6-maxGrade, 0)
|
||
|
||
return score, rune(grade)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// parseEmailDate attempts to parse an email date string using common email date formats
|
||
// Returns the parsed time and an error if parsing fails
|
||
func (h *HeaderAnalyzer) parseEmailDate(dateStr string) (time.Time, error) {
|
||
// Remove timezone name in parentheses if present
|
||
dateStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(strings.TrimSpace(dateStr), "")
|
||
|
||
// Try parsing with common email date formats
|
||
formats := []string{
|
||
time.RFC1123Z, // "Mon, 02 Jan 2006 15:04:05 -0700"
|
||
time.RFC1123, // "Mon, 02 Jan 2006 15:04:05 MST"
|
||
"Mon, 2 Jan 2006 15:04:05 -0700",
|
||
"Mon, 2 Jan 2006 15:04:05 MST",
|
||
"2 Jan 2006 15:04:05 -0700",
|
||
}
|
||
|
||
for _, format := range formats {
|
||
if parsedTime, err := time.Parse(format, dateStr); err == nil {
|
||
return parsedTime, nil
|
||
}
|
||
}
|
||
|
||
return time.Time{}, fmt.Errorf("unable to parse date string: %s", dateStr)
|
||
}
|
||
|
||
// isNoReplyAddress checks if a header check represents a no-reply email address
|
||
func (h *HeaderAnalyzer) isNoReplyAddress(headerCheck model.HeaderCheck) bool {
|
||
if !headerCheck.Present || headerCheck.Value == nil {
|
||
return false
|
||
}
|
||
|
||
value := strings.ToLower(*headerCheck.Value)
|
||
noReplyPatterns := []string{
|
||
"no-reply",
|
||
"noreply",
|
||
"ne-pas-repondre",
|
||
"nepasrepondre",
|
||
}
|
||
|
||
for _, pattern := range noReplyPatterns {
|
||
if strings.Contains(value, pattern) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// validateAddressHeader validates email address header using net/mail parser
|
||
// and returns the normalized address string in "Name <email>" format
|
||
func (h *HeaderAnalyzer) validateAddressHeader(value string) (string, error) {
|
||
// Try to parse as a single address first
|
||
if addr, err := mail.ParseAddress(value); err == nil {
|
||
return h.formatAddress(addr), nil
|
||
}
|
||
|
||
// If single address parsing fails, try parsing as an address list
|
||
// (for headers like To, Cc that can contain multiple addresses)
|
||
if addrs, err := mail.ParseAddressList(value); err != nil {
|
||
return "", err
|
||
} else {
|
||
// Join multiple addresses with ", "
|
||
result := ""
|
||
for i, addr := range addrs {
|
||
if i > 0 {
|
||
result += ", "
|
||
}
|
||
result += h.formatAddress(addr)
|
||
}
|
||
return result, nil
|
||
}
|
||
}
|
||
|
||
// formatAddress formats a mail.Address as "Name <email>" or just "email" if no name
|
||
func (h *HeaderAnalyzer) formatAddress(addr *mail.Address) string {
|
||
if addr.Name != "" {
|
||
return fmt.Sprintf("%s <%s>", addr.Name, addr.Address)
|
||
}
|
||
return addr.Address
|
||
}
|
||
|
||
// GenerateHeaderAnalysis creates structured header analysis from email
|
||
func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage, authResults *model.AuthenticationResults) *model.HeaderAnalysis {
|
||
if email == nil {
|
||
return nil
|
||
}
|
||
|
||
analysis := &model.HeaderAnalysis{}
|
||
|
||
// Check for proper MIME structure
|
||
analysis.HasMimeStructure = utils.PtrTo(len(email.Parts) > 0)
|
||
|
||
// Initialize headers map
|
||
headers := make(map[string]model.HeaderCheck)
|
||
|
||
// Check required headers
|
||
requiredHeaders := []string{"From", "To", "Date", "Message-ID", "Subject"}
|
||
for _, headerName := range requiredHeaders {
|
||
check := h.checkHeader(email, headerName, "required")
|
||
headers[strings.ToLower(headerName)] = *check
|
||
}
|
||
|
||
// Check recommended headers
|
||
recommendedHeaders := []string{}
|
||
if h.isNoReplyAddress(headers["from"]) {
|
||
recommendedHeaders = append(recommendedHeaders, "reply-to")
|
||
}
|
||
for _, headerName := range recommendedHeaders {
|
||
check := h.checkHeader(email, headerName, "recommended")
|
||
headers[strings.ToLower(headerName)] = *check
|
||
}
|
||
|
||
// Check MIME-Version header (recommended but absence is not penalized)
|
||
mimeVersionCheck := h.checkHeader(email, "MIME-Version", "recommended")
|
||
headers[strings.ToLower("MIME-Version")] = *mimeVersionCheck
|
||
|
||
// Check optional headers
|
||
optionalHeaders := []string{"List-Unsubscribe", "List-Unsubscribe-Post"}
|
||
for _, headerName := range optionalHeaders {
|
||
check := h.checkHeader(email, headerName, "newsletter")
|
||
headers[strings.ToLower(headerName)] = *check
|
||
}
|
||
|
||
analysis.Headers = &headers
|
||
|
||
// Received chain
|
||
receivedChain := h.parseReceivedChain(email)
|
||
if len(receivedChain) > 0 {
|
||
analysis.ReceivedChain = &receivedChain
|
||
}
|
||
|
||
// Domain alignment
|
||
domainAlignment := h.analyzeDomainAlignment(email, authResults)
|
||
if domainAlignment != nil {
|
||
analysis.DomainAlignment = domainAlignment
|
||
}
|
||
|
||
// Header issues
|
||
issues := h.findHeaderIssues(email)
|
||
if len(issues) > 0 {
|
||
analysis.Issues = &issues
|
||
}
|
||
|
||
return analysis
|
||
}
|
||
|
||
// checkHeader checks if a header is present and valid
|
||
func (h *HeaderAnalyzer) checkHeader(email *EmailMessage, headerName string, importance string) *model.HeaderCheck {
|
||
value := email.GetHeaderValue(headerName)
|
||
present := email.HasHeader(headerName) && value != ""
|
||
|
||
importanceEnum := model.HeaderCheckImportance(importance)
|
||
check := &model.HeaderCheck{
|
||
Present: present,
|
||
Importance: &importanceEnum,
|
||
}
|
||
|
||
if present {
|
||
check.Value = &value
|
||
|
||
// Validate specific headers
|
||
valid := true
|
||
var headerIssues []string
|
||
|
||
switch headerName {
|
||
case "Message-ID":
|
||
if !h.isValidMessageID(value) {
|
||
valid = false
|
||
headerIssues = append(headerIssues, "Invalid Message-ID format (should be <id@domain>)")
|
||
}
|
||
if len(email.Header["Message-Id"]) > 1 {
|
||
valid = false
|
||
headerIssues = append(headerIssues, fmt.Sprintf("Multiple Message-ID headers found (%d); only one is allowed", len(email.Header["Message-Id"])))
|
||
}
|
||
case "Date":
|
||
// Validate date format
|
||
if _, err := h.parseEmailDate(value); err != nil {
|
||
valid = false
|
||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid date format: %v", err))
|
||
}
|
||
case "MIME-Version":
|
||
if value != "1.0" {
|
||
valid = false
|
||
headerIssues = append(headerIssues, fmt.Sprintf("MIME-Version should be '1.0', got '%s'", value))
|
||
}
|
||
case "From", "To", "Cc", "Bcc", "Reply-To", "Sender", "Resent-From", "Resent-To", "Return-Path":
|
||
// Parse address header using net/mail and get normalized address
|
||
if normalizedAddr, err := h.validateAddressHeader(value); err != nil {
|
||
valid = false
|
||
headerIssues = append(headerIssues, fmt.Sprintf("Invalid email address format: %v", err))
|
||
} else {
|
||
// Use the normalized address as the value
|
||
check.Value = &normalizedAddr
|
||
}
|
||
}
|
||
|
||
check.Valid = &valid
|
||
if len(headerIssues) > 0 {
|
||
check.Issues = &headerIssues
|
||
}
|
||
} else {
|
||
valid := false
|
||
check.Valid = &valid
|
||
if importance == "required" {
|
||
issues := []string{"Required header is missing"}
|
||
check.Issues = &issues
|
||
}
|
||
}
|
||
|
||
return check
|
||
}
|
||
|
||
// analyzeDomainAlignment checks domain alignment between headers and DKIM signatures
|
||
func (h *HeaderAnalyzer) analyzeDomainAlignment(email *EmailMessage, authResults *model.AuthenticationResults) *model.DomainAlignment {
|
||
alignment := &model.DomainAlignment{
|
||
Aligned: utils.PtrTo(true),
|
||
RelaxedAligned: utils.PtrTo(true),
|
||
}
|
||
|
||
// Extract From domain
|
||
fromAddr := email.GetHeaderValue("From")
|
||
if fromAddr != "" {
|
||
domain := h.extractDomain(fromAddr)
|
||
if domain != "" {
|
||
alignment.FromDomain = &domain
|
||
// Extract organizational domain
|
||
orgDomain := getOrganizationalDomain(domain)
|
||
alignment.FromOrgDomain = &orgDomain
|
||
}
|
||
}
|
||
|
||
// Extract Return-Path domain
|
||
returnPath := email.GetHeaderValue("Return-Path")
|
||
if returnPath != "" {
|
||
domain := h.extractDomain(returnPath)
|
||
if domain != "" {
|
||
alignment.ReturnPathDomain = &domain
|
||
// Extract organizational domain
|
||
orgDomain := getOrganizationalDomain(domain)
|
||
alignment.ReturnPathOrgDomain = &orgDomain
|
||
}
|
||
}
|
||
|
||
// Extract DKIM domains from authentication results
|
||
var dkimDomains []model.DKIMDomainInfo
|
||
if authResults != nil && authResults.Dkim != nil {
|
||
for _, dkim := range *authResults.Dkim {
|
||
if dkim.Domain != nil && *dkim.Domain != "" {
|
||
domain := *dkim.Domain
|
||
orgDomain := getOrganizationalDomain(domain)
|
||
dkimDomains = append(dkimDomains, model.DKIMDomainInfo{
|
||
Domain: domain,
|
||
OrgDomain: orgDomain,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if len(dkimDomains) > 0 {
|
||
alignment.DkimDomains = &dkimDomains
|
||
}
|
||
|
||
// Check alignment (strict and relaxed)
|
||
issues := []string{}
|
||
|
||
// hasReturnPath and hasDKIM track whether we have these fields to check
|
||
hasReturnPath := alignment.FromDomain != nil && alignment.ReturnPathDomain != nil
|
||
hasDKIM := alignment.FromDomain != nil && len(dkimDomains) > 0
|
||
|
||
// If neither Return-Path nor DKIM is present, keep default alignment (true)
|
||
// Otherwise, at least one must be aligned for overall alignment to be true
|
||
strictAligned := !hasReturnPath && !hasDKIM
|
||
relaxedAligned := !hasReturnPath && !hasDKIM
|
||
|
||
// Check Return-Path alignment
|
||
rpStrictAligned := false
|
||
rpRelaxedAligned := false
|
||
if hasReturnPath {
|
||
fromDomain := *alignment.FromDomain
|
||
rpDomain := *alignment.ReturnPathDomain
|
||
|
||
// Strict alignment: exact match (case-insensitive)
|
||
rpStrictAligned = strings.EqualFold(fromDomain, rpDomain)
|
||
|
||
// Relaxed alignment: organizational domain match
|
||
var fromOrgDomain, rpOrgDomain string
|
||
if alignment.FromOrgDomain != nil {
|
||
fromOrgDomain = *alignment.FromOrgDomain
|
||
}
|
||
if alignment.ReturnPathOrgDomain != nil {
|
||
rpOrgDomain = *alignment.ReturnPathOrgDomain
|
||
}
|
||
rpRelaxedAligned = strings.EqualFold(fromOrgDomain, rpOrgDomain)
|
||
|
||
if !rpStrictAligned {
|
||
if rpRelaxedAligned {
|
||
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not exactly match From domain (%s), but satisfies relaxed alignment (organizational domain: %s)", rpDomain, fromDomain, fromOrgDomain))
|
||
} else {
|
||
issues = append(issues, fmt.Sprintf("Return-Path domain (%s) does not match From domain (%s) - neither strict nor relaxed alignment", rpDomain, fromDomain))
|
||
}
|
||
}
|
||
|
||
strictAligned = rpStrictAligned
|
||
relaxedAligned = rpRelaxedAligned
|
||
}
|
||
|
||
// Check DKIM alignment
|
||
dkimStrictAligned := false
|
||
dkimRelaxedAligned := false
|
||
if hasDKIM {
|
||
fromDomain := *alignment.FromDomain
|
||
var fromOrgDomain string
|
||
if alignment.FromOrgDomain != nil {
|
||
fromOrgDomain = *alignment.FromOrgDomain
|
||
}
|
||
|
||
for _, dkimDomain := range dkimDomains {
|
||
// Check strict alignment for this DKIM signature
|
||
if strings.EqualFold(fromDomain, dkimDomain.Domain) {
|
||
dkimStrictAligned = true
|
||
}
|
||
|
||
// Check relaxed alignment for this DKIM signature
|
||
if strings.EqualFold(fromOrgDomain, dkimDomain.OrgDomain) {
|
||
dkimRelaxedAligned = true
|
||
}
|
||
}
|
||
|
||
if !dkimStrictAligned && !dkimRelaxedAligned {
|
||
// List all DKIM domains that failed alignment
|
||
dkimDomainsList := []string{}
|
||
for _, dkimDomain := range dkimDomains {
|
||
dkimDomainsList = append(dkimDomainsList, dkimDomain.Domain)
|
||
}
|
||
issues = append(issues, fmt.Sprintf("DKIM signature domains (%s) do not align with From domain (%s) - neither strict nor relaxed alignment", strings.Join(dkimDomainsList, ", "), fromDomain))
|
||
} else if !dkimStrictAligned && dkimRelaxedAligned {
|
||
// DKIM has relaxed alignment but not strict
|
||
issues = append(issues, fmt.Sprintf("DKIM signature domains satisfy relaxed alignment with From domain (%s) but not strict alignment (organizational domain: %s)", fromDomain, fromOrgDomain))
|
||
}
|
||
|
||
// Overall alignment requires at least one method (Return-Path OR DKIM) to be aligned
|
||
// For DMARC compliance, at least one of SPF or DKIM must be aligned
|
||
if dkimStrictAligned {
|
||
strictAligned = true
|
||
}
|
||
if dkimRelaxedAligned {
|
||
relaxedAligned = true
|
||
}
|
||
}
|
||
|
||
*alignment.Aligned = strictAligned
|
||
*alignment.RelaxedAligned = relaxedAligned
|
||
|
||
if len(issues) > 0 {
|
||
alignment.Issues = &issues
|
||
}
|
||
|
||
return alignment
|
||
}
|
||
|
||
// extractDomain extracts domain from email address
|
||
func (h *HeaderAnalyzer) extractDomain(emailAddr string) string {
|
||
// Remove angle brackets if present
|
||
emailAddr = strings.Trim(emailAddr, "<> ")
|
||
|
||
// Find @ symbol
|
||
atIndex := strings.LastIndex(emailAddr, "@")
|
||
if atIndex == -1 {
|
||
return ""
|
||
}
|
||
|
||
domain := emailAddr[atIndex+1:]
|
||
// Remove any trailing >
|
||
domain = strings.TrimRight(domain, ">")
|
||
|
||
return domain
|
||
}
|
||
|
||
// getOrganizationalDomain extracts the organizational domain from a fully qualified domain name
|
||
// using the Public Suffix List (PSL) to correctly handle multi-level TLDs.
|
||
// For example: mail.example.com -> example.com, mail.example.co.uk -> example.co.uk
|
||
func getOrganizationalDomain(domain string) string {
|
||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||
|
||
// Use golang.org/x/net/publicsuffix to get the eTLD+1 (organizational domain)
|
||
// This correctly handles cases like .co.uk, .com.au, etc.
|
||
etldPlusOne, err := publicsuffix.EffectiveTLDPlusOne(domain)
|
||
if err != nil {
|
||
// Fallback to simple two-label extraction if PSL lookup fails
|
||
labels := strings.Split(domain, ".")
|
||
if len(labels) <= 2 {
|
||
return domain
|
||
}
|
||
return strings.Join(labels[len(labels)-2:], ".")
|
||
}
|
||
|
||
return etldPlusOne
|
||
}
|
||
|
||
// findHeaderIssues identifies issues with headers
|
||
func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIssue {
|
||
var issues []model.HeaderIssue
|
||
|
||
// Check for missing required headers
|
||
requiredHeaders := []string{"From", "Date", "Message-ID"}
|
||
for _, header := range requiredHeaders {
|
||
if !email.HasHeader(header) || email.GetHeaderValue(header) == "" {
|
||
issues = append(issues, model.HeaderIssue{
|
||
Header: header,
|
||
Severity: model.HeaderIssueSeverityCritical,
|
||
Message: fmt.Sprintf("Required header '%s' is missing", header),
|
||
Advice: utils.PtrTo(fmt.Sprintf("Add the %s header to ensure RFC 5322 compliance", header)),
|
||
})
|
||
}
|
||
}
|
||
|
||
// Check Message-ID format
|
||
messageID := email.GetHeaderValue("Message-ID")
|
||
if messageID != "" && !h.isValidMessageID(messageID) {
|
||
issues = append(issues, model.HeaderIssue{
|
||
Header: "Message-ID",
|
||
Severity: model.HeaderIssueSeverityMedium,
|
||
Message: "Message-ID format is invalid",
|
||
Advice: utils.PtrTo("Use proper Message-ID format: <unique-id@domain.com>"),
|
||
})
|
||
}
|
||
|
||
// Check for fake reply/forward: Subject has Re:/Fwd: prefix but no thread headers
|
||
subject := email.GetHeaderValue("Subject")
|
||
if h.hasReplyPrefix(subject) && !email.HasHeader("References") && !email.HasHeader("In-Reply-To") {
|
||
issues = append(issues, model.HeaderIssue{
|
||
Header: "Subject",
|
||
Severity: model.HeaderIssueSeverityHigh,
|
||
Message: "Subject indicates a reply or forward but no References or In-Reply-To header is present",
|
||
Advice: utils.PtrTo("Remove the Re:/Fwd: prefix from the subject, or add References/In-Reply-To headers if this is a genuine reply"),
|
||
})
|
||
}
|
||
|
||
return issues
|
||
}
|
||
|
||
// hasReplyPrefix reports whether a subject line starts with a reply or forward prefix.
|
||
func (h *HeaderAnalyzer) hasReplyPrefix(subject string) bool {
|
||
// Normalize: collapse leading whitespace and make comparison case-insensitive
|
||
s := strings.ToLower(strings.TrimSpace(subject))
|
||
|
||
prefixes := []string{
|
||
"re:", // English / universal
|
||
"fwd:", // English forward
|
||
"fw:", // English forward (short)
|
||
"aw:", // German Antwort
|
||
"wg:", // German Weitergeleitet
|
||
"sv:", // Scandinavian Svar
|
||
"vs:", // Finnish Vastaus / Norwegian
|
||
"ref:", // Some clients
|
||
"rép:", // French Réponse
|
||
"tr:", // French Transfert
|
||
"odp:", // Polish Odpowiedź
|
||
"ynt:", // Turkish Yanıt
|
||
"res:", // Portuguese/Spanish Resposta/Respuesta
|
||
"enc:", // Spanish Enviado/Reenviado
|
||
"vl:", // Dutch Verwijzing
|
||
"antw:", // Dutch Antwoord
|
||
"rv:", // Norwegian/Swedish
|
||
}
|
||
|
||
for _, p := range prefixes {
|
||
if strings.HasPrefix(s, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// parseReceivedChain extracts the chain of Received headers from an email
|
||
func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop {
|
||
if email == nil || email.Header == nil {
|
||
return nil
|
||
}
|
||
|
||
receivedHeaders := email.Header["Received"]
|
||
if len(receivedHeaders) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var chain []model.ReceivedHop
|
||
|
||
for _, receivedValue := range receivedHeaders {
|
||
hop := h.parseReceivedHeader(receivedValue)
|
||
if hop != nil {
|
||
chain = append(chain, *hop)
|
||
}
|
||
}
|
||
|
||
return chain
|
||
}
|
||
|
||
// parseReceivedHeader parses a single Received header value
|
||
func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.ReceivedHop {
|
||
hop := &model.ReceivedHop{}
|
||
|
||
// Normalize whitespace - Received headers can span multiple lines
|
||
normalized := strings.Join(strings.Fields(receivedValue), " ")
|
||
|
||
// Check if this is a "by-first" header (e.g., "by hostname (Postfix, from userid...)")
|
||
// vs standard "from-first" header (e.g., "from hostname ... by hostname")
|
||
isByFirst := regexp.MustCompile(`^by\s+`).MatchString(strings.TrimSpace(normalized))
|
||
|
||
// Extract "from" field - only if not in "by-first" format
|
||
// Avoid matching "from" inside parentheses after "by"
|
||
if !isByFirst {
|
||
fromRegex := regexp.MustCompile(`(?i)^from\s+([^\s(]+)`)
|
||
if matches := fromRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||
from := matches[1]
|
||
hop.From = &from
|
||
}
|
||
}
|
||
|
||
// Extract "by" field
|
||
byRegex := regexp.MustCompile(`(?i)by\s+([^\s(]+)`)
|
||
if matches := byRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||
by := matches[1]
|
||
hop.By = &by
|
||
}
|
||
|
||
// Extract "with" field (protocol) - must come after "by" and before "id" or "for"
|
||
// This ensures we get the mail transfer protocol, not other "with" occurrences
|
||
// Avoid matching "with" inside parentheses (like in TLS details)
|
||
withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)(?:\s|;)`)
|
||
if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||
with := matches[1]
|
||
hop.With = &with
|
||
}
|
||
|
||
// Extract "id" field - should come after "with" or "by", not inside parentheses
|
||
// Match pattern: "id <value>" where value doesn't contain parentheses or semicolons
|
||
idRegex := regexp.MustCompile(`(?i)\s+id\s+([^\s;()]+)`)
|
||
if matches := idRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||
id := matches[1]
|
||
hop.Id = &id
|
||
}
|
||
|
||
// Extract IP address from parentheses after "from"
|
||
// Pattern: from hostname (anything [IPv4/IPv6])
|
||
ipRegex := regexp.MustCompile(`\[([^\]]+)\]`)
|
||
if matches := ipRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||
ipStr := matches[1]
|
||
|
||
// Handle IPv6: prefix (some MTAs include this)
|
||
ipStr = strings.TrimPrefix(ipStr, "IPv6:")
|
||
|
||
// Check if it's a valid IP (IPv4 or IPv6)
|
||
if net.ParseIP(ipStr) != nil {
|
||
hop.Ip = &ipStr
|
||
|
||
// Perform reverse DNS lookup
|
||
if reverseNames, err := net.LookupAddr(ipStr); err == nil && len(reverseNames) > 0 {
|
||
// Remove trailing dot from PTR record
|
||
reverse := strings.TrimSuffix(reverseNames[0], ".")
|
||
hop.Reverse = &reverse
|
||
}
|
||
}
|
||
}
|
||
|
||
// Extract timestamp - usually at the end after semicolon
|
||
// Common formats: "for <...>; Tue, 15 Oct 2024 12:34:56 +0000 (UTC)"
|
||
timestampRegex := regexp.MustCompile(`;\s*(.+)$`)
|
||
if matches := timestampRegex.FindStringSubmatch(normalized); len(matches) > 1 {
|
||
timestampStr := strings.TrimSpace(matches[1])
|
||
|
||
// Use the dedicated date parsing function
|
||
if parsedTime, err := h.parseEmailDate(timestampStr); err == nil {
|
||
hop.Timestamp = &parsedTime
|
||
}
|
||
}
|
||
|
||
// Extract TLS details from the Received header parentheticals
|
||
// (e.g. "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) ...)")
|
||
hop.Tls = parseReceivedTLS(normalized)
|
||
|
||
return hop
|
||
}
|
||
|
||
// parseReceivedTLS extracts TLS connection details from a normalized Received header value.
|
||
// Returns nil when the hop was not encrypted (no TLS version/cipher found).
|
||
func parseReceivedTLS(normalized string) *model.TLSInfo {
|
||
tls := &model.TLSInfo{}
|
||
found := false
|
||
|
||
// TLS protocol version, e.g. "using TLSv1.3"
|
||
if matches := regexp.MustCompile(`(?i)using\s+(TLSv[0-9.]+|SSLv[0-9.]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||
tls.Version = &matches[1]
|
||
found = true
|
||
}
|
||
|
||
// Cipher suite, e.g. "with cipher TLS_AES_256_GCM_SHA384"
|
||
if matches := regexp.MustCompile(`(?i)with cipher\s+([A-Za-z0-9_-]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||
tls.Cipher = &matches[1]
|
||
found = true
|
||
}
|
||
|
||
// Cipher strength, e.g. "(256/256 bits)"
|
||
if matches := regexp.MustCompile(`\((\d+)/\d+ bits\)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||
if bits, err := strconv.Atoi(matches[1]); err == nil {
|
||
tls.Bits = &bits
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return nil
|
||
}
|
||
|
||
// Certificate verification status. Postfix emits "(verified OK)" when the peer
|
||
// certificate was trusted, "(not verified)" otherwise. "No client certificate
|
||
// requested" leaves the field unset (trust is simply not applicable).
|
||
if regexp.MustCompile(`(?i)verified OK`).MatchString(normalized) {
|
||
tls.Verified = utils.PtrTo(true)
|
||
} else if regexp.MustCompile(`(?i)not verified`).MatchString(normalized) {
|
||
tls.Verified = utils.PtrTo(false)
|
||
}
|
||
|
||
return tls
|
||
}
|