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}