// 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 ( "net/mail" "net/textproto" "testing" "git.happydns.org/happyDeliver/internal/api" ) 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": "", "Reply-To": "reply@example.com", }), MessageID: "", 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": "", }), MessageID: "", Date: "Mon, 01 Jan 2024 12:00:00 +0000", Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, minScore: 40, maxScore: 80, }, { 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) { score := analyzer.calculateHeaderScore(tt.email) if score < tt.minScore || score > tt.maxScore { t.Errorf("calculateHeaderScore() = %v, want between %v and %v", score, tt.minScore, tt.maxScore) } }) } } func TestGenerateRequiredHeadersCheck(t *testing.T) { tests := []struct { name string email *EmailMessage expectedStatus api.CheckStatus expectedScore int }{ { name: "All required headers present", email: &EmailMessage{ Header: createHeaderWithFields(map[string]string{ "From": "sender@example.com", "Date": "Mon, 01 Jan 2024 12:00:00 +0000", "Message-ID": "", }), From: &mail.Address{Address: "sender@example.com"}, MessageID: "", Date: "Mon, 01 Jan 2024 12:00:00 +0000", }, expectedStatus: api.CheckStatusPass, expectedScore: 40, }, { name: "Missing all required headers", email: &EmailMessage{ Header: make(mail.Header), }, expectedStatus: api.CheckStatusFail, expectedScore: 0, }, { name: "Missing some required headers", email: &EmailMessage{ Header: createHeaderWithFields(map[string]string{ "From": "sender@example.com", }), }, expectedStatus: api.CheckStatusFail, expectedScore: 0, }, } analyzer := NewHeaderAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { check := analyzer.generateRequiredHeadersCheck(tt.email) if check.Status != tt.expectedStatus { t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) } if check.Score != tt.expectedScore { t.Errorf("Score = %v, want %v", check.Score, tt.expectedScore) } if check.Category != api.Headers { t.Errorf("Category = %v, want %v", check.Category, api.Headers) } }) } } func TestGenerateMessageIDCheck(t *testing.T) { tests := []struct { name string messageID string expectedStatus api.CheckStatus }{ { name: "Valid Message-ID", messageID: "", expectedStatus: api.CheckStatusPass, }, { name: "Invalid Message-ID format", messageID: "invalid-message-id", expectedStatus: api.CheckStatusWarn, }, { name: "Missing Message-ID", messageID: "", expectedStatus: api.CheckStatusFail, }, } analyzer := NewHeaderAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { email := &EmailMessage{ Header: createHeaderWithFields(map[string]string{ "Message-ID": tt.messageID, }), } check := analyzer.generateMessageIDCheck(email) if check.Status != tt.expectedStatus { t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) } if check.Category != api.Headers { t.Errorf("Category = %v, want %v", check.Category, api.Headers) } }) } } func TestGenerateMIMEStructureCheck(t *testing.T) { tests := []struct { name string parts []MessagePart expectedStatus api.CheckStatus }{ { name: "With MIME parts", parts: []MessagePart{ {ContentType: "text/plain", Content: "test"}, {ContentType: "text/html", Content: "

test

"}, }, expectedStatus: api.CheckStatusPass, }, { name: "No MIME parts", parts: []MessagePart{}, expectedStatus: api.CheckStatusWarn, }, } analyzer := NewHeaderAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { email := &EmailMessage{ Header: make(mail.Header), Parts: tt.parts, } check := analyzer.generateMIMEStructureCheck(email) if check.Status != tt.expectedStatus { t.Errorf("Status = %v, want %v", check.Status, tt.expectedStatus) } }) } } func TestGenerateHeaderChecks(t *testing.T) { tests := []struct { name string email *EmailMessage minChecks int }{ { name: "Nil email", email: nil, minChecks: 0, }, { name: "Complete email", 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": "", "Reply-To": "reply@example.com", }), Parts: []MessagePart{{ContentType: "text/plain", Content: "test"}}, }, minChecks: 4, // Required, Recommended, Message-ID, MIME }, } analyzer := NewHeaderAnalyzer() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { checks := analyzer.GenerateHeaderChecks(tt.email) if len(checks) < tt.minChecks { t.Errorf("Got %d checks, want at least %d", len(checks), tt.minChecks) } // Verify all checks have the Headers category for _, check := range checks { if check.Category != api.Headers { t.Errorf("Check %s has category %v, want %v", check.Name, check.Category, api.Headers) } } }) } } // 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 }