diff --git a/internal/analyzer/authentication.go b/internal/analyzer/authentication.go new file mode 100644 index 0000000..45df0a3 --- /dev/null +++ b/internal/analyzer/authentication.go @@ -0,0 +1,511 @@ +// 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" + "regexp" + "strings" + + "git.happydns.org/happyDeliver/internal/api" +) + +// AuthenticationAnalyzer analyzes email authentication results +type AuthenticationAnalyzer struct{} + +// NewAuthenticationAnalyzer creates a new authentication analyzer +func NewAuthenticationAnalyzer() *AuthenticationAnalyzer { + return &AuthenticationAnalyzer{} +} + +// AnalyzeAuthentication extracts and analyzes authentication results from email headers +func (a *AuthenticationAnalyzer) AnalyzeAuthentication(email *EmailMessage) *api.AuthenticationResults { + results := &api.AuthenticationResults{} + + // Parse Authentication-Results headers + authHeaders := email.GetAuthenticationResults() + for _, header := range authHeaders { + a.parseAuthenticationResultsHeader(header, results) + } + + // If no Authentication-Results headers, try to parse legacy headers + if results.Spf == nil { + results.Spf = a.parseLegacySPF(email) + } + + if results.Dkim == nil || len(*results.Dkim) == 0 { + dkimResults := a.parseLegacyDKIM(email) + if len(dkimResults) > 0 { + results.Dkim = &dkimResults + } + } + + return results +} + +// parseAuthenticationResultsHeader parses an Authentication-Results header +// Format: example.com; spf=pass smtp.mailfrom=sender@example.com; dkim=pass header.d=example.com +func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string, results *api.AuthenticationResults) { + // Split by semicolon to get individual results + parts := strings.Split(header, ";") + if len(parts) < 2 { + return + } + + // Skip the authserv-id (first part) + for i := 1; i < len(parts); i++ { + part := strings.TrimSpace(parts[i]) + if part == "" { + continue + } + + // Parse SPF + if strings.HasPrefix(part, "spf=") { + if results.Spf == nil { + results.Spf = a.parseSPFResult(part) + } + } + + // Parse DKIM + if strings.HasPrefix(part, "dkim=") { + dkimResult := a.parseDKIMResult(part) + if dkimResult != nil { + if results.Dkim == nil { + dkimList := []api.AuthResult{*dkimResult} + results.Dkim = &dkimList + } else { + *results.Dkim = append(*results.Dkim, *dkimResult) + } + } + } + + // Parse DMARC + if strings.HasPrefix(part, "dmarc=") { + if results.Dmarc == nil { + results.Dmarc = a.parseDMARCResult(part) + } + } + } +} + +// parseSPFResult parses SPF result from Authentication-Results +// Example: spf=pass smtp.mailfrom=sender@example.com +func (a *AuthenticationAnalyzer) parseSPFResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`spf=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain + domainRe := regexp.MustCompile(`smtp\.mailfrom=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + email := matches[1] + // Extract domain from email + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + +// parseDKIMResult parses DKIM result from Authentication-Results +// Example: dkim=pass header.d=example.com header.s=selector1 +func (a *AuthenticationAnalyzer) parseDKIMResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dkim=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.d or d) + domainRe := regexp.MustCompile(`(?:header\.)?d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (header.s or s) + selectorRe := regexp.MustCompile(`(?:header\.)?s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(part); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + // Extract details + if idx := strings.Index(part, "("); idx != -1 { + endIdx := strings.Index(part[idx:], ")") + if endIdx != -1 { + details := strings.TrimSpace(part[idx+1 : idx+endIdx]) + result.Details = &details + } + } + + return result +} + +// parseDMARCResult parses DMARC result from Authentication-Results +// Example: dmarc=pass action=none header.from=example.com +func (a *AuthenticationAnalyzer) parseDMARCResult(part string) *api.AuthResult { + result := &api.AuthResult{} + + // Extract result (pass, fail, etc.) + re := regexp.MustCompile(`dmarc=(\w+)`) + if matches := re.FindStringSubmatch(part); len(matches) > 1 { + resultStr := strings.ToLower(matches[1]) + result.Result = api.AuthResultResult(resultStr) + } + + // Extract domain (header.from) + domainRe := regexp.MustCompile(`header\.from=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(part); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract details (action, policy, etc.) + var detailsParts []string + actionRe := regexp.MustCompile(`action=([^\s;]+)`) + if matches := actionRe.FindStringSubmatch(part); len(matches) > 1 { + detailsParts = append(detailsParts, fmt.Sprintf("action=%s", matches[1])) + } + + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, " ") + result.Details = &details + } + + return result +} + +// parseLegacySPF attempts to parse SPF from Received-SPF header +func (a *AuthenticationAnalyzer) parseLegacySPF(email *EmailMessage) *api.AuthResult { + receivedSPF := email.Header.Get("Received-SPF") + if receivedSPF == "" { + return nil + } + + result := &api.AuthResult{} + + // Extract result (first word) + parts := strings.Fields(receivedSPF) + if len(parts) > 0 { + resultStr := strings.ToLower(parts[0]) + result.Result = api.AuthResultResult(resultStr) + } + + // Try to extract domain + domainRe := regexp.MustCompile(`(?:envelope-from|sender)=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(receivedSPF); len(matches) > 1 { + email := matches[1] + if idx := strings.Index(email, "@"); idx != -1 { + domain := email[idx+1:] + result.Domain = &domain + } + } + + return result +} + +// parseLegacyDKIM attempts to parse DKIM from DKIM-Signature header +func (a *AuthenticationAnalyzer) parseLegacyDKIM(email *EmailMessage) []api.AuthResult { + var results []api.AuthResult + + // Get all DKIM-Signature headers + dkimHeaders := email.Header[textprotoCanonical("DKIM-Signature")] + for _, dkimHeader := range dkimHeaders { + result := api.AuthResult{ + Result: api.AuthResultResultNone, // We can't determine pass/fail from signature alone + } + + // Extract domain (d=) + domainRe := regexp.MustCompile(`d=([^\s;]+)`) + if matches := domainRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + domain := matches[1] + result.Domain = &domain + } + + // Extract selector (s=) + selectorRe := regexp.MustCompile(`s=([^\s;]+)`) + if matches := selectorRe.FindStringSubmatch(dkimHeader); len(matches) > 1 { + selector := matches[1] + result.Selector = &selector + } + + details := "DKIM signature present (verification status unknown)" + result.Details = &details + + results = append(results, result) + } + + return results +} + +// textprotoCanonical converts a header name to canonical form +func textprotoCanonical(s string) string { + // Simple implementation - capitalize each word + words := strings.Split(s, "-") + for i, word := range words { + if len(word) > 0 { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + return strings.Join(words, "-") +} + +// GetAuthenticationScore calculates the authentication score (0-3 points) +func (a *AuthenticationAnalyzer) GetAuthenticationScore(results *api.AuthenticationResults) float32 { + var score float32 = 0.0 + + // SPF: 1 point for pass, 0.5 for neutral/softfail, 0 for fail + if results.Spf != nil { + switch results.Spf.Result { + case api.AuthResultResultPass: + score += 1.0 + case api.AuthResultResultNeutral, api.AuthResultResultSoftfail: + score += 0.5 + } + } + + // DKIM: 1 point for at least one pass + if results.Dkim != nil && len(*results.Dkim) > 0 { + for _, dkim := range *results.Dkim { + if dkim.Result == api.AuthResultResultPass { + score += 1.0 + break + } + } + } + + // DMARC: 1 point for pass + if results.Dmarc != nil { + switch results.Dmarc.Result { + case api.AuthResultResultPass: + score += 1.0 + } + } + + // Cap at 3 points maximum + if score > 3.0 { + score = 3.0 + } + + return score +} + +// GenerateAuthenticationChecks generates check results for authentication +func (a *AuthenticationAnalyzer) GenerateAuthenticationChecks(results *api.AuthenticationResults) []api.Check { + var checks []api.Check + + // SPF check + if results.Spf != nil { + check := a.generateSPFCheck(results.Spf) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "SPF Record", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No SPF authentication result found", + Severity: api.PtrTo(api.Medium), + Advice: api.PtrTo("Ensure your MTA is configured to check SPF records"), + }) + } + + // DKIM check + if results.Dkim != nil && len(*results.Dkim) > 0 { + for i, dkim := range *results.Dkim { + check := a.generateDKIMCheck(&dkim, i) + checks = append(checks, check) + } + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DKIM Signature", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DKIM signature found", + Severity: api.PtrTo(api.Medium), + Advice: api.PtrTo("Configure DKIM signing for your domain to improve deliverability"), + }) + } + + // DMARC check + if results.Dmarc != nil { + check := a.generateDMARCCheck(results.Dmarc) + checks = append(checks, check) + } else { + checks = append(checks, api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + Status: api.CheckStatusWarn, + Score: 0.0, + Message: "No DMARC authentication result found", + Severity: api.PtrTo(api.Medium), + Advice: api.PtrTo("Implement DMARC policy for your domain"), + }) + } + + return checks +} + +func (a *AuthenticationAnalyzer) generateSPFCheck(spf *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "SPF Record", + } + + switch spf.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "SPF validation passed" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your SPF record is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "SPF validation failed" + check.Severity = api.PtrTo(api.Critical) + check.Advice = api.PtrTo("Fix your SPF record to authorize this sending server") + case api.AuthResultResultSoftfail: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation softfail" + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Review your SPF record configuration") + case api.AuthResultResultNeutral: + check.Status = api.CheckStatusWarn + check.Score = 0.5 + check.Message = "SPF validation neutral" + check.Severity = api.PtrTo(api.Low) + check.Advice = api.PtrTo("Consider tightening your SPF policy") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("SPF validation result: %s", spf.Result) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Review your SPF record configuration") + } + + if spf.Domain != nil { + details := fmt.Sprintf("Domain: %s", *spf.Domain) + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDKIMCheck(dkim *api.AuthResult, index int) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: fmt.Sprintf("DKIM Signature #%d", index+1), + } + + switch dkim.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DKIM signature is valid" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your DKIM signature is properly configured") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DKIM signature validation failed" + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Check your DKIM keys and signing configuration") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DKIM validation result: %s", dkim.Result) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Ensure DKIM signing is enabled and configured correctly") + } + + var detailsParts []string + if dkim.Domain != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Domain: %s", *dkim.Domain)) + } + if dkim.Selector != nil { + detailsParts = append(detailsParts, fmt.Sprintf("Selector: %s", *dkim.Selector)) + } + if len(detailsParts) > 0 { + details := strings.Join(detailsParts, ", ") + check.Details = &details + } + + return check +} + +func (a *AuthenticationAnalyzer) generateDMARCCheck(dmarc *api.AuthResult) api.Check { + check := api.Check{ + Category: api.Authentication, + Name: "DMARC Policy", + } + + switch dmarc.Result { + case api.AuthResultResultPass: + check.Status = api.CheckStatusPass + check.Score = 1.0 + check.Message = "DMARC validation passed" + check.Severity = api.PtrTo(api.Info) + check.Advice = api.PtrTo("Your DMARC policy is properly aligned") + case api.AuthResultResultFail: + check.Status = api.CheckStatusFail + check.Score = 0.0 + check.Message = "DMARC validation failed" + check.Severity = api.PtrTo(api.High) + check.Advice = api.PtrTo("Ensure SPF or DKIM alignment with your From domain") + default: + check.Status = api.CheckStatusWarn + check.Score = 0.0 + check.Message = fmt.Sprintf("DMARC validation result: %s", dmarc.Result) + check.Severity = api.PtrTo(api.Medium) + check.Advice = api.PtrTo("Configure DMARC policy for your domain") + } + + if dmarc.Domain != nil { + details := fmt.Sprintf("Domain: %s", *dmarc.Domain) + check.Details = &details + } + + return check +} diff --git a/internal/analyzer/parser.go b/internal/analyzer/parser.go new file mode 100644 index 0000000..13c012c --- /dev/null +++ b/internal/analyzer/parser.go @@ -0,0 +1,277 @@ +// 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" + "io" + "mime" + "mime/multipart" + "net/mail" + "net/textproto" + "strings" +) + +// EmailMessage represents a parsed email message +type EmailMessage struct { + Header mail.Header + From *mail.Address + To []*mail.Address + Subject string + MessageID string + Date string + ReturnPath string + Parts []MessagePart + RawHeaders string + RawBody string +} + +// MessagePart represents a MIME part of an email +type MessagePart struct { + ContentType string + Encoding string + Content string + IsHTML bool + IsText bool + Boundary string + Parts []MessagePart // For nested multipart messages +} + +// ParseEmail parses an email message from a reader +func ParseEmail(r io.Reader) (*EmailMessage, error) { + msg, err := mail.ReadMessage(r) + if err != nil { + return nil, fmt.Errorf("failed to read email message: %w", err) + } + + email := &EmailMessage{ + Header: msg.Header, + Subject: msg.Header.Get("Subject"), + MessageID: msg.Header.Get("Message-ID"), + Date: msg.Header.Get("Date"), + ReturnPath: msg.Header.Get("Return-Path"), + } + + // Parse From address + if fromStr := msg.Header.Get("From"); fromStr != "" { + from, err := mail.ParseAddress(fromStr) + if err == nil { + email.From = from + } + } + + // Parse To addresses + if toStr := msg.Header.Get("To"); toStr != "" { + toAddrs, err := mail.ParseAddressList(toStr) + if err == nil { + email.To = toAddrs + } + } + + // Build raw headers string + email.RawHeaders = buildRawHeaders(msg.Header) + + // Parse MIME parts + contentType := msg.Header.Get("Content-Type") + if contentType == "" { + // Plain text email without MIME + body, err := io.ReadAll(msg.Body) + if err != nil { + return nil, fmt.Errorf("failed to read email body: %w", err) + } + email.RawBody = string(body) + email.Parts = []MessagePart{ + { + ContentType: "text/plain", + Content: string(body), + IsText: true, + }, + } + } else { + // Parse MIME message + parts, err := parseMIMEParts(msg.Body, contentType) + if err != nil { + return nil, fmt.Errorf("failed to parse MIME parts: %w", err) + } + email.Parts = parts + } + + return email, nil +} + +// parseMIMEParts recursively parses MIME parts +func parseMIMEParts(body io.Reader, contentType string) ([]MessagePart, error) { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, fmt.Errorf("failed to parse media type: %w", err) + } + + var parts []MessagePart + + if strings.HasPrefix(mediaType, "multipart/") { + // Handle multipart messages + boundary := params["boundary"] + if boundary == "" { + return nil, fmt.Errorf("multipart message missing boundary") + } + + mr := multipart.NewReader(body, boundary) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read multipart part: %w", err) + } + + partContentType := part.Header.Get("Content-Type") + if partContentType == "" { + partContentType = "text/plain" + } + + // Check if this part is also multipart + partMediaType, _, _ := mime.ParseMediaType(partContentType) + if strings.HasPrefix(partMediaType, "multipart/") { + // Recursively parse nested multipart + nestedParts, err := parseMIMEParts(part, partContentType) + if err != nil { + return nil, err + } + parts = append(parts, MessagePart{ + ContentType: partContentType, + Encoding: part.Header.Get("Content-Transfer-Encoding"), + Parts: nestedParts, + }) + } else { + // Read the part content + content, err := io.ReadAll(part) + if err != nil { + return nil, fmt.Errorf("failed to read part content: %w", err) + } + + messagePart := MessagePart{ + ContentType: partContentType, + Encoding: part.Header.Get("Content-Transfer-Encoding"), + Content: string(content), + IsHTML: strings.Contains(strings.ToLower(partMediaType), "html"), + IsText: strings.Contains(strings.ToLower(partMediaType), "text"), + } + parts = append(parts, messagePart) + } + } + } else { + // Single part message + content, err := io.ReadAll(body) + if err != nil { + return nil, fmt.Errorf("failed to read body content: %w", err) + } + + parts = []MessagePart{ + { + ContentType: contentType, + Content: string(content), + IsHTML: strings.Contains(strings.ToLower(mediaType), "html"), + IsText: strings.Contains(strings.ToLower(mediaType), "text"), + }, + } + } + + return parts, nil +} + +// buildRawHeaders reconstructs the raw header string +func buildRawHeaders(header mail.Header) string { + var sb strings.Builder + for key, values := range header { + for _, value := range values { + sb.WriteString(fmt.Sprintf("%s: %s\n", key, value)) + } + } + return sb.String() +} + +// GetAuthenticationResults extracts Authentication-Results headers +func (e *EmailMessage) GetAuthenticationResults() []string { + return e.Header[textproto.CanonicalMIMEHeaderKey("Authentication-Results")] +} + +// GetSpamAssassinHeaders extracts SpamAssassin-related headers +func (e *EmailMessage) GetSpamAssassinHeaders() map[string]string { + headers := make(map[string]string) + + // Common SpamAssassin headers + saHeaders := []string{ + "X-Spam-Status", + "X-Spam-Score", + "X-Spam-Flag", + "X-Spam-Level", + "X-Spam-Report", + "X-Spam-Checker-Version", + } + + for _, headerName := range saHeaders { + if value := e.Header.Get(headerName); value != "" { + headers[headerName] = value + } + } + + return headers +} + +// GetTextParts returns all text/plain parts +func (e *EmailMessage) GetTextParts() []MessagePart { + return filterParts(e.Parts, func(p MessagePart) bool { + return p.IsText && !p.IsHTML + }) +} + +// GetHTMLParts returns all text/html parts +func (e *EmailMessage) GetHTMLParts() []MessagePart { + return filterParts(e.Parts, func(p MessagePart) bool { + return p.IsHTML + }) +} + +// filterParts recursively filters message parts +func filterParts(parts []MessagePart, predicate func(MessagePart) bool) []MessagePart { + var result []MessagePart + for _, part := range parts { + if len(part.Parts) > 0 { + // Recursively filter nested parts + result = append(result, filterParts(part.Parts, predicate)...) + } else if predicate(part) { + result = append(result, part) + } + } + return result +} + +// GetHeaderValue safely gets a header value +func (e *EmailMessage) GetHeaderValue(key string) string { + return e.Header.Get(key) +} + +// HasHeader checks if a header exists +func (e *EmailMessage) HasHeader(key string) bool { + return e.Header.Get(key) != "" +} diff --git a/internal/analyzer/parser_test.go b/internal/analyzer/parser_test.go new file mode 100644 index 0000000..571f542 --- /dev/null +++ b/internal/analyzer/parser_test.go @@ -0,0 +1,176 @@ +// 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 ( + "strings" + "testing" +) + +func TestParseEmail_SimplePlainText(t *testing.T) { + rawEmail := `From: sender@example.com +To: recipient@example.com +Subject: Test Email +Message-ID: +Date: Mon, 15 Oct 2025 12:00:00 +0000 + +This is a plain text email body. +` + + email, err := ParseEmail(strings.NewReader(rawEmail)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + if email.From.Address != "sender@example.com" { + t.Errorf("Expected From: sender@example.com, got: %s", email.From.Address) + } + + if email.Subject != "Test Email" { + t.Errorf("Expected Subject: Test Email, got: %s", email.Subject) + } + + if len(email.Parts) != 1 { + t.Fatalf("Expected 1 part, got: %d", len(email.Parts)) + } + + if !email.Parts[0].IsText { + t.Error("Expected part to be text") + } + + if !strings.Contains(email.Parts[0].Content, "plain text email body") { + t.Error("Expected body content not found") + } +} + +func TestParseEmail_MultipartAlternative(t *testing.T) { + rawEmail := `From: sender@example.com +To: recipient@example.com +Subject: Test Multipart Email +Content-Type: multipart/alternative; boundary="boundary123" + +--boundary123 +Content-Type: text/plain; charset=utf-8 + +This is the plain text version. + +--boundary123 +Content-Type: text/html; charset=utf-8 + +

