From 4bbba66a81089e55bafb37bfb25b6a698ce5c9e7 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 24 Oct 2025 10:12:43 +0700 Subject: [PATCH] Handle local postfix delivery --- pkg/analyzer/headers.go | 25 ++++++++++++++------- pkg/analyzer/headers_test.go | 10 +++++++++ web/src/lib/components/EmailPathCard.svelte | 16 ++++++++----- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 954f229..854841c 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -450,11 +450,18 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received // 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 + // 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 @@ -466,14 +473,16 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *api.Received // 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]+)`) + // 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 - idRegex := regexp.MustCompile(`(?i)id\s+([^\s;]+)`) + // 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 diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 46b4a71..744c16a 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -619,6 +619,16 @@ func TestParseReceivedHeader(t *testing.T) { 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() diff --git a/web/src/lib/components/EmailPathCard.svelte b/web/src/lib/components/EmailPathCard.svelte index b70e427..c8b9a67 100644 --- a/web/src/lib/components/EmailPathCard.svelte +++ b/web/src/lib/components/EmailPathCard.svelte @@ -17,20 +17,26 @@
{receivedChain.length - i} - {hop.reverse || '-'} ({hop.ip}) → {hop.by || 'Unknown'} + {hop.reverse || '-'}{#if hop.ip} ({hop.ip}){/if} → {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} + + Protocol: {hop.with} + {/if} {#if hop.id} - ID: {hop.id} + + ID: {hop.id} + {/if} {#if hop.from} - Helo: {hop.from} + + Helo: {hop.from} + {/if}

{/if}