// 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 . // // 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 . package analyzer import ( "fmt" "net" "net/mail" "regexp" "strings" "time" "golang.org/x/net/publicsuffix" "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 from header analysis func (h *HeaderAnalyzer) CalculateHeaderScore(analysis *api.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 api.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 " 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 " 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 *api.AuthenticationResults) *api.HeaderAnalysis { if email == nil { return nil } analysis := &api.HeaderAnalysis{} // Check for proper MIME structure analysis.HasMimeStructure = api.PtrTo(len(email.Parts) > 0) // Initialize headers map headers := make(map[string]api.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) *api.HeaderCheck { value := email.GetHeaderValue(headerName) present := email.HasHeader(headerName) && value != "" importanceEnum := api.HeaderCheckImportance(importance) check := &api.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 )") } 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 *api.AuthenticationResults) *api.DomainAlignment { alignment := &api.DomainAlignment{ Aligned: api.PtrTo(true), RelaxedAligned: api.PtrTo(true), } // Extract From domain fromAddr := email.GetHeaderValue("From") if fromAddr != "" { domain := h.extractDomain(fromAddr) if domain != "" { alignment.FromDomain = &domain // Extract organizational domain orgDomain := h.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 := h.getOrganizationalDomain(domain) alignment.ReturnPathOrgDomain = &orgDomain } } // Extract DKIM domains from authentication results var dkimDomains []api.DKIMDomainInfo if authResults != nil && authResults.Dkim != nil { for _, dkim := range *authResults.Dkim { if dkim.Domain != nil && *dkim.Domain != "" { domain := *dkim.Domain orgDomain := h.getOrganizationalDomain(domain) dkimDomains = append(dkimDomains, api.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 (h *HeaderAnalyzer) 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) []api.HeaderIssue { var issues []api.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, api.HeaderIssue{ Header: header, Severity: api.HeaderIssueSeverityCritical, Message: fmt.Sprintf("Required header '%s' is missing", header), Advice: api.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, api.HeaderIssue{ Header: "Message-ID", Severity: api.HeaderIssueSeverityMedium, Message: "Message-ID format is invalid", Advice: api.PtrTo("Use proper Message-ID format: "), }) } return issues } // parseReceivedChain extracts the chain of Received headers from an email func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []api.ReceivedHop { if email == nil || email.Header == nil { return nil } receivedHeaders := email.Header["Received"] if len(receivedHeaders) == 0 { return nil } var chain []api.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) *api.ReceivedHop { hop := &api.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 " 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 } } return hop }