From 970cbc02a3577843700e7522bc8bb462ebc6ca51 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 17:14:41 +0900 Subject: [PATCH 1/4] bimi: suggest declination record when no valid BIMI record is found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show an informational tip with a ready-to-copy declination record (§4.3.1 of draft-brand-indicators-for-message-identification) so users who do not intend to publish a logo can explicitly opt out and prevent mail clients from falling back to a parent-domain record. --- .../lib/components/BimiRecordDisplay.svelte | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/web/src/lib/components/BimiRecordDisplay.svelte b/web/src/lib/components/BimiRecordDisplay.svelte index 889e24f..8d21b1f 100644 --- a/web/src/lib/components/BimiRecordDisplay.svelte +++ b/web/src/lib/components/BimiRecordDisplay.svelte @@ -72,6 +72,26 @@ {bimiRecord.error} {/if} + {#if !bimiRecord.valid} +
+
+ + Explicitly decline BIMI participation +
+

+ If you do not intend to publish a brand logo, you can add a declination + record to signal that this domain deliberately opts out of BIMI. This + prevents mail clients from falling back to a parent-domain record: +

+ {bimiRecord.selector}._bimi.{bimiRecord.domain}. IN TXT "v=BIMI1; l=; a=" +

+ Declination record format as defined in § 4.3.1 of + draft-brand-indicators-for-message-identification. +

+
+ {/if} {/if} From 57022129e38613a0788303a02ea615952c8d45c4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 17:30:24 +0900 Subject: [PATCH 2/4] content: fix false-positive suspicious URL detection for email addresses in link text The domain regex in hasDomainMisalignment matched local-parts like "john.doe" in "john.doe@example.com" as if they were domain names, causing legitimate mailto and http links to be incorrectly flagged. Normalize email addresses in link text to their domain part before applying the regex. --- pkg/analyzer/content.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/analyzer/content.go b/pkg/analyzer/content.go index 06f8ddf..3e29a7a 100644 --- a/pkg/analyzer/content.go +++ b/pkg/analyzer/content.go @@ -501,6 +501,11 @@ func (c *ContentAnalyzer) hasDomainMisalignment(href, linkText string) bool { return false } + // Replace email addresses with just their domain part to avoid false positives + // e.g. "john.doe@example.com" → "example.com" so local-part dots don't look like domains + emailAddrRegex := regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@([a-z0-9.\-]+\.[a-z]{2,})`) + linkText = emailAddrRegex.ReplaceAllString(linkText, "$1") + // Common generic link texts that shouldn't trigger warnings genericTexts := []string{ "click here", "read more", "learn more", "download", "subscribe", From 1c2218a779b8132fd1b9269c57268d0e4b382b12 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 6 Jun 2026 17:49:57 +0900 Subject: [PATCH 3/4] 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 From e2ec55e9334f91049c4d4556815f2ab778bc7512 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 6 Jun 2026 09:06:58 +0000 Subject: [PATCH 4/4] chore(deps): update dependency prettier-plugin-svelte to v4 --- web/package-lock.json | 29 ++++++++--------------------- web/package.json | 2 +- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 27e6fc1..f9cf9f1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -25,7 +25,7 @@ "eslint-plugin-svelte": "^3.12.4", "globals": "^17.0.0", "prettier": "^3.6.2", - "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-svelte": "^4.0.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", "typescript": "^6.0.0", @@ -4007,14 +4007,17 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.2.tgz", - "integrity": "sha512-ItFouLvzSFE3ulNl4DKoWM3BGcbDCNVpIyy/Y3F2gC3aNiGLxtFUdffVqO5Z5hhYG+DFT5KULWaxmeFFpdbvaQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.0.tgz", + "integrity": "sha512-YZkhA2Q9oOerFFG9tq+2f98WYT7Z2JgrybJrAyrB78jpsH9i/DdgplXemehuFPgsldetFNCcR/yCcYlDjPy94Q==", "dev": true, "license": "MIT", + "engines": { + "node": ">=20" + }, "peerDependencies": { "prettier": "^3.0.0", - "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + "svelte": "^5.0.0" } }, "node_modules/punycode": { @@ -5035,22 +5038,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web/package.json b/web/package.json index 90b545e..2eeaa52 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "eslint-plugin-svelte": "^3.12.4", "globals": "^17.0.0", "prettier": "^3.6.2", - "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-svelte": "^4.0.0", "svelte": "^5.39.5", "svelte-check": "^4.3.2", "typescript": "^6.0.0",