From aa25750c4f8bf08b6db0d961838e189f89c50e44 Mon Sep 17 00:00:00 2001 From: Mohamed Mahmoud Date: Sat, 23 May 2026 20:16:27 +0300 Subject: [PATCH] feat: show unread counts (#1343) ## What? Shows unread email counts next to folder names in the sidebar. ## Why? Folder list showed folder names only, making it impossible to see which folders have unread mail without opening each one. This improves navigation efficiency. Closes #645 --------- Signed-off-by: drew Co-authored-by: drew --- config/folder_cache.go | 19 ++++++++------ config/folder_cache_test.go | 52 ++++++++++++++++++------------------- fetcher/fetcher.go | 14 +++++++++- main.go | 31 +++++++++++++++++++--- tui/folder_inbox.go | 38 +++++++++++++++++++++++++-- 5 files changed, 114 insertions(+), 40 deletions(-) diff --git a/config/folder_cache.go b/config/folder_cache.go index ab30fae6e02b116772624fe84b18a6a161cff59a..0553ab71da7ecb0f83811063dda7d72631840054 100644 --- a/config/folder_cache.go +++ b/config/folder_cache.go @@ -14,9 +14,10 @@ import ( // CachedFolders stores folder names for a single account. type CachedFolders struct { - AccountID string `json:"account_id"` - Folders []string `json:"folders"` - UpdatedAt time.Time `json:"updated_at"` + AccountID string `json:"account_id"` + Folders []string `json:"folders"` + Unread map[string]int `json:"unread_counts,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } // FolderCache stores cached folders for all accounts. @@ -70,21 +71,21 @@ func LoadFolderCache() (*FolderCache, error) { } // GetCachedFolders returns cached folder names for a specific account. -func GetCachedFolders(accountID string) []string { +func GetCachedFolders(accountID string) ([]string, map[string]int) { cache, err := LoadFolderCache() if err != nil { - return nil + return nil, nil } for _, acc := range cache.Accounts { if acc.AccountID == accountID { - return acc.Folders + return acc.Folders, acc.Unread } } - return nil + return nil, nil } // SaveAccountFolders saves folder names for a specific account, merging into the existing cache. -func SaveAccountFolders(accountID string, folders []string) error { +func SaveAccountFolders(accountID string, folders []string, unread map[string]int) error { cache, err := LoadFolderCache() if err != nil { cache = &FolderCache{} @@ -94,6 +95,7 @@ func SaveAccountFolders(accountID string, folders []string) error { for i, acc := range cache.Accounts { if acc.AccountID == accountID { cache.Accounts[i].Folders = folders + cache.Accounts[i].Unread = unread cache.Accounts[i].UpdatedAt = time.Now() found = true break @@ -104,6 +106,7 @@ func SaveAccountFolders(accountID string, folders []string) error { cache.Accounts = append(cache.Accounts, CachedFolders{ AccountID: accountID, Folders: folders, + Unread: unread, UpdatedAt: time.Now(), }) } diff --git a/config/folder_cache_test.go b/config/folder_cache_test.go index 337b93840c539c038e38b8b2284d5a51be39cf4e..c67f5672c8ba29f9459ebadeab3d8d6aa0de3142 100644 --- a/config/folder_cache_test.go +++ b/config/folder_cache_test.go @@ -62,21 +62,21 @@ func TestSaveLoadFolderCache_RoundTrip(t *testing.T) { func TestSaveAccountFolders_InvalidatesExistingEntry(t *testing.T) { folderCacheTestSetup(t) - if err := SaveAccountFolders("acct-1", []string{"INBOX", "Sent"}); err != nil { + if err := SaveAccountFolders("acct-1", []string{"INBOX", "Sent"}, nil); err != nil { t.Fatalf("first SaveAccountFolders: %v", err) } // Overwriting the same accountID must replace the folder list, not append. - if err := SaveAccountFolders("acct-1", []string{"INBOX", "Trash"}); err != nil { + if err := SaveAccountFolders("acct-1", []string{"INBOX", "Trash"}, nil); err != nil { t.Fatalf("second SaveAccountFolders: %v", err) } - got := GetCachedFolders("acct-1") + folders, _ := GetCachedFolders("acct-1") want := []string{"INBOX", "Trash"} - sort.Strings(got) + sort.Strings(folders) sort.Strings(want) - if !reflect.DeepEqual(got, want) { - t.Errorf("after overwrite: got %v, want %v", got, want) + if !reflect.DeepEqual(folders, want) { + t.Errorf("after overwrite: got %v, want %v", folders, want) } cache, err := LoadFolderCache() @@ -91,10 +91,10 @@ func TestSaveAccountFolders_InvalidatesExistingEntry(t *testing.T) { func TestSaveAccountFolders_AddsNewAccount(t *testing.T) { folderCacheTestSetup(t) - if err := SaveAccountFolders("acct-1", []string{"INBOX"}); err != nil { + if err := SaveAccountFolders("acct-1", []string{"INBOX"}, nil); err != nil { t.Fatalf("SaveAccountFolders acct-1: %v", err) } - if err := SaveAccountFolders("acct-2", []string{"INBOX", "Spam"}); err != nil { + if err := SaveAccountFolders("acct-2", []string{"INBOX", "Spam"}, nil); err != nil { t.Fatalf("SaveAccountFolders acct-2: %v", err) } @@ -105,11 +105,11 @@ func TestSaveAccountFolders_AddsNewAccount(t *testing.T) { if len(cache.Accounts) != 2 { t.Errorf("accounts: got %d, want 2", len(cache.Accounts)) } - if got := GetCachedFolders("acct-1"); !reflect.DeepEqual(got, []string{"INBOX"}) { - t.Errorf("acct-1: got %v", got) + if folders, _ := GetCachedFolders("acct-1"); !reflect.DeepEqual(folders, []string{"INBOX"}) { + t.Errorf("acct-1: got %v", folders) } - if got := GetCachedFolders("acct-2"); !reflect.DeepEqual(got, []string{"INBOX", "Spam"}) { - t.Errorf("acct-2: got %v", got) + if folders, _ := GetCachedFolders("acct-2"); !reflect.DeepEqual(folders, []string{"INBOX", "Spam"}) { + t.Errorf("acct-2: got %v", folders) } } @@ -150,11 +150,11 @@ func TestSaveAccountFolders_RecoversFromCorruptCache(t *testing.T) { // SaveAccountFolders treats a load error as "start fresh" (see the // `cache = &FolderCache{}` fall-back) so a corrupt file must be // silently replaced with a valid one, not fail the whole save. - if err := SaveAccountFolders("acct-1", []string{"INBOX"}); err != nil { + if err := SaveAccountFolders("acct-1", []string{"INBOX"}, nil); err != nil { t.Fatalf("SaveAccountFolders should recover from corrupt cache: %v", err) } - if got := GetCachedFolders("acct-1"); !reflect.DeepEqual(got, []string{"INBOX"}) { - t.Errorf("after recovery: got %v, want [INBOX]", got) + if folders, _ := GetCachedFolders("acct-1"); !reflect.DeepEqual(folders, []string{"INBOX"}) { + t.Errorf("after recovery: got %v, want [INBOX]", folders) } } @@ -177,18 +177,18 @@ func TestSaveFolderCache_EmptyAccounts(t *testing.T) { func TestSaveAccountFolders_EmptyFolderList(t *testing.T) { folderCacheTestSetup(t) - if err := SaveAccountFolders("acct-1", nil); err != nil { + if err := SaveAccountFolders("acct-1", nil, nil); err != nil { t.Fatalf("SaveAccountFolders nil folders: %v", err) } - if err := SaveAccountFolders("acct-2", []string{}); err != nil { + if err := SaveAccountFolders("acct-2", []string{}, nil); err != nil { t.Fatalf("SaveAccountFolders empty folders: %v", err) } - if got := GetCachedFolders("acct-1"); len(got) != 0 { - t.Errorf("acct-1: got %v, want empty", got) + if folders, _ := GetCachedFolders("acct-1"); len(folders) != 0 { + t.Errorf("acct-1: got %v, want empty", folders) } - if got := GetCachedFolders("acct-2"); len(got) != 0 { - t.Errorf("acct-2: got %v, want empty", got) + if folders, _ := GetCachedFolders("acct-2"); len(folders) != 0 { + t.Errorf("acct-2: got %v, want empty", folders) } // Both accounts should still be tracked even though their folder @@ -206,11 +206,11 @@ func TestSaveAccountFolders_EmptyFolderList(t *testing.T) { func TestGetCachedFolders_MissingAccount(t *testing.T) { folderCacheTestSetup(t) - if err := SaveAccountFolders("acct-1", []string{"INBOX"}); err != nil { + if err := SaveAccountFolders("acct-1", []string{"INBOX"}, nil); err != nil { t.Fatalf("SaveAccountFolders: %v", err) } - if got := GetCachedFolders("missing"); got != nil { - t.Errorf("missing account: got %v, want nil", got) + if folders, _ := GetCachedFolders("missing"); folders != nil { + t.Errorf("missing account: got %v, want nil", folders) } } @@ -218,7 +218,7 @@ func TestGetCachedFolders_NoCacheFile(t *testing.T) { folderCacheTestSetup(t) // No cache file has been written yet. - if got := GetCachedFolders("acct-1"); got != nil { - t.Errorf("no cache file: got %v, want nil", got) + if folders, _ := GetCachedFolders("acct-1"); folders != nil { + t.Errorf("no cache file: got %v, want nil", folders) } } diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index d431524d6eaea9908f4dea7b79d2dcdf60da5c70..6110a3599c31b5451d9ff9177480ede4f05e97cf 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -106,6 +106,7 @@ var headerMessageIDRE = regexp.MustCompile(`<[^>]+>`) type Folder struct { Name string Delimiter string + Unread uint32 Attributes []string } @@ -1787,7 +1788,11 @@ func FetchFolders(account *config.Account) ([]Folder, error) { } defer c.Close() //nolint:errcheck - listCmd := c.List("", "*", nil) + listCmd := c.List("", "*", &imap.ListOptions{ + ReturnStatus: &imap.StatusOptions{ + NumUnseen: true, + }, + }) defer listCmd.Close() //nolint:errcheck var folders []Folder @@ -1800,6 +1805,12 @@ func FetchFolders(account *config.Account) ([]Folder, error) { if data.Delim != 0 { delim = string(data.Delim) } + + var unread uint32 + if data.Status != nil { + unread = *data.Status.NumUnseen + } + var attrs []string for _, a := range data.Attrs { attrs = append(attrs, string(a)) @@ -1807,6 +1818,7 @@ func FetchFolders(account *config.Account) ([]Folder, error) { folders = append(folders, Folder{ Name: data.Mailbox, Delimiter: delim, + Unread: unread, Attributes: attrs, }) } diff --git a/main.go b/main.go index 5e26efbec9dd3b458861d5650804a73b819dc775..b6f3e95dcb3f929328e2d3b63a5ded8bbb75aabe 100644 --- a/main.go +++ b/main.go @@ -516,12 +516,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo // Load cached folders from all accounts, merge unique names seen := make(map[string]bool) var cachedFolders []string + unread := make(map[string]int) for _, acc := range m.config.Accounts { - for _, f := range config.GetCachedFolders(acc.ID) { + folders, counters := config.GetCachedFolders(acc.ID) + for _, f := range folders { if !seen[f] { seen[f] = true cachedFolders = append(cachedFolders, f) } + if count, ok := counters[f]; ok { + unread[f] += count + } } } // Always ensure INBOX is present, even if cache is empty or stale @@ -529,6 +534,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo cachedFolders = append([]string{folderInbox}, cachedFolders...) } m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts) + m.folderInbox.SetUnreadCounts(unread) m.folderInbox.SetDateFormat(m.config.GetDateFormat()) m.folderInbox.SetDetailedDates(m.config.EnableDetailedDates) m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded) @@ -579,17 +585,26 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo return m, nil } var folderNames []string + unread := make(map[string]int) for _, f := range msg.MergedFolders { folderNames = append(folderNames, f.Name) + if f.Unread > 0 { + unread[f.Name] = int(f.Unread) + } } m.folderInbox.SetFolders(folderNames) + m.folderInbox.SetUnreadCounts(unread) // Cache folder lists per account for accID, folders := range msg.FoldersByAccount { var names []string + unread := make(map[string]int) for _, f := range folders { names = append(names, f.Name) + if f.Unread > 0 { + unread[f.Name] = int(f.Unread) + } } - go config.SaveAccountFolders(accID, names) //nolint:errcheck + go config.SaveAccountFolders(accID, names, unread) //nolint:errcheck } // Per-account fetch errors (e.g. broken IMAP login, unreachable // server) are non-fatal: other accounts' folders are still shown. @@ -638,7 +653,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo // Update IDLE watchers to monitor the new folder for i := range m.config.Accounts { // Only start IDLE for accounts that actually have this folder - folders := config.GetCachedFolders(m.config.Accounts[i].ID) + folders, _ := config.GetCachedFolders(m.config.Accounts[i].ID) if !slices.Contains(folders, msg.FolderName) { if m.service != nil && m.service.IsDaemon() { m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder) //nolint:errcheck,gosec @@ -2083,6 +2098,16 @@ func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) { // Update the inbox UI if m.folderInbox != nil { m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID) + + for folderName, folderEmails := range m.folderEmails { + for _, e := range folderEmails { + if e.UID == uid && e.AccountID == accountID { + m.folderInbox.DecrementUnreadCount(folderName) + config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec + return + } + } + } } } diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index a6760fb4bb28439cec48328e8d29a22ec86eb7f9..05875db93e67a8400cb383924718f29d43dfb237 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + "maps" "sort" "strings" @@ -80,6 +82,7 @@ const ( // FolderInbox combines a folder sidebar with an email list. type FolderInbox struct { folders []string + unread map[string]int activeFolderIdx int currentFolder string inbox *Inbox @@ -110,6 +113,15 @@ type FolderInbox struct { focusedPane PaneType } +func (m *FolderInbox) GetUnreadCountsCopy() map[string]int { + if m.unread == nil { + return make(map[string]int) + } + result := make(map[string]int) + maps.Copy(result, m.unread) + return result +} + // sortFolders sorts folder names with INBOX always first, then alphabetically. func sortFolders(folders []string) []string { sorted := make([]string, len(folders)) @@ -550,10 +562,19 @@ func (m *FolderInbox) renderSidebar() string { for i, folder := range m.folders { displayName := m.formatFolderName(folder) + unread := m.unread[folder] + + var tab string + if unread > 0 { + tab = fmt.Sprintf("%s (%d)", displayName, unread) + } else { + tab = displayName + } + if i == m.activeFolderIdx { - b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(displayName)) + b.WriteString(activeFolderStyle.Width(sidebarWidth - 4).Render(tab)) } else { - b.WriteString(folderStyle.Render(displayName)) + b.WriteString(folderStyle.Render(tab)) } if i < len(m.folders)-1 { b.WriteString("\n") @@ -671,6 +692,19 @@ func (m *FolderInbox) SetFolders(folders []string) { } } +func (m *FolderInbox) SetUnreadCounts(counts map[string]int) { + m.unread = counts +} + +func (m *FolderInbox) DecrementUnreadCount(folder string) { + if m.unread == nil { + return + } + if m.unread[folder] > 0 { + m.unread[folder]-- + } +} + // SetEmails updates the inbox emails. func (m *FolderInbox) SetEmails(emails []fetcher.Email, accounts []config.Account) { m.accounts = accounts