fix(tui): update folder unread counter (#1493)

KBS created

## What?

After deleting or archiving emails, the per-folder unread counter shown
in the sidebar (`(N)`) now updates immediately. The four handlers
(`DeleteEmailMsg`, `ArchiveEmailMsg`, `BatchDeleteEmailsMsg`,
`BatchArchiveEmailsMsg`) decrement `m.unread[folder]` for each removed
unread email via a new `decrementFolderUnreadForRemoved` helper that
mirrors the existing read/unread path (`DecrementUnreadCount` +
persist); undo re-increments it. Adds five tests.

## Why?

The delete/archive handlers mutated the email stores but never touched
`m.unread`, so the sidebar folder counter stayed stale until the next
server fetch (#1404). (`syncUnreadBadge` only sets the macOS dock badge,
so it did not address this.)

Closes #1404

Change summary

main.go             |  57 ++++++++++++++++++++++++
main_test.go        | 110 +++++++++++++++++++++++++++++++++++++++++++++++
tui/folder_inbox.go |   9 +++
3 files changed, 176 insertions(+)

Detailed changes

main.go 🔗

@@ -1923,6 +1923,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
 		folderSnap := slices.Clone(m.folderEmails[folderName])
 
+		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID})
 		m.removeEmailFromStores(msg.UID, msg.AccountID)
 
 		if emails, ok := m.folderEmails[folderName]; ok {
@@ -1973,6 +1974,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
 		folderSnap := slices.Clone(m.folderEmails[folderName])
 
+		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID})
 		m.removeEmailFromStores(msg.UID, msg.AccountID)
 
 		if emails, ok := m.folderEmails[folderName]; ok {
@@ -2050,6 +2052,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
 		folderSnap := slices.Clone(m.folderEmails[folderName])
 
+		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs)
 		for _, uid := range msg.UIDs {
 			m.removeEmailFromStores(uid, msg.AccountID)
 		}
@@ -2104,6 +2107,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 		acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
 		folderSnap := slices.Clone(m.folderEmails[folderName])
 
+		m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs)
 		for _, uid := range msg.UIDs {
 			m.removeEmailFromStores(uid, msg.AccountID)
 		}
@@ -2347,6 +2351,23 @@ func (m *mainModel) restorePendingAction() {
 
 	if m.folderInbox != nil {
 		m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts)
+		// Restore the folder unread counter that was decremented when the action
+		// was applied, so the sidebar count is correct again after undo.
+		restored := false
+		for _, uid := range pa.uids {
+			for _, e := range pa.folderSnap {
+				if e.UID == uid && e.AccountID == pa.accountID {
+					if !e.IsRead {
+						m.folderInbox.IncrementUnreadCount(pa.folderName)
+						restored = true
+					}
+					break
+				}
+			}
+		}
+		if restored {
+			config.SaveAccountFolders(pa.accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
+		}
 	}
 	go saveFolderEmailsToCache(pa.folderName, pa.folderSnap)
 }
@@ -2544,6 +2565,42 @@ func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
 	}
 }
 
