cache.go

  1package config
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"os"
  7	"path/filepath"
  8	"sort"
  9	"strings"
 10	"time"
 11)
 12
 13// CachedEmail stores essential email data for caching.
 14type CachedEmail struct {
 15	UID        uint32    `json:"uid"`
 16	From       string    `json:"from"`
 17	To         []string  `json:"to"`
 18	Subject    string    `json:"subject"`
 19	Date       time.Time `json:"date"`
 20	MessageID  string    `json:"message_id"`
 21	InReplyTo  string    `json:"in_reply_to,omitempty"`
 22	References []string  `json:"references,omitempty"`
 23	AccountID  string    `json:"account_id"`
 24	IsRead     bool      `json:"is_read"`
 25}
 26
 27// EmailCache stores cached emails for all accounts.
 28type EmailCache struct {
 29	Emails    []CachedEmail `json:"emails"`
 30	UpdatedAt time.Time     `json:"updated_at"`
 31}
 32
 33// cacheFile returns the full path to the email cache file.
 34func cacheFile() (string, error) {
 35	dir, err := cacheDir()
 36	if err != nil {
 37		return "", err
 38	}
 39	return filepath.Join(dir, "email_cache.json"), nil
 40}
 41
 42// SaveEmailCache saves emails to the cache file.
 43func SaveEmailCache(cache *EmailCache) error {
 44	path, err := cacheFile()
 45	if err != nil {
 46		return err
 47	}
 48	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
 49		return err
 50	}
 51	cache.UpdatedAt = time.Now()
 52	data, err := json.MarshalIndent(cache, "", "  ")
 53	if err != nil {
 54		return err
 55	}
 56	return SecureWriteFile(path, data, 0600)
 57}
 58
 59// LoadEmailCache loads emails from the cache file.
 60func LoadEmailCache() (*EmailCache, error) {
 61	path, err := cacheFile()
 62	if err != nil {
 63		return nil, err
 64	}
 65	data, err := SecureReadFile(path)
 66	if err != nil {
 67		return nil, err
 68	}
 69	var cache EmailCache
 70	if err := json.Unmarshal(data, &cache); err != nil {
 71		return nil, err
 72	}
 73	return &cache, nil
 74}
 75
 76// HasEmailCache checks if a cache file exists.
 77func HasEmailCache() bool {
 78	path, err := cacheFile()
 79	if err != nil {
 80		return false
 81	}
 82	_, err = os.Stat(path)
 83	return err == nil
 84}
 85
 86// ClearEmailCache removes the cache file.
 87func ClearEmailCache() error {
 88	path, err := cacheFile()
 89	if err != nil {
 90		return err
 91	}
 92	return os.Remove(path)
 93}
 94
 95func removeAccountFromEmailCache(accountID string) error {
 96	cache, err := LoadEmailCache()
 97	if err != nil {
 98		if os.IsNotExist(err) {
 99			return nil
100		}
101		return err
102	}
103	filtered := cache.Emails[:0]
104	for _, email := range cache.Emails {
105		if email.AccountID != accountID {
106			filtered = append(filtered, email)
107		}
108	}
109	if len(filtered) == len(cache.Emails) {
110		return nil
111	}
112	cache.Emails = filtered
113	return SaveEmailCache(cache)
114}
115
116// --- Contacts Cache ---
117
118const legacyContactUsageKey = "__legacy__"
119
120// ContactUsage stores per-account contact usage metadata.
121type ContactUsage struct {
122	LastUsed time.Time `json:"last_used"`
123	UseCount int       `json:"use_count"`
124}
125
126// Contact stores a contact's name, email address, and per-account usage.
127type Contact struct {
128	Name  string                  `json:"name"`
129	Email string                  `json:"email"`
130	Usage map[string]ContactUsage `json:"usage_by_account"`
131}
132
133// UnmarshalJSON accepts both the current usage_by_account format and the
134// legacy last_used/use_count fields so old contacts can be migrated.
135func (c *Contact) UnmarshalJSON(data []byte) error {
136	type contactAlias Contact
137	aux := struct {
138		*contactAlias
139		LastUsed time.Time `json:"last_used"`
140		UseCount int       `json:"use_count"`
141	}{
142		contactAlias: (*contactAlias)(c),
143	}
144	if err := json.Unmarshal(data, &aux); err != nil {
145		return err
146	}
147	if c.Usage == nil {
148		c.Usage = make(map[string]ContactUsage)
149	}
150	if len(c.Usage) == 0 && (!aux.LastUsed.IsZero() || aux.UseCount > 0) {
151		c.Usage[legacyContactUsageKey] = ContactUsage{
152			LastUsed: aux.LastUsed,
153			UseCount: aux.UseCount,
154		}
155	}
156	return nil
157}
158
159// ContactsCache stores all known contacts.
160type ContactsCache struct {
161	Contacts  []Contact `json:"contacts"`
162	UpdatedAt time.Time `json:"updated_at"`
163}
164
165// GetContactsCachePath returns the full path to the contacts cache file.
166func GetContactsCachePath() (string, error) {
167	dir, err := cacheDir()
168	if err != nil {
169		return "", err
170	}
171	return filepath.Join(dir, "contacts.json"), nil
172}
173
174// SaveContactsCache saves contacts to the cache file.
175func SaveContactsCache(cache *ContactsCache) error {
176	path, err := GetContactsCachePath()
177	if err != nil {
178		return err
179	}
180	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
181		return err
182	}
183	for i := range cache.Contacts {
184		if cache.Contacts[i].Usage == nil {
185			cache.Contacts[i].Usage = make(map[string]ContactUsage)
186		}
187	}
188	cache.UpdatedAt = time.Now()
189	data, err := json.MarshalIndent(cache, "", "  ")
190	if err != nil {
191		return err
192	}
193	return SecureWriteFile(path, data, 0600)
194}
195
196// LoadContactsCache loads contacts from the cache file.
197func LoadContactsCache() (*ContactsCache, error) {
198	path, err := GetContactsCachePath()
199	if err != nil {
200		return nil, err
201	}
202	data, err := SecureReadFile(path)
203	if err != nil {
204		return nil, err
205	}
206	var cache ContactsCache
207	if err := json.Unmarshal(data, &cache); err != nil {
208		return nil, err
209	}
210	return &cache, nil
211}
212
213func normalizeContactEmail(email string) string {
214	return strings.ToLower(strings.Trim(strings.TrimSpace(email), ",<>"))
215}
216
217// AddContact adds or updates a global contact in the cache.
218func AddContact(name, email string) error {
219	return AddContactForAccount(name, email, "")
220}
221
222// AddContactForAccount adds or updates a contact in the cache for an account.
223func AddContactForAccount(name, email, accountID string) error {
224	if email == "" {
225		return nil
226	}
227
228	email = normalizeContactEmail(email)
229	name = strings.TrimSpace(name)
230
231	cache, err := LoadContactsCache()
232	if err != nil {
233		cache = &ContactsCache{Contacts: []Contact{}}
234	}
235
236	// Check if contact exists
237	found := false
238	for i, c := range cache.Contacts {
239		if strings.EqualFold(c.Email, email) {
240			// Normalize the stored email to a canonical lowercase form.
241			cache.Contacts[i].Email = email
242			if cache.Contacts[i].Usage == nil {
243				cache.Contacts[i].Usage = make(map[string]ContactUsage)
244			}
245			usage := cache.Contacts[i].Usage[accountID]
246			usage.UseCount++
247			usage.LastUsed = time.Now()
248			cache.Contacts[i].Usage[accountID] = usage
249			// Update name if we have a better one
250			if name != "" && (c.Name == "" || c.Name == email) {
251				cache.Contacts[i].Name = name
252			}
253			found = true
254			break
255		}
256	}
257
258	if !found {
259		cache.Contacts = append(cache.Contacts, Contact{
260			Name:  name,
261			Email: email,
262			Usage: map[string]ContactUsage{
263				accountID: {
264					LastUsed: time.Now(),
265					UseCount: 1,
266				},
267			},
268		})
269	}
270
271	return SaveContactsCache(cache)
272}
273
274func contactUsageForAccount(c Contact, accountID string) (ContactUsage, bool) {
275	if len(c.Usage) == 0 {
276		return ContactUsage{}, accountID == ""
277	}
278	if accountID != "" {
279		if usage, ok := c.Usage[legacyContactUsageKey]; ok {
280			return usage, true
281		}
282		usage, ok := c.Usage[accountID]
283		return usage, ok
284	}
285	var aggregate ContactUsage
286	for _, usage := range c.Usage {
287		aggregate.UseCount += usage.UseCount
288		if usage.LastUsed.After(aggregate.LastUsed) {
289			aggregate.LastUsed = usage.LastUsed
290		}
291	}
292	return aggregate, true
293}
294
295// ContactAggregateUsage returns a contact's total usage across accounts.
296func ContactAggregateUsage(c Contact) ContactUsage {
297	usage, _ := contactUsageForAccount(c, "")
298	return usage
299}
300
301// SearchContacts searches for contacts matching the query across all accounts.
302func SearchContacts(query string) []Contact {
303	return SearchContactsForAccount(query, "")
304}
305
306// SearchContactsForAccount searches for contacts matching the query for an account.
307func SearchContactsForAccount(query, accountID string) []Contact {
308	cache, err := LoadContactsCache()
309	if err != nil {
310		return nil
311	}
312
313	query = strings.ToLower(strings.TrimSpace(query))
314	if query == "" {
315		return nil
316	}
317
318	var matches []Contact
319
320	// Add mailing lists to matches if they match the query
321	cfg, err := LoadConfig()
322	if err == nil {
323		for _, list := range cfg.MailingLists {
324			if strings.Contains(strings.ToLower(list.Name), query) {
325				// Convert mailing list to a virtual contact
326				matches = append(matches, Contact{
327					Name:  list.Name,
328					Email: strings.Join(list.Addresses, ", "),
329					Usage: map[string]ContactUsage{
330						accountID: {
331							UseCount: 9999, // Ensure lists appear at the top
332							LastUsed: time.Now(),
333						},
334					},
335				})
336			}
337		}
338	}
339
340	for _, c := range cache.Contacts {
341		if strings.Contains(strings.ToLower(c.Email), query) ||
342			strings.Contains(strings.ToLower(c.Name), query) {
343			if _, ok := contactUsageForAccount(c, accountID); ok {
344				matches = append(matches, c)
345			}
346		}
347	}
348
349	// Sort by use count (most used first), then by last used
350	sort.Slice(matches, func(i, j int) bool {
351		left, _ := contactUsageForAccount(matches[i], accountID)
352		right, _ := contactUsageForAccount(matches[j], accountID)
353		if left.UseCount != right.UseCount {
354			return left.UseCount > right.UseCount
355		}
356		return left.LastUsed.After(right.LastUsed)
357	})
358
359	// Limit to 5 suggestions
360	if len(matches) > 5 {
361		matches = matches[:5]
362	}
363
364	return matches
365}
366
367// MigrateContactsCacheUsage expands legacy global contact usage to all accounts.
368func MigrateContactsCacheUsage(accountIDs []string) error {
369	cache, err := LoadContactsCache()
370	if err != nil {
371		return nil
372	}
373
374	changed := false
375	for i := range cache.Contacts {
376		if cache.Contacts[i].Usage == nil {
377			cache.Contacts[i].Usage = make(map[string]ContactUsage)
378			changed = true
379		}
380		legacyUsage, hasLegacy := cache.Contacts[i].Usage[legacyContactUsageKey]
381		if !hasLegacy {
382			continue
383		}
384		delete(cache.Contacts[i].Usage, legacyContactUsageKey)
385		for _, accountID := range accountIDs {
386			if accountID == "" {
387				continue
388			}
389			if _, ok := cache.Contacts[i].Usage[accountID]; !ok {
390				cache.Contacts[i].Usage[accountID] = legacyUsage
391			}
392		}
393		changed = true
394	}
395	if !changed {
396		return nil
397	}
398	return SaveContactsCache(cache)
399}
400
401func removeAccountFromContactsCache(accountID string) error {
402	cache, err := LoadContactsCache()
403	if err != nil {
404		if os.IsNotExist(err) {
405			return nil
406		}
407		return err
408	}
409
410	changed := false
411	filtered := cache.Contacts[:0]
412	for _, contact := range cache.Contacts {
413		if _, ok := contact.Usage[accountID]; ok {
414			delete(contact.Usage, accountID)
415			changed = true
416		}
417		if len(contact.Usage) > 0 {
418			filtered = append(filtered, contact)
419		} else {
420			changed = true
421		}
422	}
423	if !changed {
424		return nil
425	}
426	cache.Contacts = filtered
427	return SaveContactsCache(cache)
428}
429
430// --- Drafts Cache ---
431
432// Draft stores a saved email draft.
433type Draft struct {
434	ID              string    `json:"id"`
435	To              string    `json:"to"`
436	Cc              string    `json:"cc,omitempty"`
437	Bcc             string    `json:"bcc,omitempty"`
438	Subject         string    `json:"subject"`
439	Body            string    `json:"body"`
440	AttachmentPaths []string  `json:"attachment_paths,omitempty"`
441	AccountID       string    `json:"account_id"`
442	FromOverride    string    `json:"from_override,omitempty"`
443	InReplyTo       string    `json:"in_reply_to,omitempty"`
444	References      []string  `json:"references,omitempty"`
445	QuotedText      string    `json:"quoted_text,omitempty"`
446	CreatedAt       time.Time `json:"created_at"`
447	UpdatedAt       time.Time `json:"updated_at"`
448}
449
450// DraftsCache stores all saved drafts.
451type DraftsCache struct {
452	Drafts    []Draft   `json:"drafts"`
453	UpdatedAt time.Time `json:"updated_at"`
454}
455
456// draftsFile returns the full path to the drafts cache file.
457func draftsFile() (string, error) {
458	dir, err := cacheDir()
459	if err != nil {
460		return "", err
461	}
462	return filepath.Join(dir, "drafts.json"), nil
463}
464
465// SaveDraftsCache saves drafts to the cache file.
466func SaveDraftsCache(cache *DraftsCache) error {
467	path, err := draftsFile()
468	if err != nil {
469		return err
470	}
471	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
472		return err
473	}
474	cache.UpdatedAt = time.Now()
475	data, err := json.MarshalIndent(cache, "", "  ")
476	if err != nil {
477		return err
478	}
479	return SecureWriteFile(path, data, 0600)
480}
481
482// LoadDraftsCache loads drafts from the cache file.
483func LoadDraftsCache() (*DraftsCache, error) {
484	path, err := draftsFile()
485	if err != nil {
486		return nil, err
487	}
488	data, err := SecureReadFile(path)
489	if err != nil {
490		return nil, err
491	}
492	var cache DraftsCache
493	if err := json.Unmarshal(data, &cache); err != nil {
494		return nil, err
495	}
496	return &cache, nil
497}
498
499// SaveDraft saves or updates a draft.
500func SaveDraft(draft Draft) error {
501	cache, err := LoadDraftsCache()
502	if err != nil {
503		cache = &DraftsCache{Drafts: []Draft{}}
504	}
505
506	draft.UpdatedAt = time.Now()
507
508	// Check if draft exists (update) or is new
509	found := false
510	for i, d := range cache.Drafts {
511		if d.ID == draft.ID {
512			cache.Drafts[i] = draft
513			found = true
514			break
515		}
516	}
517
518	if !found {
519		if draft.CreatedAt.IsZero() {
520			draft.CreatedAt = time.Now()
521		}
522		cache.Drafts = append(cache.Drafts, draft)
523	}
524
525	return SaveDraftsCache(cache)
526}
527
528// DeleteDraft removes a draft by ID.
529func DeleteDraft(id string) error {
530	cache, err := LoadDraftsCache()
531	if err != nil {
532		return nil // No cache, nothing to delete
533	}
534
535	var filtered []Draft
536	for _, d := range cache.Drafts {
537		if d.ID != id {
538			filtered = append(filtered, d)
539		}
540	}
541	cache.Drafts = filtered
542
543	return SaveDraftsCache(cache)
544}
545
546// GetDraft retrieves a draft by ID.
547func GetDraft(id string) *Draft {
548	cache, err := LoadDraftsCache()
549	if err != nil {
550		return nil
551	}
552
553	for _, d := range cache.Drafts {
554		if d.ID == id {
555			return &d
556		}
557	}
558	return nil
559}
560
561// GetAllDrafts retrieves all drafts sorted by update time (newest first).
562func GetAllDrafts() []Draft {
563	cache, err := LoadDraftsCache()
564	if err != nil {
565		return nil
566	}
567
568	drafts := cache.Drafts
569	sort.Slice(drafts, func(i, j int) bool {
570		return drafts[i].UpdatedAt.After(drafts[j].UpdatedAt)
571	})
572
573	return drafts
574}
575
576// HasDrafts checks if there are any saved drafts.
577func HasDrafts() bool {
578	cache, err := LoadDraftsCache()
579	if err != nil {
580		return false
581	}
582	return len(cache.Drafts) > 0
583}
584
585func removeAccountFromDraftsCache(accountID string) error {
586	cache, err := LoadDraftsCache()
587	if err != nil {
588		if os.IsNotExist(err) {
589			return nil
590		}
591		return err
592	}
593	filtered := cache.Drafts[:0]
594	for _, draft := range cache.Drafts {
595		if draft.AccountID != accountID {
596			filtered = append(filtered, draft)
597		}
598	}
599	if len(filtered) == len(cache.Drafts) {
600		return nil
601	}
602	cache.Drafts = filtered
603	return SaveDraftsCache(cache)
604}
605
606// --- Email Body Cache ---
607
608// CachedAttachment stores attachment metadata (not the binary data).
609type CachedAttachment struct {
610	Filename         string `json:"filename"`
611	PartID           string `json:"part_id"`
612	Encoding         string `json:"encoding,omitempty"`
613	MIMEType         string `json:"mime_type,omitempty"`
614	ContentID        string `json:"content_id,omitempty"`
615	Inline           bool   `json:"inline,omitempty"`
616	IsSMIMESignature bool   `json:"is_smime_signature,omitempty"`
617	SMIMEVerified    bool   `json:"smime_verified,omitempty"`
618	IsSMIMEEncrypted bool   `json:"is_smime_encrypted,omitempty"`
619	IsCalendarInvite bool   `json:"is_calendar_invite,omitempty"`
620	CalendarData     []byte `json:"calendar_data,omitempty"` // Raw .ics data for calendar invites
621}
622
623// CachedEmailBody stores the body and attachment metadata for a single email.
624type CachedEmailBody struct {
625	UID            uint32             `json:"uid"`
626	AccountID      string             `json:"account_id"`
627	Body           string             `json:"body"`
628	BodyMIMEType   string             `json:"body_mime_type,omitempty"` // empty for cache rows written before MIME-type tracking; renderer falls back to legacy markdown→HTML pre-pass
629	Attachments    []CachedAttachment `json:"attachments,omitempty"`
630	CachedAt       time.Time          `json:"cached_at"`
631	LastAccessedAt time.Time          `json:"last_accessed_at"`
632	SizeBytes      int                `json:"size_bytes"`
633}
634
635// EmailBodyCache stores cached email bodies for a folder.
636type EmailBodyCache struct {
637	FolderName string            `json:"folder_name"`
638	Bodies     []CachedEmailBody `json:"bodies"`
639	UpdatedAt  time.Time         `json:"updated_at"`
640}
641
642// bodyCacheDir returns the directory for body cache files.
643func bodyCacheDir() (string, error) {
644	dir, err := cacheDir()
645	if err != nil {
646		return "", err
647	}
648	return filepath.Join(dir, "email_bodies"), nil
649}
650
651// bodyBacheFile returns the file path for a folder's body cache.
652func bodyCacheFile(folderName string) (string, error) {
653	dir, err := bodyCacheDir()
654	if err != nil {
655		return "", err
656	}
657	safe := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_").Replace(folderName)
658	return filepath.Join(dir, safe+".json"), nil
659}
660
661// LoadEmailBodyCache loads the body cache for a folder.
662func LoadEmailBodyCache(folderName string) (*EmailBodyCache, error) {
663	path, err := bodyCacheFile(folderName)
664	if err != nil {
665		return nil, err
666	}
667	data, err := SecureReadFile(path)
668	if err != nil {
669		return nil, err
670	}
671	var cache EmailBodyCache
672	if err := json.Unmarshal(data, &cache); err != nil {
673		return nil, err
674	}
675	return &cache, nil
676}
677
678// saveEmailBodyCache writes the body cache for a folder.
679func saveEmailBodyCache(cache *EmailBodyCache) error {
680	path, err := bodyCacheFile(cache.FolderName)
681	if err != nil {
682		return err
683	}
684	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
685		return err
686	}
687	cache.UpdatedAt = time.Now()
688	data, err := json.Marshal(cache)
689	if err != nil {
690		return err
691	}
692	return SecureWriteFile(path, data, 0600)
693}
694
695// GetCachedEmailBody returns the cached body for a specific email, or nil if not cached.
696// LastAccessedAt is updated by SaveEmailBody, not here -- a read should not
697// mutate cache state.
698func GetCachedEmailBody(folderName string, uid uint32, accountID string) *CachedEmailBody {
699	cache, err := LoadEmailBodyCache(folderName)
700	if err != nil {
701		return nil
702	}
703	for i, b := range cache.Bodies {
704		if b.UID == uid && b.AccountID == accountID {
705			return &cache.Bodies[i]
706		}
707	}
708	return nil
709}
710
711func calculateEmailBodySize(body *CachedEmailBody) int {
712	size := len(body.Body)
713	for _, att := range body.Attachments {
714		size += len(att.Filename)
715		size += len(att.PartID)
716		size += len(att.Encoding)
717		size += len(att.MIMEType)
718		size += len(att.ContentID)
719		size += len(att.CalendarData)
720	}
721	return size
722}
723
724func calculateTotalCacheSize(cache *EmailBodyCache) int {
725	total := 0
726	for _, b := range cache.Bodies {
727		total += b.SizeBytes
728	}
729	return total
730}
731
732type bodyCacheFileState struct {
733	path  string
734	cache EmailBodyCache
735}
736
737type bodyCacheEntryRef struct {
738	fileIndex int
739	bodyIndex int
740}
741
742func loadAllEmailBodyCaches() ([]bodyCacheFileState, error) {
743	dir, err := bodyCacheDir()
744	if err != nil {
745		return nil, err
746	}
747
748	entries, err := os.ReadDir(dir)
749	if err != nil {
750		if os.IsNotExist(err) {
751			return nil, nil
752		}
753		return nil, err
754	}
755
756	var caches []bodyCacheFileState
757	for _, entry := range entries {
758		if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
759			continue
760		}
761
762		path := filepath.Join(dir, entry.Name())
763		data, err := SecureReadFile(path)
764		if err != nil {
765			return nil, err
766		}
767
768		var cache EmailBodyCache
769		if err := json.Unmarshal(data, &cache); err != nil {
770			return nil, err
771		}
772		for i := range cache.Bodies {
773			if cache.Bodies[i].SizeBytes <= 0 {
774				cache.Bodies[i].SizeBytes = calculateEmailBodySize(&cache.Bodies[i])
775			}
776		}
777
778		caches = append(caches, bodyCacheFileState{
779			path:  path,
780			cache: cache,
781		})
782	}
783
784	return caches, nil
785}
786
787func saveEmailBodyCacheFile(state *bodyCacheFileState) error {
788	if err := os.MkdirAll(filepath.Dir(state.path), 0700); err != nil {
789		return err
790	}
791
792	state.cache.UpdatedAt = time.Now()
793	data, err := json.Marshal(&state.cache)
794	if err != nil {
795		return err
796	}
797	return SecureWriteFile(state.path, data, 0600)
798}
799
800func pruneEmailBodyCacheSize(threshold int) error {
801	if threshold <= 0 {
802		return nil
803	}
804
805	caches, err := loadAllEmailBodyCaches()
806	if err != nil {
807		return err
808	}
809
810	totalSize := 0
811	var refs []bodyCacheEntryRef
812	for fileIndex := range caches {
813		for bodyIndex, body := range caches[fileIndex].cache.Bodies {
814			totalSize += body.SizeBytes
815			refs = append(refs, bodyCacheEntryRef{
816				fileIndex: fileIndex,
817				bodyIndex: bodyIndex,
818			})
819		}
820	}
821	if totalSize <= threshold {
822		return nil
823	}
824
825	sort.Slice(refs, func(i, j int) bool {
826		left := caches[refs[i].fileIndex].cache.Bodies[refs[i].bodyIndex]
827		right := caches[refs[j].fileIndex].cache.Bodies[refs[j].bodyIndex]
828		return left.LastAccessedAt.Before(right.LastAccessedAt)
829	})
830
831	remove := make(map[int]map[int]struct{})
832	for _, ref := range refs {
833		if totalSize <= threshold {
834			break
835		}
836
837		body := caches[ref.fileIndex].cache.Bodies[ref.bodyIndex]
838		totalSize -= body.SizeBytes
839		if remove[ref.fileIndex] == nil {
840			remove[ref.fileIndex] = make(map[int]struct{})
841		}
842		remove[ref.fileIndex][ref.bodyIndex] = struct{}{}
843	}
844
845	for fileIndex, bodyIndexes := range remove {
846		bodies := caches[fileIndex].cache.Bodies
847		kept := bodies[:0]
848		for bodyIndex, body := range bodies {
849			if _, ok := bodyIndexes[bodyIndex]; !ok {
850				kept = append(kept, body)
851			}
852		}
853		caches[fileIndex].cache.Bodies = kept
854		if err := saveEmailBodyCacheFile(&caches[fileIndex]); err != nil {
855			return err
856		}
857	}
858
859	return nil
860}
861
862// SaveEmailBody saves or updates a cached email body for a folder.
863func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error {
864	cache, err := LoadEmailBodyCache(folderName)
865	if err != nil {
866		cache = &EmailBodyCache{FolderName: folderName}
867	}
868
869	body.CachedAt = time.Now()
870	body.LastAccessedAt = time.Now()
871	body.SizeBytes = calculateEmailBodySize(&body)
872
873	// Replace existing or append
874	found := false
875	for i, b := range cache.Bodies {
876		if b.UID == body.UID && b.AccountID == body.AccountID {
877			if body.SizeBytes <= threshold {
878				cache.Bodies[i] = body
879			} else {
880				cache.Bodies = append(cache.Bodies[:i], cache.Bodies[i+1:]...)
881			}
882			found = true
883			break
884		}
885	}
886	if !found && body.SizeBytes <= threshold {
887		cache.Bodies = append(cache.Bodies, body)
888	}
889
890	if err := saveEmailBodyCache(cache); err != nil {
891		return err
892	}
893	return pruneEmailBodyCacheSize(threshold)
894}
895
896// PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder.
897// validUIDs is a map of UID -> AccountID for emails still present.
898func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error {
899	cache, err := LoadEmailBodyCache(folderName)
900	if err != nil {
901		return nil // No cache to prune
902	}
903
904	var kept []CachedEmailBody
905	for _, b := range cache.Bodies {
906		if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID {
907			kept = append(kept, b)
908		}
909	}
910
911	if len(kept) == len(cache.Bodies) {
912		return nil // Nothing pruned
913	}
914
915	cache.Bodies = kept
916	return saveEmailBodyCache(cache)
917}
918
919func removeAccountFromEmailBodyCaches(accountID string) error {
920	dir, err := bodyCacheDir()
921	if err != nil {
922		return err
923	}
924	entries, err := os.ReadDir(dir)
925	if err != nil {
926		if os.IsNotExist(err) {
927			return nil
928		}
929		return err
930	}
931
932	var errs []error
933	for _, entry := range entries {
934		if entry.IsDir() {
935			continue
936		}
937		path := filepath.Join(dir, entry.Name())
938		data, err := SecureReadFile(path)
939		if err != nil {
940			errs = append(errs, err)
941			continue
942		}
943		var cache EmailBodyCache
944		if err := json.Unmarshal(data, &cache); err != nil {
945			errs = append(errs, err)
946			continue
947		}
948
949		filtered := cache.Bodies[:0]
950		for _, body := range cache.Bodies {
951			if body.AccountID != accountID {
952				filtered = append(filtered, body)
953			}
954		}
955		if len(filtered) == len(cache.Bodies) {
956			continue
957		}
958		if len(filtered) == 0 {
959			if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
960				errs = append(errs, err)
961			}
962			continue
963		}
964		cache.Bodies = filtered
965		cache.UpdatedAt = time.Now()
966		data, err = json.Marshal(cache)
967		if err != nil {
968			errs = append(errs, err)
969			continue
970		}
971		if err := SecureWriteFile(path, data, 0600); err != nil {
972			errs = append(errs, err)
973		}
974	}
975	return errors.Join(errs...)
976}
977
978// CleanupAccountCache removes cached data associated with an account.
979func CleanupAccountCache(accountID string) error {
980	if accountID == "" {
981		return nil
982	}
983
984	return errors.Join(
985		removeAccountFromEmailCache(accountID),
986		removeAccountFromFolderCache(accountID),
987		removeAccountFromFolderEmailCaches(accountID),
988		removeAccountFromEmailBodyCaches(accountID),
989		removeAccountFromContactsCache(accountID),
990		removeAccountFromDraftsCache(accountID),
991	)
992}