cache.go

  1package config
  2
  3import (
  4	"encoding/json"
  5	"os"
  6	"path/filepath"
  7	"sort"
  8	"strings"
  9	"time"
 10)
 11
 12// CachedEmail stores essential email data for caching.
 13type CachedEmail struct {
 14	UID        uint32    `json:"uid"`
 15	From       string    `json:"from"`
 16	To         []string  `json:"to"`
 17	Subject    string    `json:"subject"`
 18	Date       time.Time `json:"date"`
 19	MessageID  string    `json:"message_id"`
 20	InReplyTo  string    `json:"in_reply_to,omitempty"`
 21	References []string  `json:"references,omitempty"`
 22	AccountID  string    `json:"account_id"`
 23	IsRead     bool      `json:"is_read"`
 24}
 25
 26// EmailCache stores cached emails for all accounts.
 27type EmailCache struct {
 28	Emails    []CachedEmail `json:"emails"`
 29	UpdatedAt time.Time     `json:"updated_at"`
 30}
 31
 32// cacheFile returns the full path to the email cache file.
 33func cacheFile() (string, error) {
 34	dir, err := cacheDir()
 35	if err != nil {
 36		return "", err
 37	}
 38	return filepath.Join(dir, "email_cache.json"), nil
 39}
 40
 41// SaveEmailCache saves emails to the cache file.
 42func SaveEmailCache(cache *EmailCache) error {
 43	path, err := cacheFile()
 44	if err != nil {
 45		return err
 46	}
 47	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
 48		return err
 49	}
 50	cache.UpdatedAt = time.Now()
 51	data, err := json.MarshalIndent(cache, "", "  ")
 52	if err != nil {
 53		return err
 54	}
 55	return SecureWriteFile(path, data, 0600)
 56}
 57
 58// LoadEmailCache loads emails from the cache file.
 59func LoadEmailCache() (*EmailCache, error) {
 60	path, err := cacheFile()
 61	if err != nil {
 62		return nil, err
 63	}
 64	data, err := SecureReadFile(path)
 65	if err != nil {
 66		return nil, err
 67	}
 68	var cache EmailCache
 69	if err := json.Unmarshal(data, &cache); err != nil {
 70		return nil, err
 71	}
 72	return &cache, nil
 73}
 74
 75// HasEmailCache checks if a cache file exists.
 76func HasEmailCache() bool {
 77	path, err := cacheFile()
 78	if err != nil {
 79		return false
 80	}
 81	_, err = os.Stat(path)
 82	return err == nil
 83}
 84
 85// ClearEmailCache removes the cache file.
 86func ClearEmailCache() error {
 87	path, err := cacheFile()
 88	if err != nil {
 89		return err
 90	}
 91	return os.Remove(path)
 92}
 93
 94// --- Contacts Cache ---
 95
 96// Contact stores a contact's name and email address.
 97type Contact struct {
 98	Name     string    `json:"name"`
 99	Email    string    `json:"email"`
100	LastUsed time.Time `json:"last_used"`
101	UseCount int       `json:"use_count"`
102}
103
104// ContactsCache stores all known contacts.
105type ContactsCache struct {
106	Contacts  []Contact `json:"contacts"`
107	UpdatedAt time.Time `json:"updated_at"`
108}
109
110// GetContactsCachePath returns the full path to the contacts cache file.
111func GetContactsCachePath() (string, error) {
112	dir, err := cacheDir()
113	if err != nil {
114		return "", err
115	}
116	return filepath.Join(dir, "contacts.json"), nil
117}
118
119// SaveContactsCache saves contacts to the cache file.
120func SaveContactsCache(cache *ContactsCache) error {
121	path, err := GetContactsCachePath()
122	if err != nil {
123		return err
124	}
125	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
126		return err
127	}
128	cache.UpdatedAt = time.Now()
129	data, err := json.MarshalIndent(cache, "", "  ")
130	if err != nil {
131		return err
132	}
133	return SecureWriteFile(path, data, 0600)
134}
135
136// LoadContactsCache loads contacts from the cache file.
137func LoadContactsCache() (*ContactsCache, error) {
138	path, err := GetContactsCachePath()
139	if err != nil {
140		return nil, err
141	}
142	data, err := SecureReadFile(path)
143	if err != nil {
144		return nil, err
145	}
146	var cache ContactsCache
147	if err := json.Unmarshal(data, &cache); err != nil {
148		return nil, err
149	}
150	return &cache, nil
151}
152
153func normalizeContactEmail(email string) string {
154	return strings.ToLower(strings.Trim(strings.TrimSpace(email), ",<>"))
155}
156
157// AddContact adds or updates a contact in the cache.
158func AddContact(name, email string) error {
159	if email == "" {
160		return nil
161	}
162
163	email = normalizeContactEmail(email)
164	name = strings.TrimSpace(name)
165
166	cache, err := LoadContactsCache()
167	if err != nil {
168		cache = &ContactsCache{Contacts: []Contact{}}
169	}
170
171	// Check if contact exists
172	found := false
173	for i, c := range cache.Contacts {
174		if strings.EqualFold(c.Email, email) {
175			// Normalize the stored email to a canonical lowercase form.
176			cache.Contacts[i].Email = email
177			cache.Contacts[i].UseCount++
178			cache.Contacts[i].LastUsed = time.Now()
179			// Update name if we have a better one
180			if name != "" && (c.Name == "" || c.Name == email) {
181				cache.Contacts[i].Name = name
182			}
183			found = true
184			break
185		}
186	}
187
188	if !found {
189		cache.Contacts = append(cache.Contacts, Contact{
190			Name:     name,
191			Email:    email,
192			LastUsed: time.Now(),
193			UseCount: 1,
194		})
195	}
196
197	return SaveContactsCache(cache)
198}
199
200// SearchContacts searches for contacts matching the query.
201func SearchContacts(query string) []Contact {
202	cache, err := LoadContactsCache()
203	if err != nil {
204		return nil
205	}
206
207	query = strings.ToLower(strings.TrimSpace(query))
208	if query == "" {
209		return nil
210	}
211
212	var matches []Contact
213
214	// Add mailing lists to matches if they match the query
215	cfg, err := LoadConfig()
216	if err == nil {
217		for _, list := range cfg.MailingLists {
218			if strings.Contains(strings.ToLower(list.Name), query) {
219				// Convert mailing list to a virtual contact
220				matches = append(matches, Contact{
221					Name:     list.Name,
222					Email:    strings.Join(list.Addresses, ", "),
223					UseCount: 9999, // Ensure lists appear at the top
224					LastUsed: time.Now(),
225				})
226			}
227		}
228	}
229
230	for _, c := range cache.Contacts {
231		if strings.Contains(strings.ToLower(c.Email), query) ||
232			strings.Contains(strings.ToLower(c.Name), query) {
233			matches = append(matches, c)
234		}
235	}
236
237	// Sort by use count (most used first), then by last used
238	sort.Slice(matches, func(i, j int) bool {
239		if matches[i].UseCount != matches[j].UseCount {
240			return matches[i].UseCount > matches[j].UseCount
241		}
242		return matches[i].LastUsed.After(matches[j].LastUsed)
243	})
244
245	// Limit to 5 suggestions
246	if len(matches) > 5 {
247		matches = matches[:5]
248	}
249
250	return matches
251}
252
253// --- Drafts Cache ---
254
255// Draft stores a saved email draft.
256type Draft struct {
257	ID              string    `json:"id"`
258	To              string    `json:"to"`
259	Cc              string    `json:"cc,omitempty"`
260	Bcc             string    `json:"bcc,omitempty"`
261	Subject         string    `json:"subject"`
262	Body            string    `json:"body"`
263	AttachmentPaths []string  `json:"attachment_paths,omitempty"`
264	AccountID       string    `json:"account_id"`
265	FromOverride    string    `json:"from_override,omitempty"`
266	InReplyTo       string    `json:"in_reply_to,omitempty"`
267	References      []string  `json:"references,omitempty"`
268	QuotedText      string    `json:"quoted_text,omitempty"`
269	CreatedAt       time.Time `json:"created_at"`
270	UpdatedAt       time.Time `json:"updated_at"`
271}
272
273// DraftsCache stores all saved drafts.
274type DraftsCache struct {
275	Drafts    []Draft   `json:"drafts"`
276	UpdatedAt time.Time `json:"updated_at"`
277}
278
279// draftsFile returns the full path to the drafts cache file.
280func draftsFile() (string, error) {
281	dir, err := cacheDir()
282	if err != nil {
283		return "", err
284	}
285	return filepath.Join(dir, "drafts.json"), nil
286}
287
288// SaveDraftsCache saves drafts to the cache file.
289func SaveDraftsCache(cache *DraftsCache) error {
290	path, err := draftsFile()
291	if err != nil {
292		return err
293	}
294	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
295		return err
296	}
297	cache.UpdatedAt = time.Now()
298	data, err := json.MarshalIndent(cache, "", "  ")
299	if err != nil {
300		return err
301	}
302	return SecureWriteFile(path, data, 0600)
303}
304
305// LoadDraftsCache loads drafts from the cache file.
306func LoadDraftsCache() (*DraftsCache, error) {
307	path, err := draftsFile()
308	if err != nil {
309		return nil, err
310	}
311	data, err := SecureReadFile(path)
312	if err != nil {
313		return nil, err
314	}
315	var cache DraftsCache
316	if err := json.Unmarshal(data, &cache); err != nil {
317		return nil, err
318	}
319	return &cache, nil
320}
321
322// SaveDraft saves or updates a draft.
323func SaveDraft(draft Draft) error {
324	cache, err := LoadDraftsCache()
325	if err != nil {
326		cache = &DraftsCache{Drafts: []Draft{}}
327	}
328
329	draft.UpdatedAt = time.Now()
330
331	// Check if draft exists (update) or is new
332	found := false
333	for i, d := range cache.Drafts {
334		if d.ID == draft.ID {
335			cache.Drafts[i] = draft
336			found = true
337			break
338		}
339	}
340
341	if !found {
342		if draft.CreatedAt.IsZero() {
343			draft.CreatedAt = time.Now()
344		}
345		cache.Drafts = append(cache.Drafts, draft)
346	}
347
348	return SaveDraftsCache(cache)
349}
350
351// DeleteDraft removes a draft by ID.
352func DeleteDraft(id string) error {
353	cache, err := LoadDraftsCache()
354	if err != nil {
355		return nil // No cache, nothing to delete
356	}
357
358	var filtered []Draft
359	for _, d := range cache.Drafts {
360		if d.ID != id {
361			filtered = append(filtered, d)
362		}
363	}
364	cache.Drafts = filtered
365
366	return SaveDraftsCache(cache)
367}
368
369// GetDraft retrieves a draft by ID.
370func GetDraft(id string) *Draft {
371	cache, err := LoadDraftsCache()
372	if err != nil {
373		return nil
374	}
375
376	for _, d := range cache.Drafts {
377		if d.ID == id {
378			return &d
379		}
380	}
381	return nil
382}
383
384// GetAllDrafts retrieves all drafts sorted by update time (newest first).
385func GetAllDrafts() []Draft {
386	cache, err := LoadDraftsCache()
387	if err != nil {
388		return nil
389	}
390
391	drafts := cache.Drafts
392	sort.Slice(drafts, func(i, j int) bool {
393		return drafts[i].UpdatedAt.After(drafts[j].UpdatedAt)
394	})
395
396	return drafts
397}
398
399// HasDrafts checks if there are any saved drafts.
400func HasDrafts() bool {
401	cache, err := LoadDraftsCache()
402	if err != nil {
403		return false
404	}
405	return len(cache.Drafts) > 0
406}
407
408// --- Email Body Cache ---
409
410// CachedAttachment stores attachment metadata (not the binary data).
411type CachedAttachment struct {
412	Filename         string `json:"filename"`
413	PartID           string `json:"part_id"`
414	Encoding         string `json:"encoding,omitempty"`
415	MIMEType         string `json:"mime_type,omitempty"`
416	ContentID        string `json:"content_id,omitempty"`
417	Inline           bool   `json:"inline,omitempty"`
418	IsSMIMESignature bool   `json:"is_smime_signature,omitempty"`
419	SMIMEVerified    bool   `json:"smime_verified,omitempty"`
420	IsSMIMEEncrypted bool   `json:"is_smime_encrypted,omitempty"`
421	IsCalendarInvite bool   `json:"is_calendar_invite,omitempty"`
422	CalendarData     []byte `json:"calendar_data,omitempty"` // Raw .ics data for calendar invites
423}
424
425// CachedEmailBody stores the body and attachment metadata for a single email.
426type CachedEmailBody struct {
427	UID            uint32             `json:"uid"`
428	AccountID      string             `json:"account_id"`
429	Body           string             `json:"body"`
430	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
431	Attachments    []CachedAttachment `json:"attachments,omitempty"`
432	CachedAt       time.Time          `json:"cached_at"`
433	LastAccessedAt time.Time          `json:"last_accessed_at"`
434	SizeBytes      int                `json:"size_bytes"`
435}
436
437// EmailBodyCache stores cached email bodies for a folder.
438type EmailBodyCache struct {
439	FolderName string            `json:"folder_name"`
440	Bodies     []CachedEmailBody `json:"bodies"`
441	UpdatedAt  time.Time         `json:"updated_at"`
442}
443
444// bodyCacheDir returns the directory for body cache files.
445func bodyCacheDir() (string, error) {
446	dir, err := cacheDir()
447	if err != nil {
448		return "", err
449	}
450	return filepath.Join(dir, "email_bodies"), nil
451}
452
453// bodyBacheFile returns the file path for a folder's body cache.
454func bodyCacheFile(folderName string) (string, error) {
455	dir, err := bodyCacheDir()
456	if err != nil {
457		return "", err
458	}
459	safe := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_").Replace(folderName)
460	return filepath.Join(dir, safe+".json"), nil
461}
462
463// LoadEmailBodyCache loads the body cache for a folder.
464func LoadEmailBodyCache(folderName string) (*EmailBodyCache, error) {
465	path, err := bodyCacheFile(folderName)
466	if err != nil {
467		return nil, err
468	}
469	data, err := SecureReadFile(path)
470	if err != nil {
471		return nil, err
472	}
473	var cache EmailBodyCache
474	if err := json.Unmarshal(data, &cache); err != nil {
475		return nil, err
476	}
477	return &cache, nil
478}
479
480// saveEmailBodyCache writes the body cache for a folder.
481func saveEmailBodyCache(cache *EmailBodyCache) error {
482	path, err := bodyCacheFile(cache.FolderName)
483	if err != nil {
484		return err
485	}
486	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
487		return err
488	}
489	cache.UpdatedAt = time.Now()
490	data, err := json.Marshal(cache)
491	if err != nil {
492		return err
493	}
494	return SecureWriteFile(path, data, 0600)
495}
496
497// GetCachedEmailBody returns the cached body for a specific email, or nil if not cached.
498// LastAccessedAt is updated by SaveEmailBody, not here -- a read should not
499// mutate cache state.
500func GetCachedEmailBody(folderName string, uid uint32, accountID string) *CachedEmailBody {
501	cache, err := LoadEmailBodyCache(folderName)
502	if err != nil {
503		return nil
504	}
505	for i, b := range cache.Bodies {
506		if b.UID == uid && b.AccountID == accountID {
507			return &cache.Bodies[i]
508		}
509	}
510	return nil
511}
512
513func calculateEmailBodySize(body *CachedEmailBody) int {
514	size := len(body.Body)
515	for _, att := range body.Attachments {
516		size += len(att.Filename)
517		size += len(att.PartID)
518		size += len(att.Encoding)
519		size += len(att.MIMEType)
520		size += len(att.ContentID)
521		size += len(att.CalendarData)
522	}
523	return size
524}
525
526func calculateTotalCacheSize(cache *EmailBodyCache) int {
527	total := 0
528	for _, b := range cache.Bodies {
529		total += b.SizeBytes
530	}
531	return total
532}
533
534type bodyCacheFileState struct {
535	path  string
536	cache EmailBodyCache
537}
538
539type bodyCacheEntryRef struct {
540	fileIndex int
541	bodyIndex int
542}
543
544func 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
589func 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
602func 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)
631	})
632
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		}
659	}
660
661	return nil
662}
663
664// SaveEmailBody saves or updates a cached email body for a folder.
665func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error {
666	cache, err := LoadEmailBodyCache(folderName)
667	if err != nil {
668		cache = &EmailBodyCache{FolderName: folderName}
669	}
670
671	body.CachedAt = time.Now()
672	body.LastAccessedAt = time.Now()
673	body.SizeBytes = calculateEmailBodySize(&body)
674
675	// Replace existing or append
676	found := false
677	for i, b := range cache.Bodies {
678		if b.UID == body.UID && b.AccountID == body.AccountID {
679			if body.SizeBytes <= threshold {
680				cache.Bodies[i] = body
681			} else {
682				cache.Bodies = append(cache.Bodies[:i], cache.Bodies[i+1:]...)
683			}
684			found = true
685			break
686		}
687	}
688	if !found && body.SizeBytes <= threshold {
689		cache.Bodies = append(cache.Bodies, body)
690	}
691
692	if err := saveEmailBodyCache(cache); err != nil {
693		return err
694	}
695	return pruneEmailBodyCacheSize(threshold)
696}
697
698// PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder.
699// validUIDs is a map of UID -> AccountID for emails still present.
700func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error {
701	cache, err := LoadEmailBodyCache(folderName)
702	if err != nil {
703		return nil // No cache to prune
704	}
705
706	var kept []CachedEmailBody
707	for _, b := range cache.Bodies {
708		if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID {
709			kept = append(kept, b)
710		}
711	}
712
713	if len(kept) == len(cache.Bodies) {
714		return nil // Nothing pruned
715	}
716
717	cache.Bodies = kept
718	return saveEmailBodyCache(cache)
719}