Skip to content

Commit cd409ca

Browse files
authored
feat(config): add LRU eviction (#1227)
## 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
1 parent fba7663 commit cd409ca

4 files changed

Lines changed: 71 additions & 12 deletions

File tree

config/cache.go

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -422,11 +422,13 @@ type CachedAttachment struct {
422422

423423
// CachedEmailBody stores the body and attachment metadata for a single email.
424424
type CachedEmailBody struct {
425-
UID uint32 `json:"uid"`
426-
AccountID string `json:"account_id"`
427-
Body string `json:"body"`
428-
Attachments []CachedAttachment `json:"attachments,omitempty"`
429-
CachedAt time.Time `json:"cached_at"`
425+
UID uint32 `json:"uid"`
426+
AccountID string `json:"account_id"`
427+
Body string `json:"body"`
428+
Attachments []CachedAttachment `json:"attachments,omitempty"`
429+
CachedAt time.Time `json:"cached_at"`
430+
LastAccessedAt time.Time `json:"last_accessed_at"`
431+
SizeBytes int `json:"size_bytes"`
430432
}
431433

432434
// EmailBodyCache stores cached email bodies for a folder.
@@ -495,22 +497,57 @@ func GetCachedEmailBody(folderName string, uid uint32, accountID string) *Cached
495497
if err != nil {
496498
return nil
497499
}
498-
for _, b := range cache.Bodies {
500+
for i, b := range cache.Bodies {
499501
if b.UID == uid && b.AccountID == accountID {
500-
return &b
502+
cache.Bodies[i].LastAccessedAt = time.Now()
503+
_ = saveEmailBodyCache(cache)
504+
return &cache.Bodies[i]
501505
}
502506
}
503507
return nil
504508
}
505509

510+
func calculateEmailBodySize(body *CachedEmailBody) int {
511+
size := len(body.Body)
512+
for _, att := range body.Attachments {
513+
size += len(att.Filename)
514+
size += len(att.PartID)
515+
size += len(att.Encoding)
516+
size += len(att.MIMEType)
517+
size += len(att.ContentID)
518+
size += len(att.CalendarData)
519+
}
520+
return size
521+
}
522+
523+
func calculateTotalCacheSize(cache *EmailBodyCache) int {
524+
total := 0
525+
for _, b := range cache.Bodies {
526+
total += b.SizeBytes
527+
}
528+
return total
529+
}
530+
531+
func evict(cache *EmailBodyCache, newSize int, threshold int) {
532+
sort.Slice(cache.Bodies, func(i, j int) bool {
533+
return cache.Bodies[i].LastAccessedAt.Before(cache.Bodies[j].LastAccessedAt)
534+
})
535+
536+
for len(cache.Bodies) > 0 && calculateTotalCacheSize(cache)+newSize > threshold {
537+
cache.Bodies = cache.Bodies[1:]
538+
}
539+
}
540+
506541
// SaveEmailBody saves or updates a cached email body for a folder.
507-
func SaveEmailBody(folderName string, body CachedEmailBody) error {
542+
func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error {
508543
cache, err := LoadEmailBodyCache(folderName)
509544
if err != nil {
510545
cache = &EmailBodyCache{FolderName: folderName}
511546
}
512547

513548
body.CachedAt = time.Now()
549+
body.LastAccessedAt = time.Now()
550+
body.SizeBytes = calculateEmailBodySize(&body)
514551

515552
// Replace existing or append
516553
found := false
@@ -522,7 +559,13 @@ func SaveEmailBody(folderName string, body CachedEmailBody) error {
522559
}
523560
}
524561
if !found {
525-
cache.Bodies = append(cache.Bodies, body)
562+
if body.SizeBytes <= threshold {
563+
if calculateTotalCacheSize(cache)+body.SizeBytes > threshold {
564+
evict(cache, body.SizeBytes, threshold)
565+
}
566+
567+
cache.Bodies = append(cache.Bodies, body)
568+
}
526569
}
527570

528571
return saveEmailBodyCache(cache)

config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ type Config struct {
9595
MailingLists []MailingList `json:"mailing_lists,omitempty"`
9696
DateFormat string `json:"date_format,omitempty"`
9797
Language string `json:"language,omitempty"` // Language code (e.g., "en", "es", "de")
98+
BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
99+
}
100+
101+
// GetBodyCacheThreshold returns the email body cache threshold in bytes.
102+
// It defaults to 500MB if unset or zero.
103+
func (c *Config) GetBodyCacheThreshold() int {
104+
if c.BodyCacheThresholdMB <= 0 {
105+
return 500 * 1024 * 1024
106+
}
107+
return c.BodyCacheThresholdMB * 1024 * 1024
98108
}
99109

100110
// GetDateFormat returns the Go time reference layout translated from the
@@ -537,6 +547,7 @@ func LoadConfig() (*Config, error) {
537547
MailingLists []MailingList `json:"mailing_lists,omitempty"`
538548
DateFormat string `json:"date_format,omitempty"`
539549
Language string `json:"language,omitempty"`
550+
BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
540551
}
541552

542553
var raw diskConfig
@@ -572,6 +583,8 @@ func LoadConfig() (*Config, error) {
572583
config.MailingLists = raw.MailingLists
573584
config.DateFormat = raw.DateFormat
574585
config.Language = raw.Language
586+
config.BodyCacheThresholdMB = raw.BodyCacheThresholdMB
587+
575588
for _, rawAcc := range raw.Accounts {
576589
acc := Account{
577590
ID: rawAcc.ID,

docs/docs/Configuration.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,17 @@ Configuration is stored in `~/.config/matcha/config.json`.
4545
"theme": "Matcha",
4646
"enable_split_pane": true,
4747
"disable_images": true,
48-
"hide_tips": true
48+
"hide_tips": true,
49+
"body_cache_threshold_mb": 500
4950
}
5051
```
5152

5253
`send_as_email` is optional. When set, Matcha uses it for the outgoing `From` header while continuing to authenticate with the account's login address.
5354

5455
`enable_split_pane` enables a side-by-side view where the email list and the selected email are shown on the same screen.
5556

57+
`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.
58+
5659
## Data Locations
5760

5861
Configuration and persistent data are stored in `~/.config/matcha/`:

main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
762762
AccountID: msg.AccountID,
763763
Body: msg.Body,
764764
Attachments: cachedAttachments,
765-
})
765+
}, m.config.GetBodyCacheThreshold())
766766
if err != nil {
767767
log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)
768768
}
@@ -1313,7 +1313,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
13131313
AccountID: msg.AccountID,
13141314
Body: msg.Body,
13151315
Attachments: cachedAttachments,
1316-
})
1316+
}, m.config.GetBodyCacheThreshold())
13171317

13181318
if err != nil {
13191319
log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err)

0 commit comments

Comments
 (0)