Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions config/folder_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{}
Expand All @@ -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
Expand All @@ -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(),
})
}
Expand Down
14 changes: 13 additions & 1 deletion fetcher/fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ var headerMessageIDRE = regexp.MustCompile(`<[^>]+>`)
type Folder struct {
Name string
Delimiter string
Unread uint32
Attributes []string
}

Expand Down Expand Up @@ -1789,7 +1790,11 @@ func FetchFolders(account *config.Account) ([]Folder, error) {
}
defer c.Close()

listCmd := c.List("", "*", nil)
listCmd := c.List("", "*", &imap.ListOptions{
ReturnStatus: &imap.StatusOptions{
NumUnseen: true,
},
})
defer listCmd.Close()

var folders []Folder
Expand All @@ -1802,13 +1807,20 @@ 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))
}
folders = append(folders, Folder{
Name: data.Mailbox,
Delimiter: delim,
Unread: unread,
Attributes: attrs,
})
}
Expand Down
31 changes: 28 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,19 +486,25 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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
if !seen["INBOX"] {
cachedFolders = append([]string{"INBOX"}, 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)
Expand Down Expand Up @@ -549,17 +555,26 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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)
go config.SaveAccountFolders(accID, names, unread)
}
// Per-account fetch errors (e.g. broken IMAP login, unreachable
// server) are non-fatal: other accounts' folders are still shown.
Expand Down Expand Up @@ -608,7 +623,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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)
Expand Down Expand Up @@ -2005,6 +2020,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())
return
}
}
}
}
}

Expand Down
37 changes: 35 additions & 2 deletions tui/folder_inbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"fmt"
"maps"
"sort"
"strings"

Expand Down Expand Up @@ -81,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
Expand Down Expand Up @@ -111,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))
Expand Down Expand Up @@ -553,10 +564,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")
Expand Down Expand Up @@ -674,6 +694,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
Expand Down
Loading