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.
127//
128// For regular contacts, Email holds a single address and Addresses is empty.
129// For mailing-list virtual contacts emitted by SearchContacts, Email is empty
130// and Addresses holds the expanded list of recipients. Callers that need to
131// distinguish the two cases should check len(Addresses) > 0.
132type Contact struct {
133	Name      string                  `json:"name"`
134	Email     string                  `json:"email"`
135	Addresses []string                `json:"addresses,omitempty"`
136	Usage     map[string]ContactUsage `json:"usage_by_account"`
137}
138
139// UnmarshalJSON accepts both the current usage_by_account format and the
140// legacy last_used/use_count fields so old contacts can be migrated.
141func (c *Contact) UnmarshalJSON(data []byte) error {
142	type contactAlias Contact
143	aux := struct {
144		*contactAlias
145		LastUsed time.Time `json:"last_used"`
146		UseCount int       `json:"use_count"`
147	}{
148		contactAlias: (*contactAlias)(c),
149	}
150	if err := json.Unmarshal(data, &aux); err != nil {
151		return err
152	}
153	if c.Usage == nil {
154		c.Usage = make(map[string]ContactUsage)
155	}
156	if len(c.Usage) == 0 && (!aux.LastUsed.IsZero() || aux.UseCount > 0) {
157		c.Usage[legacyContactUsageKey] = ContactUsage{
158			LastUsed: aux.LastUsed,
159			UseCount: aux.UseCount,
160		}
161	}
162	return nil
163}
164
165// ContactsCache stores all known contacts.
166type ContactsCache struct {
167	Contacts  []Contact `json:"contacts"`
168	UpdatedAt time.Time `json:"updated_at"`
169}
170
171// GetContactsCachePath returns the full path to the contacts cache file.
172func GetContactsCachePath() (string, error) {
173	dir, err := cacheDir()
174	if err != nil {
175		return "", err
176	}
177	return filepath.Join(dir, "contacts.json"), nil
178}
179
180// SaveContactsCache saves contacts to the cache file.
181func SaveContactsCache(cache *ContactsCache) error {
182	path, err := GetContactsCachePath()
183	if err != nil {
184		return err
185	}
186	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
187		return err
188	}
189	for i := range cache.Contacts {
190		if cache.Contacts[i].Usage == nil {
191			cache.Contacts[i].Usage = make(map[string]ContactUsage)
192		}
193	}
194	cache.UpdatedAt = time.Now()
195	data, err := json.MarshalIndent(cache, "", "  ")
196	if err != nil {
197		return err
198	}
199	return SecureWriteFile(path, data, 0600)
200}
201
202// LoadContactsCache loads contacts from the cache file.
203func LoadContactsCache() (*ContactsCache, error) {
204	path, err := GetContactsCachePath()
205	if err != nil {
206		return nil, err
207	}
208	data, err := SecureReadFile(path)
209	if err != nil {
210		return nil, err
211	}
212	var cache ContactsCache
213	if err := json.Unmarshal(data, &cache); err != nil {
214		return nil, err
215	}
216	return &cache, nil
217}
218
219func normalizeContactEmail(email string) string {
220	return strings.ToLower(strings.Trim(strings.TrimSpace(email), ",<>"))
221}
222
223// AddContact adds or updates a global contact in the cache.
224func AddContact(name, email string) error {
225	return AddContactForAccount(name, email, "")
226}
227
228// AddContactForAccount adds or updates a contact in the cache for an account.
229func AddContactForAccount(name, email, accountID string) error {
230	if email == "" {
231		return nil
232	}
233
234	email = normalizeContactEmail(email)
235	name = strings.TrimSpace(name)
236
237	cache, err := LoadContactsCache()
238	if err != nil {
239		cache = &ContactsCache{Contacts: []Contact{}}
240	}
241
242	// Check if contact exists
243	found := false
244	for i, c := range cache.Contacts {
245		if strings.EqualFold(c.Email, email) {
246			// Normalize the stored email to a canonical lowercase form.
247			cache.Contacts[i].Email = email
248			if cache.Contacts[i].Usage == nil {
249				cache.Contacts[i].Usage = make(map[string]ContactUsage)
250			}
251			usage := cache.Contacts[i].Usage[accountID]
252			usage.UseCount++
253			usage.LastUsed = time.Now()
254			cache.Contacts[i].Usage[accountID] = usage
255			// Update name if we have a better one
256			if name != "" && (c.Name == "" || c.Name == email) {
257				cache.Contacts[i].Name = name
258			}
259			found = true
260			break
261		}
262	}
263
264	if !found {
265		cache.Contacts = append(cache.Contacts, Contact{
266			Name:  name,
267			Email: email,
268			Usage: map[string]ContactUsage{
269				accountID: {
270					LastUsed: time.Now(),
271					UseCount: 1,
272				},
273			},
274		})
275	}
276
277	return SaveContactsCache(cache)
278}
279
280func contactUsageForAccount(c Contact, accountID string) (ContactUsage, bool) {
281	if len(c.Usage) == 0 {
282		return ContactUsage{}, accountID == ""
283	}
284	if accountID != "" {
285		if usage, ok := c.Usage[legacyContactUsageKey]; ok {
286			return usage, true
287		}
288		usage, ok := c.Usage[accountID]
289		return usage, ok
290	}
291	var aggregate ContactUsage
292	for _, usage := range c.Usage {
293		aggregate.UseCount += usage.UseCount
294		if usage.LastUsed.After(aggregate.LastUsed) {
295			aggregate.LastUsed = usage.LastUsed
296		}
297	}
298	return aggregate, true
299}
300
301// ContactAggregateUsage returns a contact's total usage across accounts.
302func ContactAggregateUsage(c Contact) ContactUsage {
303	usage, _ := contactUsageForAccount(c, "")
304	return usage
305}
306
307// SearchContacts searches for contacts matching the query across all accounts.
308func SearchContacts(query string) []Contact {
309	return SearchContactsForAccount(query, "")
310}
311
312// SearchContactsForAccount searches for contacts matching the query for an account.
313func SearchContactsForAccount(query, accountID string) []Contact {
314	cache, err := LoadContactsCache()
315	if err != nil {
316		return nil
317	}
318
319	query = strings.ToLower(strings.TrimSpace(query))
320	if query == "" {
321		return nil
322	}
323
324	var matches []Contact
325
326	// Add mailing lists to matches if they match the query
327	cfg, err := LoadConfig()
328	if err == nil {
329		for _, list := range cfg.MailingLists {
330			if strings.Contains(strings.ToLower(list.Name), query) {
331				// Convert mailing list to a virtual contact. Addresses are
332				// stored in a dedicated slice so the Email field keeps its
333				// single-address invariant -- avoids corruption by
334				// normalizeContactEmail and exact-match lookups in callers.
335				addresses := append([]string(nil), list.Addresses...)
336				matches = append(matches, Contact{
337					Name:      list.Name,
338					Addresses: addresses,
339					Usage: map[string]ContactUsage{
340						accountID: {
341							UseCount: 9999, // Ensure lists appear at the top
342							LastUsed: time.Now(),
343						},
344					},
345				})
346			}
347		}
348	}
349
350	for _, c := range cache.Contacts {
351		if strings.Contains(strings.ToLower(c.Email), query) ||
352			strings.Contains(strings.ToLower(c.Name), query) {
353			if _, ok := contactUsageForAccount(c, accountID); ok {
354				matches = append(matches, c)
355			}
356		}
357	}
358
359	// Sort by use count (most used first), then by last used
360	sort.Slice(matches, func(i, j int) bool {
361		left, _ := contactUsageForAccount(matches[i], accountID)
362		right, _ := contactUsageForAccount(matches[j], accountID)
363		if left.UseCount != right.UseCount {
364			return left.UseCount > right.UseCount
365		}
366		return left.LastUsed.After(right.LastUsed)
367	})
368
369	// Limit to 5 suggestions
370	if len(matches) > 5 {
371		matches = matches[:5]
372	}
373
374	return matches
375}
376
377// MigrateContactsCacheUsage expands legacy global contact usage to all accounts.
378func MigrateContactsCacheUsage(accountIDs []string) error {
379	cache, err := LoadContactsCache()
380	if err != nil {
381		return err
382	}
383
384	changed := false
385	for i := range cache.Contacts {
386		if cache.Contacts[i].Usage == nil {
387			cache.Contacts[i].Usage = make(map[string]ContactUsage)
388			changed = true
389		}
390		legacyUsage, hasLegacy := cache.Contacts[i].Usage[legacyContactUsageKey]
391		if !hasLegacy {
392			continue
393		}
394		delete(cache.Contacts[i].Usage, legacyContactUsageKey)
395		for _, accountID := range accountIDs {
396			if accountID == "" {
397				continue
398			}
399			if _, ok := cache.Contacts[i].Usage[accountID]; !ok {
400				cache.Contacts[i].Usage[accountID] = legacyUsage
401			}
402		}
403		changed = true
404	}
405	if !changed {
406		return nil
407	}
408	return SaveContactsCache(cache)
409}
410
411func removeAccountFromContactsCache(accountID string) error {
412	cache, err := LoadContactsCache()
413	if err != nil {
414		if os.IsNotExist(err) {
415			return nil
416		}
417		return err
418	}
419
420	changed := false
421	filtered := cache.Contacts[:0]
422	for _, contact := range cache.Contacts {
423		if _, ok := contact.Usage[accountID]; ok {
424			delete(contact.Usage, accountID)
425			changed = true
426		}
427		if len(contact.Usage) > 0 {
428			filtered = append(filtered, contact)
429		} else {
430			changed = true
431		}
432	}
433	if !changed {
434		return nil
435	}
436	cache.Contacts = filtered
437	return SaveContactsCache(cache)
438}
439
440// --- Drafts Cache ---
441
442// Draft stores a saved email draft.
443type Draft struct {
444	ID              string    `json:"id"`
445	To              string    `json:"to"`
446	Cc              string    `json:"cc,omitempty"`
447	Bcc             string    `json:"bcc,omitempty"`
448	Subject         string    `json:"subject"`
449	Body            string    `json:"body"`
450	AttachmentPaths []string  `json:"attachment_paths,omitempty"`
451	AccountID       string    `json:"account_id"`
452	FromOverride    string    `json:"from_override,omitempty"`
453	InReplyTo       string    `json:"in_reply_to,omitempty"`
454	References      []string  `json:"references,omitempty"`
455	QuotedText      string    `json:"quoted_text,omitempty"`
456	CreatedAt       time.Time `json:"created_at"`
457	UpdatedAt       time.Time `json:"updated_at"`
458}
459
460// DraftsCache stores all saved drafts.
461type DraftsCache struct {
462	Drafts    []Draft   `json:"drafts"`
463	UpdatedAt time.Time `json:"updated_at"`
464}
465
466// draftsFile returns the full path to the drafts cache file.
467func draftsFile() (string, error) {
468	dir, err := cacheDir()
469	if err != nil {
470		return "", err
471	}
472	return filepath.Join(dir, "drafts.json"), nil
473}
474
475// SaveDraftsCache saves drafts to the cache file.
476func SaveDraftsCache(cache *DraftsCache) error {
477	path, err := draftsFile()
478	if err != nil {
479		return err
480	}
481	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
482		return err
483	}
484	cache.UpdatedAt = time.Now()
485	data, err := json.MarshalIndent(cache, "", "  ")
486	if err != nil {
487		return err
488	}
489	return SecureWriteFile(path, data, 0600)
490}
491
492// LoadDraftsCache loads drafts from the cache file.
493func LoadDraftsCache() (*DraftsCache, error) {
494	path, err := draftsFile()
495	if err != nil {
496		return nil, err
497	}
498	data, err := SecureReadFile(path)
499	if err != nil {
500		return nil, err
501	}
502	var cache DraftsCache
503	if err := json.Unmarshal(data, &cache); err != nil {
504		return nil, err
505	}
506	return &cache, nil
507}
508
509// SaveDraft saves or updates a draft.
510func SaveDraft(draft Draft) error {
511	cache, err := LoadDraftsCache()
512	if err != nil {
513		cache = &DraftsCache{Drafts: []Draft{}}
514	}
515
516	draft.UpdatedAt = time.Now()
517
518	// Check if draft exists (update) or is new
519	found := false
520	for i, d := range cache.Drafts {
521		if d.ID == draft.ID {
522			cache.Drafts[i] = draft
523			found = true
524			break
525		}
526	}
527
528	if !found {
529		if draft.CreatedAt.IsZero() {
530			draft.CreatedAt = time.Now()
531		}
532		cache.Drafts = append(cache.Drafts, draft)
533	}
534
535	return SaveDraftsCache(cache)
536}
537
538// DeleteDraft removes a draft by ID.
539func DeleteDraft(id string) error {
540	cache, err := LoadDraftsCache()
541	if err != nil {
542		return err
543	}
544
545	var filtered []Draft
546	for _, d := range cache.Drafts {
547		if d.ID != id {
548			filtered = append(filtered, d)
549		}
550	}
551	cache.Drafts = filtered
552
553	return SaveDraftsCache(cache)
554}
555
556// GetDraft retrieves a draft by ID.
557func GetDraft(id string) *Draft {
558	cache, err := LoadDraftsCache()
559	if err != nil {
560		return nil
561	}
562
563	for _, d := range cache.Drafts {
564		if d.ID == id {
565			return &d
566		}
567	}
568	return nil
569}
570
571// GetAllDrafts retrieves all drafts sorted by update time (newest first).
572func GetAllDrafts() []Draft {
573	cache, err := LoadDraftsCache()
574	if err != nil {
575		return nil
576	}
577
578	drafts := cache.Drafts
579	sort.Slice(drafts, func(i, j int) bool {
580		return drafts[i].UpdatedAt.After(drafts[j].UpdatedAt)
581	})
582
583	return drafts
584}
585
586// HasDrafts checks if there are any saved drafts.
587func HasDrafts() bool {
588	cache, err := LoadDraftsCache()
589	if err != nil {
590		return false
591	}
592	return len(cache.Drafts) > 0
593}
594
595func removeAccountFromDraftsCache(accountID string) error {
596	cache, err := LoadDraftsCache()
597	if err != nil {
598		if os.IsNotExist(err) {
599			return nil
600		}
601		return err
602	}
603	filtered := cache.Drafts[:0]
604	for _, draft := range cache.Drafts {
605		if draft.AccountID != accountID {
606			filtered = append(filtered, draft)
607		}
608	}
609	if len(filtered) == len(cache.Drafts) {
610		return nil
611	}
612	cache.Drafts = filtered
613	return SaveDraftsCache(cache)
614}
615
616// --- Email Body Cache ---
617
618// CachedAttachment stores attachment metadata (not the binary data).
619type CachedAttachment struct {
620	Filename         string `json:"filename"`
621	PartID           string `json:"part_id"`
622	Encoding         string `json:"encoding,omitempty"`
623	MIMEType         string `json:"mime_type,omitempty"`
624	ContentID        string `json:"content_id,omitempty"`
625	Inline           bool   `json:"inline,omitempty"`
626	IsSMIMESignature bool   `json:"is_smime_signature,omitempty"`
627	SMIMEVerified    bool   `json:"smime_verified,omitempty"`
628	IsSMIMEEncrypted bool   `json:"is_smime_encrypted,omitempty"`
629	IsCalendarInvite bool   `json:"is_calendar_invite,omitempty"`
630	CalendarData     []byte `json:"calendar_data,omitempty"` // Raw .ics data for calendar invites
631}
632
633// CachedEmailBody stores the body and attachment metadata for a single email.
634type CachedEmailBody struct {
635	UID            uint32             `json:"uid"`
636	AccountID      string             `json:"account_id"`
637	Body           string             `json:"body"`
638	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
639	Attachments    []CachedAttachment `json:"attachments,omitempty"`
640	CachedAt       time.Time          `json:"cached_at"`
641	LastAccessedAt time.Time          `json:"last_accessed_at"`
642	SizeBytes      int                `json:"size_bytes"`
643}
644
645// EmailBodyCache stores cached email bodies for a folder.
646type EmailBodyCache struct {
647	FolderName string            `json:"folder_name"`
648	Bodies     []CachedEmailBody `json:"bodies"`
649	UpdatedAt  time.Time         `json:"updated_at"`
650}
651
652// bodyCacheDir returns the directory for body cache files.
653func bodyCacheDir() (string, error) {
654	dir, err := cacheDir()
655	if err != nil {
656		return "", err
657	}
658	return filepath.Join(dir, "email_bodies"), nil
659}
660
661// bodyBacheFile returns the file path for a folder's body cache.
662func bodyCacheFile(folderName string) (string, error) {
663	dir, err := bodyCacheDir()
664	if err != nil {
665		return "", err
666	}
667	safe := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_").Replace(folderName)
668	return filepath.Join(dir, safe+".json"), nil
669}
670
671// LoadEmailBodyCache loads the body cache for a folder.
672func LoadEmailBodyCache(folderName string) (*EmailBodyCache, error) {
673	path, err := bodyCacheFile(folderName)
674	if err != nil {
675		return nil, err
676	}
677	data, err := SecureReadFile(path)
678	if err != nil {
679		return nil, err
680	}
681	var cache EmailBodyCache
682	if err := json.Unmarshal(data, &cache); err != nil {
683		return nil, err
684	}
685	return &cache, nil
686}
687
688// saveEmailBodyCache writes the body cache for a folder.
689func saveEmailBodyCache(cache *EmailBodyCache) error {
690	path, err := bodyCacheFile(cache.FolderName)
691	if err != nil {
692		return err
693	}
694	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
695		return err
696	}
697	cache.UpdatedAt = time.Now()
698	data, err := json.Marshal(cache)
699	if err != nil {
700		return err
701	}
702	return SecureWriteFile(path, data, 0600)
703}
704
705// GetCachedEmailBody returns the cached body for a specific email, or nil if not cached.
706// LastAccessedAt is updated by SaveEmailBody, not here -- a read should not
707// mutate cache state.
708func GetCachedEmailBody(folderName string, uid uint32, accountID string, threshold int) *CachedEmailBody {
709	lru := GetLRUInstance(threshold)
710	return lru.Get(folderName, uid, accountID)
711}
712
713func calculateEmailBodySize(body *CachedEmailBody) int {
714	size := len(body.Body)
715	for _, att := range body.Attachments {
716		size += len(att.Filename)
717		size += len(att.PartID)
718		size += len(att.Encoding)
719		size += len(att.MIMEType)
720		size += len(att.ContentID)
721		size += len(att.CalendarData)
722	}
723	return size
724}
725
726// SaveEmailBody saves or updates a cached email body for a folder.
727func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error {
728	body.CachedAt = time.Now()
729	body.SizeBytes = calculateEmailBodySize(&body)
730
731	lru := GetLRUInstance(threshold)
732	lru.Put(folderName, body.UID, body.AccountID, &body)
733
734	return nil
735}
736
737// PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder.
738// validUIDs is a map of UID -> AccountID for emails still present.
739func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string, threshold int) error {
740	cache, err := LoadEmailBodyCache(folderName)
741
742	if err != nil {
743		return err
744	}
745
746	lru := GetLRUInstance(threshold)
747
748	var kept []CachedEmailBody
749	for _, b := range cache.Bodies {
750		if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID {
751			kept = append(kept, b)
752		} else {
753			lru.Delete(folderName, b.UID, b.AccountID)
754		}
755	}
756
757	if len(kept) == len(cache.Bodies) {
758		return nil
759	}
760
761	cache.Bodies = kept
762	return saveEmailBodyCache(cache)
763}
764
765func removeAccountFromEmailBodyCaches(accountID string) error {
766	dir, err := bodyCacheDir()
767	if err != nil {
768		return err
769	}
770	entries, err := os.ReadDir(dir)
771	if err != nil {
772		if os.IsNotExist(err) {
773			return nil
774		}
775		return err
776	}
777
778	var errs []error
779	for _, entry := range entries {
780		if entry.IsDir() {
781			continue
782		}
783		path := filepath.Join(dir, entry.Name())
784		data, err := SecureReadFile(path)
785		if err != nil {
786			errs = append(errs, err)
787			continue
788		}
789		var cache EmailBodyCache
790		if err := json.Unmarshal(data, &cache); err != nil {
791			errs = append(errs, err)
792			continue
793		}
794
795		filtered := cache.Bodies[:0]
796		for _, body := range cache.Bodies {
797			if body.AccountID != accountID {
798				filtered = append(filtered, body)
799			}
800		}
801		if len(filtered) == len(cache.Bodies) {
802			continue
803		}
804		if len(filtered) == 0 {
805			if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
806				errs = append(errs, err)
807			}
808			continue
809		}
810		cache.Bodies = filtered
811		cache.UpdatedAt = time.Now()
812		data, err = json.Marshal(cache)
813		if err != nil {
814			errs = append(errs, err)
815			continue
816		}
817		if err := SecureWriteFile(path, data, 0600); err != nil {
818			errs = append(errs, err)
819		}
820	}
821	return errors.Join(errs...)
822}
823
824// CleanupAccountCache removes cached data associated with an account.
825func CleanupAccountCache(accountID string) error {
826	if accountID == "" {
827		return nil
828	}
829
830	return errors.Join(
831		removeAccountFromEmailCache(accountID),
832		removeAccountFromFolderCache(accountID),
833		removeAccountFromFolderEmailCaches(accountID),
834		removeAccountFromEmailBodyCaches(accountID),
835		removeAccountFromContactsCache(accountID),
836		removeAccountFromDraftsCache(accountID),
837	)
838}