diff --git a/api/openapi.yaml b/api/openapi.yaml index 6012ba0..c0acfab 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -589,6 +589,14 @@ components: type: string format: date-time description: When this hop occurred + ip: + type: string + description: IP address of the sending server (IPv4 or IPv6) + example: "192.0.2.1" + reverse: + type: string + description: Reverse DNS (PTR record) for the IP address + example: "mail.example.com" DomainAlignment: type: object diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 4364218..57973b1 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -23,7 +23,10 @@ package analyzer import ( "fmt" + "net" + "regexp" "strings" + "time" "git.happydns.org/happyDeliver/internal/api" ) @@ -209,6 +212,12 @@ func (h *HeaderAnalyzer) GenerateHeaderAnalysis(email *EmailMessage) *api.Header analysis.Headers = &headers + // Received chain + receivedChain := h.parseReceivedChain(email) + if len(receivedChain) > 0 { + analysis.ReceivedChain = &receivedChain + } + // Domain alignment domainAlignment := h.analyzeDomainAlignment(email) if domainAlignment != nil { @@ -356,3 +365,113 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []api.HeaderIssue 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), " ") + + // Extract "from" field + 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 + withRegex := regexp.MustCompile(`(?i)by\s+[^\s(]+[^;]*?\s+with\s+([A-Z0-9]+)`) + if matches := withRegex.FindStringSubmatch(normalized); len(matches) > 1 { + with := matches[1] + hop.With = &with + } + + // Extract "id" field + idRegex := regexp.MustCompile(`(?i)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]) + + // Remove timezone name in parentheses if present + timestampStr = regexp.MustCompile(`\s*\([^)]+\)\s*$`).ReplaceAllString(timestampStr, "") + + // 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, timestampStr); err == nil { + hop.Timestamp = &parsedTime + break + } + } + } + + return hop +} diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 6840b0f..46b4a71 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -25,6 +25,8 @@ import ( "net/mail" "net/textproto" "testing" + + "git.happydns.org/happyDeliver/internal/api" ) func TestCalculateHeaderScore(t *testing.T) { @@ -395,3 +397,341 @@ func createHeaderWithFields(fields map[string]string) mail.Header { } return header } + +func TestParseReceivedChain(t *testing.T) { + tests := []struct { + name string + receivedHeaders []string + expectedHops int + validateFirst func(*testing.T, *EmailMessage, []api.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 ; Mon, 01 Jan 2024 12:00:00 +0000", + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.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 []api.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 []api.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 ; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)`, + }, + expectedHops: 1, + validateFirst: func(t *testing.T, email *EmailMessage, hops []api.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 []api.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 ; 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, + }, + } + + 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 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": "", + }), + MessageID: "", + 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) + + 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) + } +} + +// Helper functions for testing +func strPtr(s string) *string { + return &s +} + +func ptrToStr(p *string) string { + if p == nil { + return "" + } + 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 +} diff --git a/web/src/lib/components/BlacklistCard.svelte b/web/src/lib/components/BlacklistCard.svelte index 1e5fd2a..a3dd010 100644 --- a/web/src/lib/components/BlacklistCard.svelte +++ b/web/src/lib/components/BlacklistCard.svelte @@ -1,15 +1,17 @@
@@ -32,6 +34,10 @@
+ {#if receivedChain} + + {/if} +
{#each Object.entries(blacklists) as [ip, checks]}
diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte new file mode 100644 index 0000000..701feee --- /dev/null +++ b/web/src/lib/components/EmailPathCard.svelte @@ -0,0 +1,41 @@ + + +{#if receivedChain && receivedChain.length > 0} +
+
Email Path (Received Chain)
+
+ {#each receivedChain as hop, i} +
+
+
+ {receivedChain.length - i} + {hop.reverse || '-'} ({hop.ip}) → {hop.by || 'Unknown'} +
+ {hop.timestamp ? new Intl.DateTimeFormat('default', { dateStyle: 'long', 'timeStyle': 'short' }).format(new Date(hop.timestamp)) : '-'} +
+ {#if hop.with || hop.id} +

+ {#if hop.with} + Protocol: {hop.with} + {/if} + {#if hop.id} + ID: {hop.id} + {/if} + {#if hop.from} + Helo: {hop.from} + {/if} +

+ {/if} +
+ {/each} +
+
+{/if} diff --git a/web/src/lib/components/HeaderAnalysisCard.svelte b/web/src/lib/components/HeaderAnalysisCard.svelte index 9a7857c..5979a66 100644 --- a/web/src/lib/components/HeaderAnalysisCard.svelte +++ b/web/src/lib/components/HeaderAnalysisCard.svelte @@ -160,34 +160,5 @@
{/if} - - {#if headerAnalysis.received_chain && headerAnalysis.received_chain.length > 0} -
-
Email Path (Received Chain)
-
- {#each headerAnalysis.received_chain as hop, i} -
-
-
- {i + 1} - {hop.from || 'Unknown'} → {hop.by || 'Unknown'} -
- {hop.timestamp || '-'} -
- {#if hop.with || hop.id} -

- {#if hop.with} - Protocol: {hop.with} - {/if} - {#if hop.id} - ID: {hop.id} - {/if} -

- {/if} -
- {/each} -
-
- {/if}
diff --git a/web/src/routes/test/[test]/+page.svelte b/web/src/routes/test/[test]/+page.svelte index c80cd0b..112ff10 100644 --- a/web/src/routes/test/[test]/+page.svelte +++ b/web/src/routes/test/[test]/+page.svelte @@ -173,6 +173,7 @@ blacklists={report.blacklists} blacklistGrade={report.summary?.blacklist_grade} blacklistScore={report.summary?.blacklist_score} + receivedChain={report.header_analysis?.received_chain} />