feat: show unread counts (#1343)

Mohamed Mahmoud and drew created

## 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 <me@andrinoff.com>
Co-authored-by: drew <me@andrinoff.com>

Change summary

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(-)

Detailed changes

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(),
 		})
 	}

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

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

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

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