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