Compare commits

...

4 commits

Author SHA1 Message Date
e2ec55e933 chore(deps): update dependency prettier-plugin-svelte to v4 2026-06-06 09:06:58 +00:00
1c2218a779 headers: detect fake reply/forward subject without thread headers
All checks were successful
continuous-integration/drone/push Build is passing
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.
2026-06-06 17:52:22 +09:00
57022129e3 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.
2026-06-06 17:33:06 +09:00
970cbc02a3 bimi: suggest declination record when no valid BIMI record is found
All checks were successful
continuous-integration/drone/push Build is passing
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.
2026-06-06 17:16:48 +09:00
6 changed files with 218 additions and 22 deletions

View file

@ -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",

View file

@ -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 {

View file

@ -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": "<abc@example.com>",
"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": "<abc@example.com>",
"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": "<abc@example.com>",
"Subject": "Re: Your invoice",
"References": "<original@example.com>",
},
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": "<abc@example.com>",
"Subject": "Re: Your invoice",
"In-Reply-To": "<original@example.com>",
},
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": "<abc@example.com>",
"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

29
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -72,6 +72,26 @@
{bimiRecord.error}
</div>
{/if}
{#if !bimiRecord.valid}
<div class="alert alert-info mt-3 mb-0">
<h6 class="alert-heading">
<i class="bi bi-lightbulb me-1"></i>
Explicitly decline BIMI participation
</h6>
<p class="mb-2 small">
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:
</p>
<code class="d-block bg-white rounded p-2 text-break border"
>{bimiRecord.selector}._bimi.{bimiRecord.domain}. IN TXT "v=BIMI1; l=; a="</code
>
<p class="mt-1 mb-0 small text-muted">
Declination record format as defined in §&thinsp;4.3.1 of
<em>draft-brand-indicators-for-message-identification</em>.
</p>
</div>
{/if}
</div>
</div>
{/if}