Skip to content

Commit ee5b534

Browse files
committed
feat: add global email body cache eviction
1 parent 62f89db commit ee5b534

4 files changed

Lines changed: 302 additions & 19 deletions

File tree

config/cache.go

Lines changed: 136 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -531,14 +531,134 @@ func calculateTotalCacheSize(cache *EmailBodyCache) int {
531531
return total
532532
}
533533

534-
func evict(cache *EmailBodyCache, newSize int, threshold int) {
535-
sort.Slice(cache.Bodies, func(i, j int) bool {
536-
return cache.Bodies[i].LastAccessedAt.Before(cache.Bodies[j].LastAccessedAt)
534+
type bodyCacheFileState struct {
535+
path string
536+
cache EmailBodyCache
537+
}
538+
539+
type bodyCacheEntryRef struct {
540+
fileIndex int
541+
bodyIndex int
542+
}
543+
544+
func loadAllEmailBodyCaches() ([]bodyCacheFileState, error) {
545+
dir, err := bodyCacheDir()
546+
if err != nil {
547+
return nil, err
548+
}
549+
550+
entries, err := os.ReadDir(dir)
551+
if err != nil {
552+
if os.IsNotExist(err) {
553+
return nil, nil
554+
}
555+
return nil, err
556+
}
557+
558+
var caches []bodyCacheFileState
559+
for _, entry := range entries {
560+
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
561+
continue
562+
}
563+
564+
path := filepath.Join(dir, entry.Name())
565+
data, err := SecureReadFile(path)
566+
if err != nil {
567+
return nil, err
568+
}
569+
570+
var cache EmailBodyCache
571+
if err := json.Unmarshal(data, &cache); err != nil {
572+
return nil, err
573+
}
574+
for i := range cache.Bodies {
575+
if cache.Bodies[i].SizeBytes <= 0 {
576+
cache.Bodies[i].SizeBytes = calculateEmailBodySize(&cache.Bodies[i])
577+
}
578+
}
579+
580+
caches = append(caches, bodyCacheFileState{
581+
path: path,
582+
cache: cache,
583+
})
584+
}
585+
586+
return caches, nil
587+
}
588+
589+
func saveEmailBodyCacheFile(state *bodyCacheFileState) error {
590+
if err := os.MkdirAll(filepath.Dir(state.path), 0700); err != nil {
591+
return err
592+
}
593+
594+
state.cache.UpdatedAt = time.Now()
595+
data, err := json.Marshal(&state.cache)
596+
if err != nil {
597+
return err
598+
}
599+
return SecureWriteFile(state.path, data, 0600)
600+
}
601+
602+
func pruneEmailBodyCacheSize(threshold int) error {
603+
if threshold <= 0 {
604+
return nil
605+
}
606+
607+
caches, err := loadAllEmailBodyCaches()
608+
if err != nil {
609+
return err
610+
}
611+
612+
totalSize := 0
613+
var refs []bodyCacheEntryRef
614+
for fileIndex := range caches {
615+
for bodyIndex, body := range caches[fileIndex].cache.Bodies {
616+
totalSize += body.SizeBytes
617+
refs = append(refs, bodyCacheEntryRef{
618+
fileIndex: fileIndex,
619+
bodyIndex: bodyIndex,
620+
})
621+
}
622+
}
623+
if totalSize <= threshold {
624+
return nil
625+
}
626+
627+
sort.Slice(refs, func(i, j int) bool {
628+
left := caches[refs[i].fileIndex].cache.Bodies[refs[i].bodyIndex]
629+
right := caches[refs[j].fileIndex].cache.Bodies[refs[j].bodyIndex]
630+
return left.LastAccessedAt.Before(right.LastAccessedAt)
537631
})
538632

539-
for len(cache.Bodies) > 0 && calculateTotalCacheSize(cache)+newSize > threshold {
540-
cache.Bodies = cache.Bodies[1:]
633+
remove := make(map[int]map[int]struct{})
634+
for _, ref := range refs {
635+
if totalSize <= threshold {
636+
break
637+
}
638+
639+
body := caches[ref.fileIndex].cache.Bodies[ref.bodyIndex]
640+
totalSize -= body.SizeBytes
641+
if remove[ref.fileIndex] == nil {
642+
remove[ref.fileIndex] = make(map[int]struct{})
643+
}
644+
remove[ref.fileIndex][ref.bodyIndex] = struct{}{}
645+
}
646+
647+
for fileIndex, bodyIndexes := range remove {
648+
bodies := caches[fileIndex].cache.Bodies
649+
kept := bodies[:0]
650+
for bodyIndex, body := range bodies {
651+
if _, ok := bodyIndexes[bodyIndex]; !ok {
652+
kept = append(kept, body)
653+
}
654+
}
655+
caches[fileIndex].cache.Bodies = kept
656+
if err := saveEmailBodyCacheFile(&caches[fileIndex]); err != nil {
657+
return err
658+
}
541659
}
660+
661+
return nil
542662
}
543663

544664
// SaveEmailBody saves or updates a cached email body for a folder.
@@ -556,22 +676,23 @@ func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error
556676
found := false
557677
for i, b := range cache.Bodies {
558678
if b.UID == body.UID && b.AccountID == body.AccountID {
559-
cache.Bodies[i] = body
679+
if body.SizeBytes <= threshold {
680+
cache.Bodies[i] = body
681+
} else {
682+
cache.Bodies = append(cache.Bodies[:i], cache.Bodies[i+1:]...)
683+
}
560684
found = true
561685
break
562686
}
563687
}
564-
if !found {
565-
if body.SizeBytes <= threshold {
566-
if calculateTotalCacheSize(cache)+body.SizeBytes > threshold {
567-
evict(cache, body.SizeBytes, threshold)
568-
}
569-
570-
cache.Bodies = append(cache.Bodies, body)
571-
}
688+
if !found && body.SizeBytes <= threshold {
689+
cache.Bodies = append(cache.Bodies, body)
572690
}
573691

574-
return saveEmailBodyCache(cache)
692+
if err := saveEmailBodyCache(cache); err != nil {
693+
return err
694+
}
695+
return pruneEmailBodyCacheSize(threshold)
575696
}
576697

577698
// PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder.

config/cache_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package config
2+
3+
import (
4+
"reflect"
5+
"strings"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestSaveEmailBodyEvictsLeastRecentlyAccessedAcrossFolders(t *testing.T) {
11+
folderCacheTestSetup(t)
12+
13+
oldTime := time.Now().Add(-2 * time.Hour)
14+
recentTime := time.Now().Add(-1 * time.Hour)
15+
16+
if err := saveEmailBodyCache(&EmailBodyCache{
17+
FolderName: "INBOX",
18+
Bodies: []CachedEmailBody{
19+
{
20+
UID: 1,
21+
AccountID: "acct",
22+
Body: strings.Repeat("a", 10),
23+
SizeBytes: 10,
24+
CachedAt: oldTime,
25+
LastAccessedAt: oldTime,
26+
},
27+
},
28+
}); err != nil {
29+
t.Fatalf("save old cache: %v", err)
30+
}
31+
32+
if err := saveEmailBodyCache(&EmailBodyCache{
33+
FolderName: "Archive",
34+
Bodies: []CachedEmailBody{
35+
{
36+
UID: 2,
37+
AccountID: "acct",
38+
Body: strings.Repeat("b", 10),
39+
SizeBytes: 10,
40+
CachedAt: recentTime,
41+
LastAccessedAt: recentTime,
42+
},
43+
},
44+
}); err != nil {
45+
t.Fatalf("save recent cache: %v", err)
46+
}
47+
48+
if err := SaveEmailBody("Sent", CachedEmailBody{
49+
UID: 3,
50+
AccountID: "acct",
51+
Body: strings.Repeat("c", 10),
52+
}, 20); err != nil {
53+
t.Fatalf("SaveEmailBody: %v", err)
54+
}
55+
56+
inbox, err := LoadEmailBodyCache("INBOX")
57+
if err != nil {
58+
t.Fatalf("LoadEmailBodyCache(INBOX): %v", err)
59+
}
60+
if len(inbox.Bodies) != 0 {
61+
t.Fatalf("oldest INBOX body should be evicted, got %d bodies", len(inbox.Bodies))
62+
}
63+
64+
archive, err := LoadEmailBodyCache("Archive")
65+
if err != nil {
66+
t.Fatalf("LoadEmailBodyCache(Archive): %v", err)
67+
}
68+
if len(archive.Bodies) != 1 || archive.Bodies[0].UID != 2 {
69+
t.Fatalf("recent Archive body should remain, got %+v", archive.Bodies)
70+
}
71+
72+
sent, err := LoadEmailBodyCache("Sent")
73+
if err != nil {
74+
t.Fatalf("LoadEmailBodyCache(Sent): %v", err)
75+
}
76+
if len(sent.Bodies) != 1 || sent.Bodies[0].UID != 3 {
77+
t.Fatalf("new Sent body should remain, got %+v", sent.Bodies)
78+
}
79+
}
80+
81+
func TestSaveEmailBodyEvictsMultipleEntriesUntilUnderLimit(t *testing.T) {
82+
folderCacheTestSetup(t)
83+
84+
now := time.Now()
85+
bodies := make([]CachedEmailBody, 0, 4)
86+
for i := 1; i <= 4; i++ {
87+
accessedAt := now.Add(-time.Duration(5-i) * time.Minute)
88+
bodies = append(bodies, CachedEmailBody{
89+
UID: uint32(i),
90+
AccountID: "acct",
91+
Body: strings.Repeat(string(rune('a'+i-1)), 10),
92+
SizeBytes: 10,
93+
CachedAt: accessedAt,
94+
LastAccessedAt: accessedAt,
95+
})
96+
}
97+
98+
if err := saveEmailBodyCache(&EmailBodyCache{
99+
FolderName: "INBOX",
100+
Bodies: bodies,
101+
}); err != nil {
102+
t.Fatalf("save cache: %v", err)
103+
}
104+
105+
if err := SaveEmailBody("Archive", CachedEmailBody{
106+
UID: 5,
107+
AccountID: "acct",
108+
Body: strings.Repeat("e", 30),
109+
}, 50); err != nil {
110+
t.Fatalf("SaveEmailBody: %v", err)
111+
}
112+
113+
inbox, err := LoadEmailBodyCache("INBOX")
114+
if err != nil {
115+
t.Fatalf("LoadEmailBodyCache(INBOX): %v", err)
116+
}
117+
118+
gotUIDs := make([]uint32, 0, len(inbox.Bodies))
119+
for _, body := range inbox.Bodies {
120+
gotUIDs = append(gotUIDs, body.UID)
121+
}
122+
wantUIDs := []uint32{3, 4}
123+
if !reflect.DeepEqual(gotUIDs, wantUIDs) {
124+
t.Fatalf("remaining INBOX UIDs = %v, want %v", gotUIDs, wantUIDs)
125+
}
126+
127+
archive, err := LoadEmailBodyCache("Archive")
128+
if err != nil {
129+
t.Fatalf("LoadEmailBodyCache(Archive): %v", err)
130+
}
131+
if len(archive.Bodies) != 1 || archive.Bodies[0].UID != 5 {
132+
t.Fatalf("new Archive body should remain, got %+v", archive.Bodies)
133+
}
134+
}
135+
136+
func TestSaveEmailBodyDropsOversizedReplacement(t *testing.T) {
137+
folderCacheTestSetup(t)
138+
139+
if err := SaveEmailBody("INBOX", CachedEmailBody{
140+
UID: 1,
141+
AccountID: "acct",
142+
Body: strings.Repeat("a", 10),
143+
}, 20); err != nil {
144+
t.Fatalf("initial SaveEmailBody: %v", err)
145+
}
146+
147+
if err := SaveEmailBody("INBOX", CachedEmailBody{
148+
UID: 1,
149+
AccountID: "acct",
150+
Body: strings.Repeat("b", 25),
151+
}, 20); err != nil {
152+
t.Fatalf("oversized SaveEmailBody: %v", err)
153+
}
154+
155+
cache, err := LoadEmailBodyCache("INBOX")
156+
if err != nil {
157+
t.Fatalf("LoadEmailBodyCache: %v", err)
158+
}
159+
if len(cache.Bodies) != 0 {
160+
t.Fatalf("oversized replacement should not remain cached, got %+v", cache.Bodies)
161+
}
162+
}

config/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,10 @@ type Config struct {
100100
}
101101

102102
// GetBodyCacheThreshold returns the email body cache threshold in bytes.
103-
// It defaults to 500MB if unset or zero.
103+
// It defaults to 100MB if unset or zero.
104104
func (c *Config) GetBodyCacheThreshold() int {
105105
if c.BodyCacheThresholdMB <= 0 {
106-
return 500 * 1024 * 1024
106+
return 100 * 1024 * 1024
107107
}
108108
return c.BodyCacheThresholdMB * 1024 * 1024
109109
}

docs/docs/Configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,15 @@ Configuration is stored in `~/.config/matcha/config.json`.
4646
"enable_split_pane": true,
4747
"disable_images": true,
4848
"hide_tips": true,
49-
"body_cache_threshold_mb": 500
49+
"body_cache_threshold_mb": 100
5050
}
5151
```
5252

5353
`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.
5454

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

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.
57+
`body_cache_threshold_mb` sets the maximum size (in megabytes) for the local email body cache. When this limit is reached, least recently accessed cached emails are evicted across all folders to make room for new ones. Defaults to `100` MB if not specified.
5858

5959
## Data Locations
6060

0 commit comments

Comments
 (0)