Detailed changes
@@ -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(),
})
}
@@ -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)
}
}
@@ -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,
})
}
@@ -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
+ }
+ }
+ }
}
}
@@ -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