fix: allow subaddresses for gmail (#1206)

Drew Smirnoff created

## What?

Adds a check for `some+some@domain.com` for Gmail (sub-addressing)

## Why?

Closes #1200

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

fetcher/fetcher.go       | 50 ++++++++++++++++++++++++++++-----
fetcher/sub_addr_test.go | 62 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 104 insertions(+), 8 deletions(-)

Detailed changes

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 {

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)
+		}
+	}
+}