fix(badge): dedupe unread count (#1398)

FromSi created

## What?

Deduplicate unread badge counting across `emailsByAcct` and
`folderEmails` by tracking seen emails with `AccountID + UID`.

Added a regression test for the case where the same unread email exists
in both stores.

<img width="595" height="652" alt="image"
src="https://github.com/user-attachments/assets/8c837fb8-017c-4c7c-aa2c-052f244288b2"
/>

## Why?

Closes #1107

`syncUnreadBadge` counted unread emails from both stores independently,
but the stores can contain the same fetched messages. This could make
the macOS unread badge show roughly double the real unread count.

<img width="598" height="647" alt="image"
src="https://github.com/user-attachments/assets/f2b1c267-29bc-4d4c-a116-4c91af789722"
/>

Change summary

main.go      | 43 +++++++++++++++++++++++++++++++------------
main_test.go | 18 ++++++++++++++++++
2 files changed, 49 insertions(+), 12 deletions(-)

Detailed changes

main.go 🔗

@@ -242,27 +242,46 @@ func waitForLogEntry(ch <-chan logging.Entry) tea.Cmd {
 	}
 }
 
-func (m *mainModel) syncUnreadBadge() {
-	if runtime.GOOS != goosDarwin {
-		return
-	}
+func unreadBadgeCount(emailsByAcct, folderEmails map[string][]fetcher.Email) int {
 	count := 0
+	seen := make(map[string]struct{})
+
+	countUnread := func(e fetcher.Email) {
+		if e.IsRead {
+			return
+		}
+		key := fmt.Sprintf("%s:%d", e.AccountID, e.UID)
+		if _, ok := seen[key]; ok {
+			return
+		}
+		seen[key] = struct{}{}
+		count++
+	}
+
 	// Count unread across all accounts (cached/loaded emails)
-	for _, emails := range m.emailsByAcct {
+	for _, emails := range emailsByAcct {
 		for _, e := range emails {
-			if !e.IsRead {
-				count++
-			}
+			countUnread(e)
 		}
 	}
 	// Also check folderEmails for unread status
-	for _, emails := range m.folderEmails {
+	for _, emails := range folderEmails {
 		for _, e := range emails {
-			if !e.IsRead {
-				count++
-			}
+			countUnread(e)
 		}
 	}
+	return count
+}
+
+func (m *mainModel) syncUnreadBadge() {
+	if runtime.GOOS != goosDarwin && loglevel.Get() < loglevel.LevelDebug {
+		return
+	}
+	count := unreadBadgeCount(m.emailsByAcct, m.folderEmails)
+	loglevel.Debugf("unread badge count: %d", count)
+	if runtime.GOOS != goosDarwin {
+		return
+	}
 	_ = macos.SetBadge(count)
 }
 

main_test.go 🔗

@@ -5,6 +5,8 @@ import (
 	"strings"
 	"testing"
 	"unicode/utf8"
+
+	"github.com/floatpane/matcha/fetcher"
 )
 
 func TestSanitizeFilenameTruncatesCJKOnUTF8Boundary(t *testing.T) {
@@ -58,3 +60,19 @@ func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) {
 		t.Fatalf("args = %q, want %q", got, "matcha send --logs")
 	}
 }
+
+func TestUnreadBadgeCountDeduplicatesOverlappingStores(t *testing.T) {
+	email := fetcher.Email{UID: 42, AccountID: "acct-a"}
+	got := unreadBadgeCount(
+		map[string][]fetcher.Email{
+			"acct-a": {email},
+		},
+		map[string][]fetcher.Email{
+			folderInbox: {email},
+		},
+	)
+
+	if got != 1 {
+		t.Fatalf("unreadBadgeCount() = %d, want 1", got)
+	}
+}