Detailed changes
@@ -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
@@ -15,6 +15,7 @@ import (
// of HOME.
func folderCacheTestSetup(t *testing.T) string {
t.Helper()
+ resetLRU()
tempDir := t.TempDir()
t.Setenv("HOME", tempDir)
t.Setenv("USERPROFILE", tempDir)
@@ -0,0 +1,297 @@
+package config
+
+import (
+ "container/list"
+ "encoding/json"
+ "fmt"
+ "log"
+ "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
+ mu sync.Mutex
+}
+
+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 {
+ log.Printf("Failed to load LRU from disk: %v\n", err)
+ }
+
+ })
+
+ lru.mu.Lock()
+ defer lru.mu.Unlock()
+
+ if lru.threshold != threshold {
+ lru.threshold = threshold
+ if lru.currentSize > lru.threshold {
+ lru.evict()
+ }
+ }
+
+ 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 {
+ if os.IsNotExist(err) {
+ return 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 {
+ lru.mu.Lock()
+ defer lru.mu.Unlock()
+
+ 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) {
+ lru.mu.Lock()
+ defer lru.mu.Unlock()
+
+ key := lru.makeKey(folder, uid, accountID)
+
+ if body.SizeBytes > lru.threshold {
+ lru.removeKey(key)
+ _ = removeBodyFromDisk(folder, uid, accountID)
+ 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) {
+ lru.mu.Lock()
+ defer lru.mu.Unlock()
+
+ key := lru.makeKey(folder, uid, accountID)
+ lru.removeKey(key)
+ _ = removeBodyFromDisk(folder, uid, accountID)
+}
+
+func resetLRU() {
+ once = sync.Once{}
+ lru = nil
+}
@@ -708,7 +708,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 {
@@ -777,7 +777,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{
@@ -1317,7 +1317,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 {