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}