@@ -110,10 +110,44 @@ func hasSeenFlag(flags []imap.Flag) bool {
return slices.Contains(flags, imap.FlagSeen)
}
+// normalizeGmailAddress canonicalizes a Gmail address by stripping the "+tag"
+// subaddress and removing dots from the local part. Gmail treats
+// "u.s.e.r+tag@gmail.com" and "user@gmail.com" as the same mailbox.
+func normalizeGmailAddress(addr string) string {
+ at := strings.LastIndex(addr, "@")
+ if at < 0 {
+ return addr
+ }
+ local, domain := addr[:at], addr[at:]
+ if plus := strings.Index(local, "+"); plus >= 0 {
+ local = local[:plus]
+ }
+ local = strings.ReplaceAll(local, ".", "")
+ return local + domain
+}
+
+// addressMatches reports whether candidate matches the configured fetch email.
+// For Gmail accounts, subaddressed forms ("local+tag@gmail.com") and dotted
+// forms ("l.o.c.a.l@gmail.com") also match.
+// fetchEmail must already be lowercased and trimmed.
+func addressMatches(candidate, fetchEmail string, account *config.Account) bool {
+ candidate = strings.ToLower(strings.TrimSpace(candidate))
+ if candidate == "" || fetchEmail == "" {
+ return false
+ }
+ if candidate == fetchEmail {
+ return true
+ }
+ if account != nil && strings.EqualFold(account.ServiceProvider, "gmail") {
+ return normalizeGmailAddress(candidate) == normalizeGmailAddress(fetchEmail)
+ }
+ return false
+}
+
// deliveryHeadersMatch checks if any of the Delivered-To, X-Forwarded-To, or
// X-Original-To headers contain the given email address. This catches
// auto-forwarded emails where the envelope To/Cc don't match the local account.
-func deliveryHeadersMatch(data []byte, fetchEmail string) bool {
+func deliveryHeadersMatch(data []byte, fetchEmail string, account *config.Account) bool {
if len(data) == 0 {
return false
}
@@ -125,7 +159,7 @@ func deliveryHeadersMatch(data []byte, fetchEmail string) bool {
}
for _, key := range []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"} {
for _, val := range headers.Values(key) {
- if strings.EqualFold(strings.TrimSpace(val), fetchEmail) {
+ if addressMatches(val, fetchEmail, account) {
return true
}
}
@@ -469,12 +503,12 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
if len(msg.Envelope.From) > 0 {
senderEmail = msg.Envelope.From[0].Addr()
}
- if strings.EqualFold(strings.TrimSpace(senderEmail), fetchEmail) {
+ if addressMatches(senderEmail, fetchEmail, account) {
matched = true
}
} else {
for _, r := range toAddrList {
- if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
+ if addressMatches(r, fetchEmail, account) {
matched = true
break
}
@@ -482,7 +516,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
// Check delivery headers for auto-forwarded emails
if !matched {
headerData := msg.FindBodySection(deliveryHeaderSection)
- matched = deliveryHeadersMatch(headerData, fetchEmail)
+ matched = deliveryHeadersMatch(headerData, fetchEmail, account)
}
}
@@ -1485,13 +1519,13 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email,
// For archive/All Mail, match emails where user is sender OR recipient
matched := false
// Check if user is the sender
- if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
+ if addressMatches(fromAddr, fetchEmail, account) {
matched = true
}
// Check if user is a recipient
if !matched {
for _, r := range toAddrList {
- if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
+ if addressMatches(r, fetchEmail, account) {
matched = true
break
}
@@ -1500,7 +1534,7 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email,
// Check delivery headers for auto-forwarded emails
if !matched {
headerData := msg.FindBodySection(deliveryHeaderSection)
- matched = deliveryHeadersMatch(headerData, fetchEmail)
+ matched = deliveryHeadersMatch(headerData, fetchEmail, account)
}
if !matched {
@@ -0,0 +1,62 @@
+package fetcher
+
+import (
+ "testing"
+
+ "github.com/floatpane/matcha/config"
+)
+
+func TestAddressMatches(t *testing.T) {
+ gmail := &config.Account{ServiceProvider: "gmail"}
+ custom := &config.Account{ServiceProvider: "custom"}
+
+ cases := []struct {
+ name string
+ candidate string
+ fetch string
+ account *config.Account
+ want bool
+ }{
+ {"exact match", "user@gmail.com", "user@gmail.com", gmail, true},
+ {"case insensitive", "User@Gmail.com", "user@gmail.com", gmail, true},
+ {"whitespace", " user@gmail.com ", "user@gmail.com", gmail, true},
+ {"gmail subaddress matches", "user+work@gmail.com", "user@gmail.com", gmail, true},
+ {"gmail subaddress on configured side", "user@gmail.com", "user+work@gmail.com", gmail, true},
+ {"gmail dots ignored", "u.s.e.r@gmail.com", "user@gmail.com", gmail, true},
+ {"gmail dots on configured side", "user@gmail.com", "u.ser@gmail.com", gmail, true},
+ {"gmail dots and subaddress combined", "u.ser+work@gmail.com", "user@gmail.com", gmail, true},
+ {"different local rejected", "other@gmail.com", "user@gmail.com", gmail, false},
+ {"different domain rejected", "user+x@example.com", "user@gmail.com", gmail, false},
+ {"non-gmail provider ignores plus", "user+work@example.com", "user@example.com", custom, false},
+ {"non-gmail provider keeps dots", "u.ser@example.com", "user@example.com", custom, false},
+ {"empty candidate", "", "user@gmail.com", gmail, false},
+ {"empty fetch", "user@gmail.com", "", gmail, false},
+ {"nil account exact still works", "user@example.com", "user@example.com", nil, true},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := addressMatches(tc.candidate, tc.fetch, tc.account); got != tc.want {
+ t.Errorf("addressMatches(%q, %q) = %v, want %v", tc.candidate, tc.fetch, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestNormalizeGmailAddress(t *testing.T) {
+ cases := map[string]string{
+ "user+tag@gmail.com": "user@gmail.com",
+ "user@gmail.com": "user@gmail.com",
+ "user+a+b@example.com": "user@example.com",
+ "u.s.e.r@gmail.com": "user@gmail.com",
+ "u.ser+work@gmail.com": "user@gmail.com",
+ "first.last@example.com": "firstlast@example.com",
+ "no-at-sign": "no-at-sign",
+ "": "",
+ }
+ for in, want := range cases {
+ if got := normalizeGmailAddress(in); got != want {
+ t.Errorf("normalizeGmailAddress(%q) = %q, want %q", in, got, want)
+ }
+ }
+}