From 1c2218a779b8132fd1b9269c57268d0e4b382b12 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 17:49:57 +0900 Subject: [PATCH] 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. --- pkg/analyzer/headers.go | 44 +++++++++++ pkg/analyzer/headers_test.go | 140 +++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/pkg/analyzer/headers.go b/pkg/analyzer/headers.go index 448de57..9e5853e 100644 --- a/pkg/analyzer/headers.go +++ b/pkg/analyzer/headers.go @@ -589,9 +589,53 @@ func (h *HeaderAnalyzer) findHeaderIssues(email *EmailMessage) []model.HeaderIss }) } + // Check for fake reply/forward: Subject has Re:/Fwd: prefix but no thread headers + subject := email.GetHeaderValue("Subject") + if h.hasReplyPrefix(subject) && !email.HasHeader("References") && !email.HasHeader("In-Reply-To") { + issues = append(issues, model.HeaderIssue{ + Header: "Subject", + Severity: model.HeaderIssueSeverityHigh, + Message: "Subject indicates a reply or forward but no References or In-Reply-To header is present", + Advice: utils.PtrTo("Remove the Re:/Fwd: prefix from the subject, or add References/In-Reply-To headers if this is a genuine reply"), + }) + } + return issues } +// hasReplyPrefix reports whether a subject line starts with a reply or forward prefix. +func (h *HeaderAnalyzer) hasReplyPrefix(subject string) bool { + // Normalize: collapse leading whitespace and make comparison case-insensitive + s := strings.ToLower(strings.TrimSpace(subject)) + + prefixes := []string{ + "re:", // English / universal + "fwd:", // English forward + "fw:", // English forward (short) + "aw:", // German Antwort + "wg:", // German Weitergeleitet + "sv:", // Scandinavian Svar + "vs:", // Finnish Vastaus / Norwegian + "ref:", // Some clients + "rép:", // French Réponse + "tr:", // French Transfert + "odp:", // Polish Odpowiedź + "ynt:", // Turkish Yanıt + "res:", // Portuguese/Spanish Resposta/Respuesta + "enc:", // Spanish Enviado/Reenviado + "vl:", // Dutch Verwijzing + "antw:", // Dutch Antwoord + "rv:", // Norwegian/Swedish + } + + for _, p := range prefixes { + if strings.HasPrefix(s, p) { + return true + } + } + return false +} + // parseReceivedChain extracts the chain of Received headers from an email func (h *HeaderAnalyzer) parseReceivedChain(email *EmailMessage) []model.ReceivedHop { if email == nil || email.Header == nil { diff --git a/pkg/analyzer/headers_test.go b/pkg/analyzer/headers_test.go index 7b453fa..8426c58 100644 --- a/pkg/analyzer/headers_test.go +++ b/pkg/analyzer/headers_test.go @@ -974,6 +974,146 @@ func TestCheckHeader_DateValidation(t *testing.T) { } } +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": "", + "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": "", + "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": "", + "Subject": "Re: Your invoice", + "References": "", + }, + 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": "", + "Subject": "Re: Your invoice", + "In-Reply-To": "", + }, + 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": "", + "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