happyDeliver/pkg/analyzer/headers_test.go
Pierre-Olivier Mercier 1c2218a779
All checks were successful
continuous-integration/drone/push Build is passing
headers: detect fake reply/forward subject without thread headers
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.
2026-06-06 17:52:22 +09:00

1294 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// This file is part of the happyDeliver (R) project.
// Copyright (c) 2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package analyzer
import (
"net/mail"
"net/textproto"
"strings"
"testing"
"git.happydns.org/happyDeliver/internal/model"
)
func TestCalculateHeaderScore(t *testing.T) {
tests := []struct {
name string
email *EmailMessage
minScore int
maxScore int
}{
{
name: "Nil email",
email: nil,
minScore: 0,
maxScore: 0,
},
{
name: "Perfect headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
"Reply-To": "reply@example.com",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 70,
maxScore: 100,
},
{
name: "Missing required headers",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Subject": "Test",
}),
},
minScore: 0,
maxScore: 40,
},
{
name: "Required only, no recommended",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 80,
maxScore: 90,
},
{
name: "Invalid Message-ID format",
email: &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "invalid-message-id",
"Subject": "Test",
"To": "recipient@example.com",
"Reply-To": "reply@example.com",
}),
MessageID: "invalid-message-id",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
},
minScore: 70,
maxScore: 100,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Generate header analysis first
analysis := analyzer.GenerateHeaderAnalysis(tt.email, nil)
score, _ := analyzer.CalculateHeaderScore(analysis)
if score < tt.minScore || score > tt.maxScore {
t.Errorf("CalculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore)
}
})
}
}
func TestCheckHeader(t *testing.T) {
tests := []struct {
name string
headerName string
headerValue string
importance string
expectedPresent bool
expectedValid bool
expectedIssuesLen int
}{
{
name: "Valid Message-ID",
headerName: "Message-ID",
headerValue: "<abc123@example.com>",
importance: "required",
expectedPresent: true,
expectedValid: true,
expectedIssuesLen: 0,
},
{
name: "Invalid Message-ID format",
headerName: "Message-ID",
headerValue: "invalid-message-id",
importance: "required",
expectedPresent: true,
expectedValid: false,
expectedIssuesLen: 1,
},
{
name: "Missing required header",
headerName: "From",
headerValue: "",
importance: "required",
expectedPresent: false,
expectedValid: false,
expectedIssuesLen: 1,
},
{
name: "Missing optional header",
headerName: "Reply-To",
headerValue: "",
importance: "optional",
expectedPresent: false,
expectedValid: false,
expectedIssuesLen: 0,
},
{
name: "Valid Date header",
headerName: "Date",
headerValue: "Mon, 01 Jan 2024 12:00:00 +0000",
importance: "required",
expectedPresent: true,
expectedValid: true,
expectedIssuesLen: 0,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
tt.headerName: tt.headerValue,
}),
}
check := analyzer.checkHeader(email, tt.headerName, tt.importance)
if check.Present != tt.expectedPresent {
t.Errorf("Present = %v, want %v", check.Present, tt.expectedPresent)
}
if check.Valid != nil && *check.Valid != tt.expectedValid {
t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid)
}
if check.Importance == nil {
t.Error("Importance is nil")
} else if string(*check.Importance) != tt.importance {
t.Errorf("Importance = %v, want %v", *check.Importance, tt.importance)
}
issuesLen := 0
if check.Issues != nil {
issuesLen = len(*check.Issues)
}
if issuesLen != tt.expectedIssuesLen {
t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectedIssuesLen)
}
})
}
}
func TestHeaderAnalyzer_IsValidMessageID(t *testing.T) {
tests := []struct {
name string
messageID string
expected bool
}{
{
name: "Valid Message-ID",
messageID: "<abc123@example.com>",
expected: true,
},
{
name: "Valid with complex local part",
messageID: "<complex.id-123_xyz@subdomain.example.com>",
expected: true,
},
{
name: "Missing angle brackets",
messageID: "abc123@example.com",
expected: false,
},
{
name: "Missing @ symbol",
messageID: "<abc123example.com>",
expected: false,
},
{
name: "Empty local part",
messageID: "<@example.com>",
expected: false,
},
{
name: "Empty domain",
messageID: "<abc123@>",
expected: false,
},
{
name: "Multiple @ symbols",
messageID: "<abc@123@example.com>",
expected: false,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.isValidMessageID(tt.messageID)
if result != tt.expected {
t.Errorf("isValidMessageID(%q) = %v, want %v", tt.messageID, result, tt.expected)
}
})
}
}
func TestHeaderAnalyzer_ExtractDomain(t *testing.T) {
tests := []struct {
name string
email string
expected string
}{
{
name: "Simple email",
email: "user@example.com",
expected: "example.com",
},
{
name: "Email with angle brackets",
email: "<user@example.com>",
expected: "example.com",
},
{
name: "Email with display name",
email: "User Name <user@example.com>",
expected: "example.com",
},
{
name: "Email with spaces",
email: " user@example.com ",
expected: "example.com",
},
{
name: "Invalid email",
email: "not-an-email",
expected: "",
},
{
name: "Empty string",
email: "",
expected: "",
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := analyzer.extractDomain(tt.email)
if result != tt.expected {
t.Errorf("extractDomain(%q) = %q, want %q", tt.email, result, tt.expected)
}
})
}
}
func TestAnalyzeDomainAlignment(t *testing.T) {
tests := []struct {
name string
fromHeader string
returnPath string
expectAligned bool
expectIssuesLen int
}{
{
name: "Aligned domains",
fromHeader: "sender@example.com",
returnPath: "bounce@example.com",
expectAligned: true,
expectIssuesLen: 0,
},
{
name: "Misaligned domains",
fromHeader: "sender@example.com",
returnPath: "bounce@different.com",
expectAligned: false,
expectIssuesLen: 1,
},
{
name: "Only From header",
fromHeader: "sender@example.com",
returnPath: "",
expectAligned: true,
expectIssuesLen: 0,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": tt.fromHeader,
"Return-Path": tt.returnPath,
}),
}
alignment := analyzer.analyzeDomainAlignment(email, nil)
if alignment == nil {
t.Fatal("Expected non-nil alignment")
}
if alignment.Aligned == nil {
t.Fatal("Expected non-nil Aligned field")
}
if *alignment.Aligned != tt.expectAligned {
t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectAligned)
}
issuesLen := 0
if alignment.Issues != nil {
issuesLen = len(*alignment.Issues)
}
if issuesLen != tt.expectIssuesLen {
t.Errorf("Issues length = %d, want %d", issuesLen, tt.expectIssuesLen)
}
})
}
}
// Helper function to create mail.Header with specific fields
func createHeaderWithFields(fields map[string]string) mail.Header {
header := make(mail.Header)
for key, value := range fields {
if value != "" {
// Use canonical MIME header key format
canonicalKey := textproto.CanonicalMIMEHeaderKey(key)
header[canonicalKey] = []string{value}
}
}
return header
}
func TestParseReceivedChain(t *testing.T) {
tests := []struct {
name string
receivedHeaders []string
expectedHops int
validateFirst func(*testing.T, *EmailMessage, []model.ReceivedHop)
}{
{
name: "No Received headers",
receivedHeaders: []string{},
expectedHops: 0,
},
{
name: "Single Received header",
receivedHeaders: []string{
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
hop := hops[0]
if hop.From == nil || *hop.From != "mail.example.com" {
t.Errorf("From = %v, want 'mail.example.com'", hop.From)
}
if hop.By == nil || *hop.By != "mx.receiver.com" {
t.Errorf("By = %v, want 'mx.receiver.com'", hop.By)
}
if hop.With == nil || *hop.With != "ESMTPS" {
t.Errorf("With = %v, want 'ESMTPS'", hop.With)
}
if hop.Id == nil || *hop.Id != "ABC123" {
t.Errorf("Id = %v, want 'ABC123'", hop.Id)
}
if hop.Ip == nil || *hop.Ip != "192.0.2.1" {
t.Errorf("Ip = %v, want '192.0.2.1'", hop.Ip)
}
if hop.Timestamp == nil {
t.Error("Timestamp should not be nil")
}
},
},
{
name: "Multiple Received headers",
receivedHeaders: []string{
"from mail1.example.com (mail1.example.com [192.0.2.1]) by mx1.receiver.com with ESMTP id 111; Mon, 01 Jan 2024 12:00:00 +0000",
"from mail2.example.com (mail2.example.com [192.0.2.2]) by mx2.receiver.com with SMTP id 222; Mon, 01 Jan 2024 11:59:00 +0000",
},
expectedHops: 2,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) != 2 {
t.Fatalf("Expected 2 hops, got %d", len(hops))
}
// Check first hop
if hops[0].From == nil || *hops[0].From != "mail1.example.com" {
t.Errorf("First hop From = %v, want 'mail1.example.com'", hops[0].From)
}
// Check second hop
if hops[1].From == nil || *hops[1].From != "mail2.example.com" {
t.Errorf("Second hop From = %v, want 'mail2.example.com'", hops[1].From)
}
},
},
{
name: "IPv6 address",
receivedHeaders: []string{
"from mail.example.com (unknown [IPv6:2607:5300:203:2818::1]) by mx.receiver.com with ESMTPS; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
hop := hops[0]
if hop.Ip == nil {
t.Fatal("IP should not be nil for IPv6 address")
}
// Should strip the "IPv6:" prefix
if *hop.Ip != "2607:5300:203:2818::1" {
t.Errorf("Ip = %v, want '2607:5300:203:2818::1'", *hop.Ip)
}
},
},
{
name: "Multiline Received header",
receivedHeaders: []string{
`from nemunai.re (unknown [IPv6:2607:5300:203:2818::1])
(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256)
(No client certificate requested)
(Authenticated sender: nemunaire)
by djehouty.pomail.fr (Postfix) with ESMTPSA id 1EFD11611EA
for <test-9a9ce364-c394-4fa9-acef-d46ff2f482bf@deliver.happydomain.org>; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`,
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
hop := hops[0]
if hop.From == nil || *hop.From != "nemunai.re" {
t.Errorf("From = %v, want 'nemunai.re'", hop.From)
}
if hop.By == nil || *hop.By != "djehouty.pomail.fr" {
t.Errorf("By = %v, want 'djehouty.pomail.fr'", hop.By)
}
if hop.With == nil {
t.Error("With should not be nil")
} else if *hop.With != "ESMTPSA" {
t.Errorf("With = %q, want 'ESMTPSA'", *hop.With)
}
if hop.Id == nil || *hop.Id != "1EFD11611EA" {
t.Errorf("Id = %v, want '1EFD11611EA'", hop.Id)
}
},
},
{
name: "Received header with minimal information",
receivedHeaders: []string{
"from unknown by localhost",
},
expectedHops: 1,
validateFirst: func(t *testing.T, email *EmailMessage, hops []model.ReceivedHop) {
if len(hops) == 0 {
t.Fatal("Expected at least one hop")
}
hop := hops[0]
if hop.From == nil || *hop.From != "unknown" {
t.Errorf("From = %v, want 'unknown'", hop.From)
}
if hop.By == nil || *hop.By != "localhost" {
t.Errorf("By = %v, want 'localhost'", hop.By)
}
},
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
header := make(mail.Header)
if len(tt.receivedHeaders) > 0 {
header["Received"] = tt.receivedHeaders
}
email := &EmailMessage{
Header: header,
}
chain := analyzer.parseReceivedChain(email)
if len(chain) != tt.expectedHops {
t.Errorf("parseReceivedChain() returned %d hops, want %d", len(chain), tt.expectedHops)
}
if tt.validateFirst != nil {
tt.validateFirst(t, email, chain)
}
})
}
}
func TestParseReceivedHeader(t *testing.T) {
tests := []struct {
name string
receivedValue string
expectFrom *string
expectBy *string
expectWith *string
expectId *string
expectIp *string
expectHasTs bool
}{
{
name: "Complete Received header",
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTPS id ABC123 for <user@receiver.com>; Mon, 01 Jan 2024 12:00:00 +0000",
expectFrom: strPtr("mail.example.com"),
expectBy: strPtr("mx.receiver.com"),
expectWith: strPtr("ESMTPS"),
expectId: strPtr("ABC123"),
expectIp: strPtr("192.0.2.1"),
expectHasTs: true,
},
{
name: "Minimal Received header",
receivedValue: "from sender.example.com by receiver.example.com",
expectFrom: strPtr("sender.example.com"),
expectBy: strPtr("receiver.example.com"),
expectWith: nil,
expectId: nil,
expectIp: nil,
expectHasTs: false,
},
{
name: "Received header with ESMTPA",
receivedValue: "from [192.0.2.50] by mail.example.com with ESMTPA id XYZ789; Tue, 02 Jan 2024 08:30:00 -0500",
expectFrom: strPtr("[192.0.2.50]"),
expectBy: strPtr("mail.example.com"),
expectWith: strPtr("ESMTPA"),
expectId: strPtr("XYZ789"),
expectIp: strPtr("192.0.2.50"),
expectHasTs: true,
},
{
name: "Received header without IP",
receivedValue: "from mail.example.com by mx.receiver.com with SMTP; Wed, 03 Jan 2024 14:20:00 +0000",
expectFrom: strPtr("mail.example.com"),
expectBy: strPtr("mx.receiver.com"),
expectWith: strPtr("SMTP"),
expectId: nil,
expectIp: nil,
expectHasTs: true,
},
{
name: "Postfix local delivery with userid",
receivedValue: "by grunt.ycc.fr (Postfix, from userid 1000) id 67276801A8; Fri, 24 Oct 2025 04:17:25 +0200 (CEST)",
expectFrom: nil,
expectBy: strPtr("grunt.ycc.fr"),
expectWith: nil,
expectId: strPtr("67276801A8"),
expectIp: nil,
expectHasTs: true,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hop := analyzer.parseReceivedHeader(tt.receivedValue)
if hop == nil {
t.Fatal("parseReceivedHeader returned nil")
}
// Check From
if !equalStrPtr(hop.From, tt.expectFrom) {
t.Errorf("From = %v, want %v", ptrToStr(hop.From), ptrToStr(tt.expectFrom))
}
// Check By
if !equalStrPtr(hop.By, tt.expectBy) {
t.Errorf("By = %v, want %v", ptrToStr(hop.By), ptrToStr(tt.expectBy))
}
// Check With
if !equalStrPtr(hop.With, tt.expectWith) {
t.Errorf("With = %v, want %v", ptrToStr(hop.With), ptrToStr(tt.expectWith))
}
// Check Id
if !equalStrPtr(hop.Id, tt.expectId) {
t.Errorf("Id = %v, want %v", ptrToStr(hop.Id), ptrToStr(tt.expectId))
}
// Check Ip
if !equalStrPtr(hop.Ip, tt.expectIp) {
t.Errorf("Ip = %v, want %v", ptrToStr(hop.Ip), ptrToStr(tt.expectIp))
}
// Check Timestamp
if tt.expectHasTs {
if hop.Timestamp == nil {
t.Error("Timestamp should not be nil")
}
}
})
}
}
func TestParseReceivedTLS(t *testing.T) {
tests := []struct {
name string
receivedValue string
expectNil bool
expectVersion *string
expectCipher *string
expectBits *int
expectVerified *bool
}{
{
name: "TLS 1.3 no client certificate",
receivedValue: "from mail.example.com (unknown [IPv6:2001:db8::1]) " +
"(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) " +
"key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) " +
"(No client certificate requested) " +
"by mx.example.org (Postfix) with ESMTPSA id 1EFD11611EA; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
expectVersion: strPtr("TLSv1.3"),
expectCipher: strPtr("TLS_AES_256_GCM_SHA384"),
expectBits: intPtr(256),
expectVerified: nil,
},
{
name: "TLS with verified client certificate",
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) " +
"(using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) " +
"(Client CN \"example\", Issuer \"CA\" (verified OK)) " +
"by mx.receiver.com (Postfix) with ESMTPS id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
expectVersion: strPtr("TLSv1.2"),
expectCipher: strPtr("ECDHE-RSA-AES128-GCM-SHA256"),
expectBits: intPtr(128),
expectVerified: boolPtr(true),
},
{
name: "Plaintext (no TLS)",
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTP id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
normalized := strings.Join(strings.Fields(tt.receivedValue), " ")
tls := parseReceivedTLS(normalized)
if tt.expectNil {
if tls != nil {
t.Fatalf("expected nil TLS info, got %+v", tls)
}
return
}
if tls == nil {
t.Fatal("parseReceivedTLS returned nil")
}
if !equalStrPtr(tls.Version, tt.expectVersion) {
t.Errorf("Version = %v, want %v", ptrToStr(tls.Version), ptrToStr(tt.expectVersion))
}
if !equalStrPtr(tls.Cipher, tt.expectCipher) {
t.Errorf("Cipher = %v, want %v", ptrToStr(tls.Cipher), ptrToStr(tt.expectCipher))
}
if (tls.Bits == nil) != (tt.expectBits == nil) || (tls.Bits != nil && *tls.Bits != *tt.expectBits) {
t.Errorf("Bits = %v, want %v", tls.Bits, tt.expectBits)
}
if (tls.Verified == nil) != (tt.expectVerified == nil) || (tls.Verified != nil && *tls.Verified != *tt.expectVerified) {
t.Errorf("Verified = %v, want %v", tls.Verified, tt.expectVerified)
}
})
}
}
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
analyzer := NewHeaderAnalyzer()
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": "sender@example.com",
"To": "recipient@example.com",
"Subject": "Test",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc123@example.com>",
}),
MessageID: "<abc123@example.com>",
Date: "Mon, 01 Jan 2024 12:00:00 +0000",
Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}},
}
// Add Received headers
email.Header["Received"] = []string{
"from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com with ESMTP id ABC123; Mon, 01 Jan 2024 12:00:00 +0000",
"from relay.example.com (relay.example.com [192.0.2.2]) by mail.example.com with SMTP id DEF456; Mon, 01 Jan 2024 11:59:00 +0000",
}
analysis := analyzer.GenerateHeaderAnalysis(email, nil)
if analysis == nil {
t.Fatal("GenerateHeaderAnalysis returned nil")
}
if analysis.ReceivedChain == nil {
t.Fatal("ReceivedChain should not be nil")
}
chain := *analysis.ReceivedChain
if len(chain) != 2 {
t.Fatalf("Expected 2 hops in ReceivedChain, got %d", len(chain))
}
// Check first hop
if chain[0].From == nil || *chain[0].From != "mail.example.com" {
t.Errorf("First hop From = %v, want 'mail.example.com'", chain[0].From)
}
// Check second hop
if chain[1].From == nil || *chain[1].From != "relay.example.com" {
t.Errorf("Second hop From = %v, want 'relay.example.com'", chain[1].From)
}
}
func TestHeaderAnalyzer_ParseEmailDate(t *testing.T) {
tests := []struct {
name string
dateStr string
expectError bool
expectYear int
expectMonth int
expectDay int
}{
{
name: "RFC1123Z format",
dateStr: "Mon, 02 Jan 2006 15:04:05 -0700",
expectError: false,
expectYear: 2006,
expectMonth: 1,
expectDay: 2,
},
{
name: "RFC1123 format",
dateStr: "Mon, 02 Jan 2006 15:04:05 MST",
expectError: false,
expectYear: 2006,
expectMonth: 1,
expectDay: 2,
},
{
name: "Single digit day",
dateStr: "Mon, 2 Jan 2006 15:04:05 -0700",
expectError: false,
expectYear: 2006,
expectMonth: 1,
expectDay: 2,
},
{
name: "Without day of week",
dateStr: "2 Jan 2006 15:04:05 -0700",
expectError: false,
expectYear: 2006,
expectMonth: 1,
expectDay: 2,
},
{
name: "With timezone name in parentheses",
dateStr: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)",
expectError: false,
expectYear: 2024,
expectMonth: 1,
expectDay: 1,
},
{
name: "With timezone name in parentheses 2",
dateStr: "Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
expectError: false,
expectYear: 2025,
expectMonth: 10,
expectDay: 19,
},
{
name: "With CEST timezone",
dateStr: "Fri, 24 Oct 2025 04:17:25 +0200 (CEST)",
expectError: false,
expectYear: 2025,
expectMonth: 10,
expectDay: 24,
},
{
name: "Invalid date format",
dateStr: "not a date",
expectError: true,
},
{
name: "Empty string",
dateStr: "",
expectError: true,
},
{
name: "ISO 8601 format (should fail)",
dateStr: "2024-01-01T12:00:00Z",
expectError: true,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := analyzer.parseEmailDate(tt.dateStr)
if tt.expectError {
if err == nil {
t.Errorf("parseEmailDate(%q) expected error, got nil", tt.dateStr)
}
} else {
if err != nil {
t.Errorf("parseEmailDate(%q) unexpected error: %v", tt.dateStr, err)
return
}
if result.Year() != tt.expectYear {
t.Errorf("Year = %d, want %d", result.Year(), tt.expectYear)
}
if int(result.Month()) != tt.expectMonth {
t.Errorf("Month = %d, want %d", result.Month(), tt.expectMonth)
}
if result.Day() != tt.expectDay {
t.Errorf("Day = %d, want %d", result.Day(), tt.expectDay)
}
}
})
}
}
func TestCheckHeader_DateValidation(t *testing.T) {
tests := []struct {
name string
dateValue string
expectedValid bool
expectedIssuesLen int
}{
{
name: "Valid RFC1123Z date",
dateValue: "Mon, 02 Jan 2006 15:04:05 -0700",
expectedValid: true,
expectedIssuesLen: 0,
},
{
name: "Valid date with timezone name",
dateValue: "Mon, 01 Jan 2024 12:00:00 +0000 (UTC)",
expectedValid: true,
expectedIssuesLen: 0,
},
{
name: "Invalid date format",
dateValue: "2024-01-01",
expectedValid: false,
expectedIssuesLen: 1,
},
{
name: "Invalid date string",
dateValue: "not a date",
expectedValid: false,
expectedIssuesLen: 1,
},
{
name: "Empty date",
dateValue: "",
expectedValid: false,
expectedIssuesLen: 1,
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"Date": tt.dateValue,
}),
}
check := analyzer.checkHeader(email, "Date", "required")
if check.Valid != nil && *check.Valid != tt.expectedValid {
t.Errorf("Valid = %v, want %v", *check.Valid, tt.expectedValid)
}
issuesLen := 0
if check.Issues != nil {
issuesLen = len(*check.Issues)
}
if issuesLen != tt.expectedIssuesLen {
t.Errorf("Issues length = %d, want %d (issues: %v)", issuesLen, tt.expectedIssuesLen, check.Issues)
}
})
}
}
func TestHasReplyPrefix(t *testing.T) {
tests := []struct {
subject string
expected bool
}{
// Positive cases
{"Re: Hello", true},
{"RE: Hello", true},
{"re: Hello", true},
{"Fwd: Hello", true},
{"FWD: Hello", true},
{"fw: Hello", true},
{"FW: Hello", true},
{"Aw: Hallo", true},
{"WG: Weitergeleitet", true},
{"Sv: Hej", true},
{"Vs: Vastaus", true},
{"Ref: something", true},
{"Rép: Bonjour", true},
{"TR: Transféré", true},
{"Odp: Odpowiedź", true},
{"Ynt: Yanıt", true},
{"Res: Resposta", true},
{"Enc: Reenviado", true},
{"Vl: Verwijzing", true},
{"Antw: Antwoord", true},
{"Rv: Svar", true},
// Negative cases
{"Hello", false},
{"", false},
{"react: something", false},
{"reference: check this", false},
{"Resources available", false},
{"Friendly reminder", false},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.subject, func(t *testing.T) {
result := analyzer.hasReplyPrefix(tt.subject)
if result != tt.expected {
t.Errorf("hasReplyPrefix(%q) = %v, want %v", tt.subject, result, tt.expected)
}
})
}
}
func TestFindHeaderIssues_FakeReply(t *testing.T) {
tests := []struct {
name string
headers map[string]string
expectIssueType string // non-empty means we expect an issue containing this substring
}{
{
name: "Re: subject without thread headers",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Re: Your invoice",
},
expectIssueType: "References or In-Reply-To",
},
{
name: "Fwd: subject without thread headers",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Fwd: Important update",
},
expectIssueType: "References or In-Reply-To",
},
{
name: "Re: subject with References header - no issue",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Re: Your invoice",
"References": "<original@example.com>",
},
expectIssueType: "",
},
{
name: "Re: subject with In-Reply-To only - no issue",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Re: Your invoice",
"In-Reply-To": "<original@example.com>",
},
expectIssueType: "",
},
{
name: "Normal subject without thread headers - no issue",
headers: map[string]string{
"From": "sender@example.com",
"Date": "Mon, 01 Jan 2024 12:00:00 +0000",
"Message-ID": "<abc@example.com>",
"Subject": "Your invoice",
},
expectIssueType: "",
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(tt.headers),
}
issues := analyzer.findHeaderIssues(email)
found := false
for _, issue := range issues {
if strings.Contains(issue.Message, tt.expectIssueType) {
found = true
break
}
}
if tt.expectIssueType != "" && !found {
t.Errorf("expected issue containing %q, but none found (issues: %v)", tt.expectIssueType, issues)
}
if tt.expectIssueType == "" {
for _, issue := range issues {
if strings.Contains(issue.Message, "References or In-Reply-To") {
t.Errorf("unexpected fake-reply issue found: %s", issue.Message)
}
}
}
})
}
}
// Helper functions for testing
func strPtr(s string) *string {
return &s
}
func boolPtr(b bool) *bool {
return &b
}
func ptrToStr(p *string) string {
if p == nil {
return "<nil>"
}
return *p
}
func equalStrPtr(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func TestAnalyzeDomainAlignment_WithDKIM(t *testing.T) {
tests := []struct {
name string
fromHeader string
returnPath string
dkimDomains []string
expectStrictAligned bool
expectRelaxedAligned bool
expectIssuesContain string
}{
{
name: "DKIM strict alignment with From domain",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "DKIM relaxed alignment only",
fromHeader: "sender@mail.example.com",
returnPath: "",
dkimDomains: []string{"example.com"},
expectStrictAligned: false,
expectRelaxedAligned: true,
expectIssuesContain: "relaxed alignment",
},
{
name: "DKIM no alignment",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"different.com"},
expectStrictAligned: false,
expectRelaxedAligned: false,
expectIssuesContain: "do not align",
},
{
name: "Multiple DKIM signatures - one aligns",
fromHeader: "sender@example.com",
returnPath: "",
dkimDomains: []string{"different.com", "example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "Return-Path misaligned but DKIM aligned",
fromHeader: "sender@example.com",
returnPath: "bounce@different.com",
dkimDomains: []string{"example.com"},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "Return-Path",
},
{
name: "Return-Path aligned, no DKIM",
fromHeader: "sender@example.com",
returnPath: "bounce@example.com",
dkimDomains: []string{},
expectStrictAligned: true,
expectRelaxedAligned: true,
expectIssuesContain: "",
},
{
name: "Both Return-Path and DKIM misaligned",
fromHeader: "sender@example.com",
returnPath: "bounce@other.com",
dkimDomains: []string{"different.com"},
expectStrictAligned: false,
expectRelaxedAligned: false,
expectIssuesContain: "do not",
},
}
analyzer := NewHeaderAnalyzer()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
email := &EmailMessage{
Header: createHeaderWithFields(map[string]string{
"From": tt.fromHeader,
"Return-Path": tt.returnPath,
}),
}
// Create authentication results with DKIM signatures
var authResults *model.AuthenticationResults
if len(tt.dkimDomains) > 0 {
dkimResults := make([]model.AuthResult, 0, len(tt.dkimDomains))
for _, domain := range tt.dkimDomains {
dkimResults = append(dkimResults, model.AuthResult{
Result: model.AuthResultResultPass,
Domain: &domain,
})
}
authResults = &model.AuthenticationResults{
Dkim: &dkimResults,
}
}
alignment := analyzer.analyzeDomainAlignment(email, authResults)
if alignment == nil {
t.Fatal("Expected non-nil alignment")
}
if alignment.Aligned == nil {
t.Fatal("Expected non-nil Aligned field")
}
if *alignment.Aligned != tt.expectStrictAligned {
t.Errorf("Aligned = %v, want %v", *alignment.Aligned, tt.expectStrictAligned)
}
if alignment.RelaxedAligned == nil {
t.Fatal("Expected non-nil RelaxedAligned field")
}
if *alignment.RelaxedAligned != tt.expectRelaxedAligned {
t.Errorf("RelaxedAligned = %v, want %v", *alignment.RelaxedAligned, tt.expectRelaxedAligned)
}
// Check DKIM domains are populated
if len(tt.dkimDomains) > 0 {
if alignment.DkimDomains == nil {
t.Error("Expected DkimDomains to be populated")
} else if len(*alignment.DkimDomains) != len(tt.dkimDomains) {
t.Errorf("Expected %d DKIM domains, got %d", len(tt.dkimDomains), len(*alignment.DkimDomains))
}
}
// Check issues contain expected string
if tt.expectIssuesContain != "" {
if alignment.Issues == nil || len(*alignment.Issues) == 0 {
t.Errorf("Expected issues to contain '%s', but no issues found", tt.expectIssuesContain)
} else {
found := false
for _, issue := range *alignment.Issues {
if strings.Contains(strings.ToLower(issue), strings.ToLower(tt.expectIssuesContain)) {
found = true
break
}
}
if !found {
t.Errorf("Expected issues to contain '%s', but found: %v", tt.expectIssuesContain, *alignment.Issues)
}
}
}
})
}
}