feat: in-memory LRU email body cache (#1281)

Mohamed Mahmoud created

## What?

Replaces the disk-only email body cache with an in-memory LRU cache
backed by write-through disk persistence.

- Single global LRU instance shared across all folders via `sync.Once`
singleton.
- Fast `Get()` and `Put()` operations — `O(1)` via hashmap +
doubly-linked list.
- Write-through: every `Get()` and `Put()` immediately persists to disk,
so no data is lost on crash.
- Eviction removes least-recently-used entries from both memory and disk
atomically.
- On the first `GetLRUInstance()` call, `LoadFromDisk()` restores LRU
order using `LastAccessedAt` timestamps sorted oldest-first, so the most
recently accessed email ends up at the front.

## Why?

The previous disk-only approach had two bottlenecks:
`GetCachedEmailBody()` read the entire folder JSON file on every call,
and `pruneEmailBodyCacheSize()` loaded all folder files on every
`SaveEmailBody()` call. With multiple accounts and many folders, this
becomes expensive.

Related #1171

Change summary

config/cache.go             | 185 ++----------------------
config/folder_cache_test.go |   1 
config/lru.go               | 297 +++++++++++++++++++++++++++++++++++++++
main.go                     |   6 
4 files changed, 318 insertions(+), 171 deletions(-)

Detailed changes

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

config/folder_cache_test.go 🔗

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

config/lru.go 🔗

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

main.go 🔗

@@ -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 {