This is the HTML version.

+ +--boundary123-- +` + + email, err := ParseEmail(strings.NewReader(rawEmail)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + if len(email.Parts) != 2 { + t.Fatalf("Expected 2 parts, got: %d", len(email.Parts)) + } + + textParts := email.GetTextParts() + if len(textParts) != 1 { + t.Errorf("Expected 1 text part, got: %d", len(textParts)) + } + + htmlParts := email.GetHTMLParts() + if len(htmlParts) != 1 { + t.Errorf("Expected 1 HTML part, got: %d", len(htmlParts)) + } + + if !strings.Contains(htmlParts[0].Content, "") { + t.Error("Expected HTML content not found") + } +} + +func TestGetAuthenticationResults(t *testing.T) { + rawEmail := `From: sender@example.com +To: recipient@example.com +Subject: Test Email +Authentication-Results: example.com; spf=pass smtp.mailfrom=sender@example.com +Authentication-Results: example.com; dkim=pass header.d=example.com + +Body content. +` + + email, err := ParseEmail(strings.NewReader(rawEmail)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + authResults := email.GetAuthenticationResults() + if len(authResults) != 2 { + t.Errorf("Expected 2 Authentication-Results headers, got: %d", len(authResults)) + } +} + +func TestGetSpamAssassinHeaders(t *testing.T) { + rawEmail := `From: sender@example.com +To: recipient@example.com +Subject: Test Email +X-Spam-Status: No, score=2.3 required=5.0 +X-Spam-Score: 2.3 +X-Spam-Flag: NO + +Body content. +` + + email, err := ParseEmail(strings.NewReader(rawEmail)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + saHeaders := email.GetSpamAssassinHeaders() + if len(saHeaders) != 3 { + t.Errorf("Expected 3 SpamAssassin headers, got: %d", len(saHeaders)) + } + + if saHeaders["X-Spam-Score"] != "2.3" { + t.Errorf("Expected X-Spam-Score: 2.3, got: %s", saHeaders["X-Spam-Score"]) + } +} + +func TestHasHeader(t *testing.T) { + rawEmail := `From: sender@example.com +To: recipient@example.com +Subject: Test Email +Message-ID: + +Body content. +` + + email, err := ParseEmail(strings.NewReader(rawEmail)) + if err != nil { + t.Fatalf("Failed to parse email: %v", err) + } + + if !email.HasHeader("Message-ID") { + t.Error("Expected Message-ID header to exist") + } + + if email.HasHeader("List-Unsubscribe") { + t.Error("Expected List-Unsubscribe header to not exist") + } +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go new file mode 100644 index 0000000..b50def0 --- /dev/null +++ b/internal/api/helpers.go @@ -0,0 +1,27 @@ +// 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 api + +// PtrTo returns a pointer to the provided value +func PtrTo[T any](v T) *T { + return &v +}