feat(config): add LRU eviction (#1227)

Mohamed Mahmoud created

## What?

Implemented a size-based LRU (Least Recently Used) eviction policy for
the email body cache.

## Why?

The cache grows without bound, reaching hundreds of `MB` or `GB` after
heavy use. This causes disk quota issues on devices with small storage.

Closes #1171

Change summary

config/cache.go            | 61 ++++++++++++++++++++++++++++++++++-----
config/config.go           | 13 ++++++++
docs/docs/Configuration.md |  5 ++
main.go                    |  4 +-
4 files changed, 71 insertions(+), 12 deletions(-)

Detailed changes

config/cache.go 🔗

@@ -422,11 +422,13 @@ type CachedAttachment struct {
 
 // CachedEmailBody stores the body and attachment metadata for a single email.
 type CachedEmailBody struct {
-	UID         uint32             `json:"uid"`
-	AccountID   string             `json:"account_id"`
-	Body        string             `json:"body"`
-	Attachments []CachedAttachment `json:"attachments,omitempty"`
-	CachedAt    time.Time          `json:"cached_at"`
+	UID            uint32             `json:"uid"`
+	AccountID      string             `json:"account_id"`
+	Body           string             `json:"body"`
+	Attachments    []CachedAttachment `json:"attachments,omitempty"`
+	CachedAt       time.Time          `json:"cached_at"`
+	LastAccessedAt time.Time          `json:"last_accessed_at"`
+	SizeBytes      int                `json:"size_bytes"`
 }
 
 // EmailBodyCache stores cached email bodies for a folder.
@@ -495,22 +497,57 @@ func GetCachedEmailBody(folderName string, uid uint32, accountID string) *Cached
 	if err != nil {
 		return nil
 	}
-	for _, b := range cache.Bodies {
+	for i, b := range cache.Bodies {
 		if b.UID == uid && b.AccountID == accountID {
-			return &b
+			cache.Bodies[i].LastAccessedAt = time.Now()
+			_ = saveEmailBodyCache(cache)
+			return &cache.Bodies[i]
 		}
 	}
 	return nil
 }
 
+func calculateEmailBodySize(body *CachedEmailBody) int {
+	size := len(body.Body)
+	for _, att := range body.Attachments {
+		size += len(att.Filename)
+		size += len(att.PartID)
+		size += len(att.Encoding)
+		size += len(att.MIMEType)
+		size += len(att.ContentID)
+		size += len(att.CalendarData)
+	}
+	return size
+}
+
+func calculateTotalCacheSize(cache *EmailBodyCache) int {
+	total := 0
+	for _, b := range cache.Bodies {
+		total += b.SizeBytes
+	}
+	return total
+}
+
+func evict(cache *EmailBodyCache, newSize int, threshold int) {
+	sort.Slice(cache.Bodies, func(i, j int) bool {
+		return cache.Bodies[i].LastAccessedAt.Before(cache.Bodies[j].LastAccessedAt)
+	})
+
+	for len(cache.Bodies) > 0 && calculateTotalCacheSize(cache)+newSize > threshold {
+		cache.Bodies = cache.Bodies[1:]
+	}
+}
+
 // SaveEmailBody saves or updates a cached email body for a folder.
-func SaveEmailBody(folderName string, body CachedEmailBody) error {
+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
@@ -522,7 +559,13 @@ func SaveEmailBody(folderName string, body CachedEmailBody) error {
 		}
 	}
 	if !found {
-		cache.Bodies = append(cache.Bodies, body)
+		if body.SizeBytes <= threshold {
+			if calculateTotalCacheSize(cache)+body.SizeBytes > threshold {
+				evict(cache, body.SizeBytes, threshold)
+			}
+
+			cache.Bodies = append(cache.Bodies, body)
+		}
 	}
 
 	return saveEmailBodyCache(cache)

config/config.go 🔗

@@ -95,6 +95,16 @@ type Config struct {
 	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 	DateFormat           string        `json:"date_format,omitempty"`
 	Language             string        `json:"language,omitempty"` // Language code (e.g., "en", "es", "de")
+	BodyCacheThresholdMB int           `json:"body_cache_threshold_mb,omitempty"`
+}
+
+// GetBodyCacheThreshold returns the email body cache threshold in bytes.
+// It defaults to 500MB if unset or zero.
+func (c *Config) GetBodyCacheThreshold() int {
+	if c.BodyCacheThresholdMB <= 0 {
+		return 500 * 1024 * 1024
+	}
+	return c.BodyCacheThresholdMB * 1024 * 1024
 }
 
 // GetDateFormat returns the Go time reference layout translated from the
@@ -537,6 +547,7 @@ func LoadConfig() (*Config, error) {
 		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 		DateFormat           string        `json:"date_format,omitempty"`
 		Language             string        `json:"language,omitempty"`
+		BodyCacheThresholdMB int           `json:"body_cache_threshold_mb,omitempty"`
 	}
 
 	var raw diskConfig
@@ -572,6 +583,8 @@ func LoadConfig() (*Config, error) {
 	config.MailingLists = raw.MailingLists
 	config.DateFormat = raw.DateFormat
 	config.Language = raw.Language
+	config.BodyCacheThresholdMB = raw.BodyCacheThresholdMB
+
 	for _, rawAcc := range raw.Accounts {
 		acc := Account{
 			ID:                 rawAcc.ID,

docs/docs/Configuration.md 🔗

@@ -45,7 +45,8 @@ Configuration is stored in `~/.config/matcha/config.json`.
   "theme": "Matcha",
   "enable_split_pane": true,
   "disable_images": true,
-  "hide_tips": true
+  "hide_tips": true,
+  "body_cache_threshold_mb": 500
 }
 ```
 
@@ -53,6 +54,8 @@ Configuration is stored in `~/.config/matcha/config.json`.
 
 `enable_split_pane` enables a side-by-side view where the email list and the selected email are shown on the same screen.
 
+`body_cache_threshold_mb` sets the maximum size (in megabytes) for the local email body cache. When this limit is reached, older cached emails are evicted to make room for new ones. Defaults to `500` MB if not specified.
+
 ## Data Locations
 
 Configuration and persistent data are stored in `~/.config/matcha/`:

main.go 🔗

@@ -762,7 +762,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					AccountID:   msg.AccountID,
 					Body:        msg.Body,
 					Attachments: cachedAttachments,
-				})
+				}, m.config.GetBodyCacheThreshold())
 				if err != nil {
 					log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
 				}
@@ -1313,7 +1313,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			AccountID:   msg.AccountID,
 			Body:        msg.Body,
 			Attachments: cachedAttachments,
-		})
+		}, m.config.GetBodyCacheThreshold())
 
 		if err != nil {
 			log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)