+// decrementFolderUnreadForRemoved updates the sidebar folder unread counter when
+// emails are removed from a folder (delete/archive). It mirrors the read-path in
+// markEmailAsReadInStores: each removed email that is currently unread decrements
+// the folder's counter, so the count shown in the folder list updates immediately
+// instead of only on the next fetch (issue #1404).
+//
+// It must be called before the email is removed from the stores, since it reads
+// the email's unread status from folderEmails.
+func (m *mainModel) decrementFolderUnreadForRemoved(folderName, accountID string, uids []uint32) {
+	if m.folderInbox == nil || len(uids) == 0 {
+		return
+	}
+
+	emails, ok := m.folderEmails[folderName]
+	if !ok {
+		return
+	}
+
+	decremented := false
+	for _, uid := range uids {
+		for _, e := range emails {
+			if e.UID == uid && e.AccountID == accountID {
+				if !e.IsRead {
+					m.folderInbox.DecrementUnreadCount(folderName)
+					decremented = true
+				}
+				break
+			}
+		}
+	}
+
+	if decremented {
+		config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
+	}
+}
+
 // pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds.
 func (m *mainModel) pluginFlagCmds() []tea.Cmd {
 	if m.plugins == nil {

main_test.go 🔗

@@ -6,7 +6,9 @@ import (
 	"testing"
 	"unicode/utf8"
 
+	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
+	"github.com/floatpane/matcha/tui"
 )
 
 func TestSanitizeFilenameTruncatesCJKOnUTF8Boundary(t *testing.T) {
@@ -61,6 +63,114 @@ func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) {
 	}
 }
 
+// newFolderCounterModel builds a minimal mainModel whose INBOX folder holds the
+// given emails and reports the given unread count. It is used to verify that the
+// per-folder unread counter shown in the sidebar is updated immediately after
+// delete/archive operations, without waiting for the next fetch.
+func newFolderCounterModel(emails []fetcher.Email, unread int) *mainModel {
+	accountID := "acct-a"
+	cfg := &config.Config{
+		Accounts: []config.Account{{ID: accountID, Email: "a@example.com"}},
+	}
+
+	fi := tui.NewFolderInbox([]string{folderInbox}, cfg.Accounts)
+	fi.SetUnreadCounts(map[string]int{folderInbox: unread})
+	fi.SetEmails(emails, cfg.Accounts)
+
+	byAcct := make(map[string][]fetcher.Email)
+	for _, e := range emails {
+		byAcct[e.AccountID] = append(byAcct[e.AccountID], e)
+	}
+
+	m := &mainModel{
+		config:       cfg,
+		folderInbox:  fi,
+		current:      fi,
+		emails:       emails,
+		emailsByAcct: byAcct,
+		folderEmails: map[string][]fetcher.Email{folderInbox: emails},
+	}
+	return m
+}
+
+// TestDeleteUnreadEmailUpdatesFolderCounter verifies that deleting an unread
+// email immediately decrements the folder's unread counter in the sidebar,
+// rather than waiting for the next fetch (issue #1404).
+func TestDeleteUnreadEmailUpdatesFolderCounter(t *testing.T) {
+	email := fetcher.Email{UID: 42, AccountID: "acct-a", IsRead: false}
+	m := newFolderCounterModel([]fetcher.Email{email}, 1)
+
+	m.Update(tui.DeleteEmailMsg{UID: 42, AccountID: "acct-a", Mailbox: folderInbox})
+
+	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
+		t.Fatalf("after deleting an unread email, folder unread count = %d, want 0", got)
+	}
+}
+
+// TestArchiveUnreadEmailUpdatesFolderCounter verifies the same immediate update
+// for archive operations.
+func TestArchiveUnreadEmailUpdatesFolderCounter(t *testing.T) {
+	email := fetcher.Email{UID: 7, AccountID: "acct-a", IsRead: false}
+	m := newFolderCounterModel([]fetcher.Email{email}, 1)
+
+	m.Update(tui.ArchiveEmailMsg{UID: 7, AccountID: "acct-a", Mailbox: folderInbox})
+
+	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
+		t.Fatalf("after archiving an unread email, folder unread count = %d, want 0", got)
+	}
+}
+
+// TestBatchDeleteUnreadEmailsUpdatesFolderCounter verifies that batch delete
+// decrements the folder counter once per unread email removed, while leaving
+// already-read emails untouched.
+func TestBatchDeleteUnreadEmailsUpdatesFolderCounter(t *testing.T) {
+	emails := []fetcher.Email{
+		{UID: 1, AccountID: "acct-a", IsRead: false},
+		{UID: 2, AccountID: "acct-a", IsRead: false},
+		{UID: 3, AccountID: "acct-a", IsRead: true},
+	}
+	m := newFolderCounterModel(emails, 2)
+
+	m.Update(tui.BatchDeleteEmailsMsg{UIDs: []uint32{1, 2, 3}, AccountID: "acct-a", Mailbox: folderInbox})
+
+	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
+		t.Fatalf("after batch-deleting two unread emails, folder unread count = %d, want 0", got)
+	}
+}
+
+// TestDeleteReadEmailLeavesFolderCounter verifies that deleting an already-read
+// email does not change the folder unread counter.
+func TestDeleteReadEmailLeavesFolderCounter(t *testing.T) {
+	emails := []fetcher.Email{
+		{UID: 10, AccountID: "acct-a", IsRead: true},
+		{UID: 11, AccountID: "acct-a", IsRead: false},
+	}
+	m := newFolderCounterModel(emails, 1)
+
+	m.Update(tui.DeleteEmailMsg{UID: 10, AccountID: "acct-a", Mailbox: folderInbox})
+
+	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 1 {
+		t.Fatalf("after deleting a read email, folder unread count = %d, want 1", got)
+	}
+}
+
+// TestUndoDeleteRestoresFolderCounter verifies that undoing a delete restores
+// the folder unread counter that was decremented when the email was deleted.
+func TestUndoDeleteRestoresFolderCounter(t *testing.T) {
+	email := fetcher.Email{UID: 99, AccountID: "acct-a", IsRead: false}
+	m := newFolderCounterModel([]fetcher.Email{email}, 1)
+
+	m.Update(tui.DeleteEmailMsg{UID: 99, AccountID: "acct-a", Mailbox: folderInbox})
+	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
+		t.Fatalf("after delete, folder unread count = %d, want 0", got)
+	}
+
+	m.restorePendingAction()
+	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 1 {
+		t.Fatalf("after undo, folder unread count = %d, want 1", got)
+	}
+}
+
 func TestUnreadBadgeCountDeduplicatesOverlappingStores(t *testing.T) {
 	email := fetcher.Email{UID: 42, AccountID: "acct-a"}
 	got := unreadBadgeCount(

tui/folder_inbox.go 🔗

@@ -673,6 +673,15 @@ func (m *FolderInbox) DecrementUnreadCount(folder string) {
 	}
 }
 
+// IncrementUnreadCount increases the unread counter for a folder. It is used to
+// restore the counter when a delete/archive action is undone.
+func (m *FolderInbox) IncrementUnreadCount(folder string) {
+	if m.unread == nil {
+		m.unread = make(map[string]int)
+	}
+	m.unread[folder]++
+}
+
 // SetEmails updates the inbox emails.
 func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
 	m.accounts = accounts