diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 01e09503e5db6e6f50316a37b47a474fbae25a00..4bf1ef79c5fa493a0ded298c495c24b41ff2a2e1 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -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 { diff --git a/fetcher/sub_addr_test.go b/fetcher/sub_addr_test.go new file mode 100644 index 0000000000000000000000000000000000000000..19d4074d6f530b24ad26c32c70410e0e2e7bd4fc --- /dev/null +++ b/fetcher/sub_addr_test.go @@ -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) + } + } +}