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
534func evict(cache *EmailBodyCache, newSize int, threshold int) {
535 sort.Slice(cache.Bodies, func(i, j int) bool {
536 return cache.Bodies[i].LastAccessedAt.Before(cache.Bodies[j].LastAccessedAt)
537 })
538
539 for len(cache.Bodies) > 0 && calculateTotalCacheSize(cache)+newSize > threshold {
540 cache.Bodies = cache.Bodies[1:]
541 }
542}
543
544// SaveEmailBody saves or updates a cached email body for a folder.
545func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error {
546 cache, err := LoadEmailBodyCache(folderName)
547 if err != nil {
548 cache = &EmailBodyCache{FolderName: folderName}
549 }
550
551 body.CachedAt = time.Now()
552 body.LastAccessedAt = time.Now()
553 body.SizeBytes = calculateEmailBodySize(&body)
554
555 // Replace existing or append
556 found := false
557 for i, b := range cache.Bodies {
558 if b.UID == body.UID && b.AccountID == body.AccountID {
559 cache.Bodies[i] = body
560 found = true
561 break
562 }
563 }
564 if !found {
565 if body.SizeBytes <= threshold {
566 if calculateTotalCacheSize(cache)+body.SizeBytes > threshold {
567 evict(cache, body.SizeBytes, threshold)
568 }
569
570 cache.Bodies = append(cache.Bodies, body)
571 }
572 }
573
574 return saveEmailBodyCache(cache)
575}
576
577// PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder.
578// validUIDs is a map of UID -> AccountID for emails still present.
579func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error {
580 cache, err := LoadEmailBodyCache(folderName)
581 if err != nil {
582 return nil // No cache to prune
583 }
584
585 var kept []CachedEmailBody
586 for _, b := range cache.Bodies {
587 if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID {
588 kept = append(kept, b)
589 }
590 }
591
592 if len(kept) == len(cache.Bodies) {
593 return nil // Nothing pruned
594 }
595
596 cache.Bodies = kept
597 return saveEmailBodyCache(cache)
598}