diff --git a/config/cache.go b/config/cache.go index a196a5c..6852a39 100644 --- a/config/cache.go +++ b/config/cache.go @@ -695,16 +695,13 @@ func saveEmailBodyCache(cache *EmailBodyCache) error { // GetCachedEmailBody returns the cached body for a specific email, or nil if not cached. // LastAccessedAt is updated by SaveEmailBody, not here -- a read should not // mutate cache state. -func GetCachedEmailBody(folderName string, uid uint32, accountID string) *CachedEmailBody { - cache, err := LoadEmailBodyCache(folderName) - if err != nil { - return nil - } - for i, b := range cache.Bodies { - if b.UID == uid && b.AccountID == accountID { - return &cache.Bodies[i] - } +func GetCachedEmailBody(folderName string, uid uint32, accountID string, threshold int) *CachedEmailBody { + lru := GetLRUInstance(threshold) + + if node := lru.Get(folderName, uid, accountID); node != nil { + return node.Body } + return nil } @@ -729,187 +726,39 @@ func calculateTotalCacheSize(cache *EmailBodyCache) int { return total } -type bodyCacheFileState struct { - path string - cache EmailBodyCache -} - -type bodyCacheEntryRef struct { - fileIndex int - bodyIndex int -} - -func loadAllEmailBodyCaches() ([]bodyCacheFileState, error) { - dir, err := bodyCacheDir() - if err != nil { - return nil, err - } - - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var caches []bodyCacheFileState - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { - continue - } - - path := filepath.Join(dir, entry.Name()) - data, err := SecureReadFile(path) - if err != nil { - return nil, err - } - - var cache EmailBodyCache - if err := json.Unmarshal(data, &cache); err != nil { - return nil, err - } - for i := range cache.Bodies { - if cache.Bodies[i].SizeBytes <= 0 { - cache.Bodies[i].SizeBytes = calculateEmailBodySize(&cache.Bodies[i]) - } - } - - caches = append(caches, bodyCacheFileState{ - path: path, - cache: cache, - }) - } - - return caches, nil -} - -func saveEmailBodyCacheFile(state *bodyCacheFileState) error { - if err := os.MkdirAll(filepath.Dir(state.path), 0700); err != nil { - return err - } - - state.cache.UpdatedAt = time.Now() - data, err := json.Marshal(&state.cache) - if err != nil { - return err - } - return SecureWriteFile(state.path, data, 0600) -} - -func pruneEmailBodyCacheSize(threshold int) error { - if threshold <= 0 { - return nil - } - - caches, err := loadAllEmailBodyCaches() - if err != nil { - return err - } - - totalSize := 0 - var refs []bodyCacheEntryRef - for fileIndex := range caches { - for bodyIndex, body := range caches[fileIndex].cache.Bodies { - totalSize += body.SizeBytes - refs = append(refs, bodyCacheEntryRef{ - fileIndex: fileIndex, - bodyIndex: bodyIndex, - }) - } - } - if totalSize <= threshold { - return nil - } - - sort.Slice(refs, func(i, j int) bool { - left := caches[refs[i].fileIndex].cache.Bodies[refs[i].bodyIndex] - right := caches[refs[j].fileIndex].cache.Bodies[refs[j].bodyIndex] - return left.LastAccessedAt.Before(right.LastAccessedAt) - }) - - remove := make(map[int]map[int]struct{}) - for _, ref := range refs { - if totalSize <= threshold { - break - } - - body := caches[ref.fileIndex].cache.Bodies[ref.bodyIndex] - totalSize -= body.SizeBytes - if remove[ref.fileIndex] == nil { - remove[ref.fileIndex] = make(map[int]struct{}) - } - remove[ref.fileIndex][ref.bodyIndex] = struct{}{} - } - - for fileIndex, bodyIndexes := range remove { - bodies := caches[fileIndex].cache.Bodies - kept := bodies[:0] - for bodyIndex, body := range bodies { - if _, ok := bodyIndexes[bodyIndex]; !ok { - kept = append(kept, body) - } - } - caches[fileIndex].cache.Bodies = kept - if err := saveEmailBodyCacheFile(&caches[fileIndex]); err != nil { - return err - } - } - - return nil -} - // SaveEmailBody saves or updates a cached email body for a folder. func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error { - cache, err := LoadEmailBodyCache(folderName) - if err != nil { - cache = &EmailBodyCache{FolderName: folderName} - } - body.CachedAt = time.Now() - body.LastAccessedAt = time.Now() body.SizeBytes = calculateEmailBodySize(&body) - // Replace existing or append - found := false - for i, b := range cache.Bodies { - if b.UID == body.UID && b.AccountID == body.AccountID { - if body.SizeBytes <= threshold { - cache.Bodies[i] = body - } else { - cache.Bodies = append(cache.Bodies[:i], cache.Bodies[i+1:]...) - } - found = true - break - } - } - if !found && body.SizeBytes <= threshold { - cache.Bodies = append(cache.Bodies, body) - } + lru := GetLRUInstance(threshold) + lru.Put(folderName, body.UID, body.AccountID, &body) - if err := saveEmailBodyCache(cache); err != nil { - return err - } - return pruneEmailBodyCacheSize(threshold) + return nil } // PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder. // validUIDs is a map of UID -> AccountID for emails still present. -func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error { +func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string, threshold int) error { cache, err := LoadEmailBodyCache(folderName) + if err != nil { - return nil // No cache to prune + return nil } + lru := GetLRUInstance(threshold) + var kept []CachedEmailBody for _, b := range cache.Bodies { if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID { kept = append(kept, b) + } else { + lru.Delete(folderName, b.UID, b.AccountID) } } if len(kept) == len(cache.Bodies) { - return nil // Nothing pruned + return nil } cache.Bodies = kept diff --git a/config/lru.go b/config/lru.go new file mode 100644 index 0000000..010a2a6 --- /dev/null +++ b/config/lru.go @@ -0,0 +1,267 @@ +package config + +import ( + "container/list" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type Node struct { + Key string // key = folder:uid:accountID + Folder string + Body *CachedEmailBody +} + +type LRU struct { + threshold int + currentSize int + cache map[string]*list.Element + ll *list.List +} + +var lru *LRU +var once sync.Once + +func GetLRUInstance(threshold int) *LRU { + once.Do( + func() { + lru = &LRU{ + threshold: threshold, + cache: make(map[string]*list.Element), + ll: list.New(), + } + + if err := lru.LoadFromDisk(); err != nil { + fmt.Printf("Failed to load LRU from disk: %v\n", err) + } + + }) + + return lru +} + +func (lru *LRU) makeKey(folder string, uid uint32, accountID string) string { + return fmt.Sprintf("%s:%d:%s", folder, uid, accountID) +} + +func removeBodyFromDisk(folder string, uid uint32, accountID string) error { + cache, err := LoadEmailBodyCache(folder) + + if err != nil { + return nil + } + + kept := cache.Bodies[:0] + for _, b := range cache.Bodies { + if !(b.UID == uid && b.AccountID == accountID) { + kept = append(kept, b) + } + } + + if len(kept) == len(cache.Bodies) { + return nil + } + + cache.Bodies = kept + return saveEmailBodyCache(cache) +} + +func (lru *LRU) evict() { + for lru.currentSize > lru.threshold { + back := lru.ll.Back() + + if back == nil { + break + } + + node := back.Value.(*Node) + + lru.ll.Remove(back) + delete(lru.cache, node.Key) + lru.currentSize -= node.Body.SizeBytes + + _ = removeBodyFromDisk(node.Folder, node.Body.UID, node.Body.AccountID) + } +} + +func (lru *LRU) LoadFromDisk() error { + dir, err := bodyCacheDir() + + if err != nil { + return err + } + + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + var caches []EmailBodyCache + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + + path := filepath.Join(dir, entry.Name()) + data, err := SecureReadFile(path) + if err != nil { + continue + } + + var cache EmailBodyCache + if err := json.Unmarshal(data, &cache); err != nil { + continue + } + + for i := range cache.Bodies { + if cache.Bodies[i].SizeBytes <= 0 { + cache.Bodies[i].SizeBytes = calculateEmailBodySize(&cache.Bodies[i]) + } + } + + caches = append(caches, cache) + } + + type bodyWithFolder struct { + folder string + body CachedEmailBody + } + + var allBodies []bodyWithFolder + + for _, cache := range caches { + for _, body := range cache.Bodies { + allBodies = append(allBodies, bodyWithFolder{ + folder: cache.FolderName, + body: body, + }) + } + } + + sort.Slice(allBodies, func(i, j int) bool { + ti := allBodies[i].body.LastAccessedAt + tj := allBodies[j].body.LastAccessedAt + return ti.After(tj) + }) + + for i := len(allBodies) - 1; i >= 0; i-- { + item := allBodies[i] + + if item.body.SizeBytes > lru.threshold { + continue + } + + key := lru.makeKey(item.folder, item.body.UID, item.body.AccountID) + + bodyCopy := item.body + node := &Node{ + Key: key, + Folder: item.folder, + Body: &bodyCopy, + } + + e := lru.ll.PushFront(node) + lru.cache[key] = e + lru.currentSize += item.body.SizeBytes + } + + if lru.currentSize > lru.threshold { + lru.evict() + } + return nil +} + +func saveEmailBodyToDisk(folder string, body *CachedEmailBody) error { + cache, err := LoadEmailBodyCache(folder) + + if err != nil { + cache = &EmailBodyCache{FolderName: folder} + } + + found := false + for i, b := range cache.Bodies { + if b.UID == body.UID && b.AccountID == body.AccountID { + cache.Bodies[i] = *body + found = true + break + } + } + if !found { + cache.Bodies = append(cache.Bodies, *body) + } + + return saveEmailBodyCache(cache) +} + +func (lru *LRU) Get(folder string, uid uint32, accountID string) *Node { + key := lru.makeKey(folder, uid, accountID) + + e, ok := lru.cache[key] + + if !ok { + return nil + } + + lru.ll.MoveToFront(e) + + node := e.Value.(*Node) + node.Body.LastAccessedAt = time.Now() + + _ = saveEmailBodyToDisk(folder, node.Body) + + return node +} + +func (lru *LRU) removeKey(key string) { + if e, ok := lru.cache[key]; ok { + node := e.Value.(*Node) + + lru.currentSize -= node.Body.SizeBytes + lru.ll.Remove(e) + delete(lru.cache, key) + } +} + +func (lru *LRU) Put(folder string, uid uint32, accountID string, body *CachedEmailBody) { + key := lru.makeKey(folder, uid, accountID) + + if body.SizeBytes > lru.threshold { + lru.removeKey(key) + return + } + + body.LastAccessedAt = time.Now() + + if e, ok := lru.cache[key]; ok { + node := e.Value.(*Node) + lru.currentSize -= node.Body.SizeBytes + lru.currentSize += body.SizeBytes + node.Body = body + lru.ll.MoveToFront(e) + } else { + node := &Node{ + Key: key, + Folder: folder, + Body: body, + } + e := lru.ll.PushFront(node) + lru.cache[key] = e + lru.currentSize += body.SizeBytes + } + + lru.evict() + + _ = saveEmailBodyToDisk(folder, body) +} + +func (lru *LRU) Delete(folder string, uid uint32, accountID string) { + key := lru.makeKey(folder, uid, accountID) + lru.removeKey(key) + _ = removeBodyFromDisk(folder, uid, accountID) +} diff --git a/main.go b/main.go index f3a4c30..e1d8219 100644 --- a/main.go +++ b/main.go @@ -667,7 +667,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, e := range msg.Emails { validUIDs[e.UID] = e.AccountID } - _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs) + _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs, m.config.GetBodyCacheThreshold()) }() // Only update the view if the user is still on this folder if m.folderInbox.GetCurrentFolder() != msg.FolderName { @@ -736,7 +736,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } folderName := m.folderInbox.GetCurrentFolder() // Check cache first - if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil { + if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil { var attachments []fetcher.Attachment for _, ca := range cached.Attachments { att := fetcher.Attachment{ @@ -1276,7 +1276,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) } // Check body cache first - if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil { + if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil { // Convert cached attachments back to fetcher.Attachment var attachments []fetcher.Attachment for _, ca := range cached.Attachments {