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 InReplyTo string `json:"in_reply_to,omitempty"`
264 References []string `json:"references,omitempty"`
265 QuotedText string `json:"quoted_text,omitempty"`
266 CreatedAt time.Time `json:"created_at"`
267 UpdatedAt time.Time `json:"updated_at"`
268}
269
270// DraftsCache stores all saved drafts.
271type DraftsCache struct {
272 Drafts []Draft `json:"drafts"`
273 UpdatedAt time.Time `json:"updated_at"`
274}
275
276// draftsFile returns the full path to the drafts cache file.
277func draftsFile() (string, error) {
278 dir, err := cacheDir()
279 if err != nil {
280 return "", err
281 }
282 return filepath.Join(dir, "drafts.json"), nil
283}
284
285// SaveDraftsCache saves drafts to the cache file.
286func SaveDraftsCache(cache *DraftsCache) error {
287 path, err := draftsFile()
288 if err != nil {
289 return err
290 }
291 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
292 return err
293 }
294 cache.UpdatedAt = time.Now()
295 data, err := json.MarshalIndent(cache, "", " ")
296 if err != nil {
297 return err
298 }
299 return SecureWriteFile(path, data, 0600)
300}
301
302// LoadDraftsCache loads drafts from the cache file.
303func LoadDraftsCache() (*DraftsCache, error) {
304 path, err := draftsFile()
305 if err != nil {
306 return nil, err
307 }
308 data, err := SecureReadFile(path)
309 if err != nil {
310 return nil, err
311 }
312 var cache DraftsCache
313 if err := json.Unmarshal(data, &cache); err != nil {
314 return nil, err
315 }
316 return &cache, nil
317}
318
319// SaveDraft saves or updates a draft.
320func SaveDraft(draft Draft) error {
321 cache, err := LoadDraftsCache()
322 if err != nil {
323 cache = &DraftsCache{Drafts: []Draft{}}
324 }
325
326 draft.UpdatedAt = time.Now()
327
328 // Check if draft exists (update) or is new
329 found := false
330 for i, d := range cache.Drafts {
331 if d.ID == draft.ID {
332 cache.Drafts[i] = draft
333 found = true
334 break
335 }
336 }
337
338 if !found {
339 if draft.CreatedAt.IsZero() {
340 draft.CreatedAt = time.Now()
341 }
342 cache.Drafts = append(cache.Drafts, draft)
343 }
344
345 return SaveDraftsCache(cache)
346}
347
348// DeleteDraft removes a draft by ID.
349func DeleteDraft(id string) error {
350 cache, err := LoadDraftsCache()
351 if err != nil {
352 return nil // No cache, nothing to delete
353 }
354
355 var filtered []Draft
356 for _, d := range cache.Drafts {
357 if d.ID != id {
358 filtered = append(filtered, d)
359 }
360 }
361 cache.Drafts = filtered
362
363 return SaveDraftsCache(cache)
364}
365
366// GetDraft retrieves a draft by ID.
367func GetDraft(id string) *Draft {
368 cache, err := LoadDraftsCache()
369 if err != nil {
370 return nil
371 }
372
373 for _, d := range cache.Drafts {
374 if d.ID == id {
375 return &d
376 }
377 }
378 return nil
379}
380
381// GetAllDrafts retrieves all drafts sorted by update time (newest first).
382func GetAllDrafts() []Draft {
383 cache, err := LoadDraftsCache()
384 if err != nil {
385 return nil
386 }
387
388 drafts := cache.Drafts
389 sort.Slice(drafts, func(i, j int) bool {
390 return drafts[i].UpdatedAt.After(drafts[j].UpdatedAt)
391 })
392
393 return drafts
394}
395
396// HasDrafts checks if there are any saved drafts.
397func HasDrafts() bool {
398 cache, err := LoadDraftsCache()
399 if err != nil {
400 return false
401 }
402 return len(cache.Drafts) > 0
403}
404
405// --- Email Body Cache ---
406
407// CachedAttachment stores attachment metadata (not the binary data).
408type CachedAttachment struct {
409 Filename string `json:"filename"`
410 PartID string `json:"part_id"`
411 Encoding string `json:"encoding,omitempty"`
412 MIMEType string `json:"mime_type,omitempty"`
413 ContentID string `json:"content_id,omitempty"`
414 Inline bool `json:"inline,omitempty"`
415 IsSMIMESignature bool `json:"is_smime_signature,omitempty"`
416 SMIMEVerified bool `json:"smime_verified,omitempty"`
417 IsSMIMEEncrypted bool `json:"is_smime_encrypted,omitempty"`
418 IsCalendarInvite bool `json:"is_calendar_invite,omitempty"`
419 CalendarData []byte `json:"calendar_data,omitempty"` // Raw .ics data for calendar invites
420}
421
422// CachedEmailBody stores the body and attachment metadata for a single email.
423type CachedEmailBody struct {
424 UID uint32 `json:"uid"`
425 AccountID string `json:"account_id"`
426 Body string `json:"body"`
427 Attachments []CachedAttachment `json:"attachments,omitempty"`
428 CachedAt time.Time `json:"cached_at"`
429}
430
431// EmailBodyCache stores cached email bodies for a folder.
432type EmailBodyCache struct {
433 FolderName string `json:"folder_name"`
434 Bodies []CachedEmailBody `json:"bodies"`
435 UpdatedAt time.Time `json:"updated_at"`
436}
437
438// bodyCacheDir returns the directory for body cache files.
439func bodyCacheDir() (string, error) {
440 dir, err := cacheDir()
441 if err != nil {
442 return "", err
443 }
444 return filepath.Join(dir, "email_bodies"), nil
445}
446
447// bodyBacheFile returns the file path for a folder's body cache.
448func bodyCacheFile(folderName string) (string, error) {
449 dir, err := bodyCacheDir()
450 if err != nil {
451 return "", err
452 }
453 safe := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_").Replace(folderName)
454 return filepath.Join(dir, safe+".json"), nil
455}
456
457// LoadEmailBodyCache loads the body cache for a folder.
458func LoadEmailBodyCache(folderName string) (*EmailBodyCache, error) {
459 path, err := bodyCacheFile(folderName)
460 if err != nil {
461 return nil, err
462 }
463 data, err := SecureReadFile(path)
464 if err != nil {
465 return nil, err
466 }
467 var cache EmailBodyCache
468 if err := json.Unmarshal(data, &cache); err != nil {
469 return nil, err
470 }
471 return &cache, nil
472}
473
474// saveEmailBodyCache writes the body cache for a folder.
475func saveEmailBodyCache(cache *EmailBodyCache) error {
476 path, err := bodyCacheFile(cache.FolderName)
477 if err != nil {
478 return err
479 }
480 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
481 return err
482 }
483 cache.UpdatedAt = time.Now()
484 data, err := json.Marshal(cache)
485 if err != nil {
486 return err
487 }
488 return SecureWriteFile(path, data, 0600)
489}
490
491// GetCachedEmailBody returns the cached body for a specific email, or nil if not cached.
492func GetCachedEmailBody(folderName string, uid uint32, accountID string) *CachedEmailBody {
493 cache, err := LoadEmailBodyCache(folderName)
494 if err != nil {
495 return nil
496 }
497 for _, b := range cache.Bodies {
498 if b.UID == uid && b.AccountID == accountID {
499 return &b
500 }
501 }
502 return nil
503}
504
505// SaveEmailBody saves or updates a cached email body for a folder.
506func SaveEmailBody(folderName string, body CachedEmailBody) error {
507 cache, err := LoadEmailBodyCache(folderName)
508 if err != nil {
509 cache = &EmailBodyCache{FolderName: folderName}
510 }
511
512 body.CachedAt = time.Now()
513
514 // Replace existing or append
515 found := false
516 for i, b := range cache.Bodies {
517 if b.UID == body.UID && b.AccountID == body.AccountID {
518 cache.Bodies[i] = body
519 found = true
520 break
521 }
522 }
523 if !found {
524 cache.Bodies = append(cache.Bodies, body)
525 }
526
527 return saveEmailBodyCache(cache)
528}
529
530// PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder.
531// validUIDs is a map of UID -> AccountID for emails still present.
532func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error {
533 cache, err := LoadEmailBodyCache(folderName)
534 if err != nil {
535 return nil // No cache to prune
536 }
537
538 var kept []CachedEmailBody
539 for _, b := range cache.Bodies {
540 if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID {
541 kept = append(kept, b)
542 }
543 }
544
545 if len(kept) == len(cache.Bodies) {
546 return nil // Nothing pruned
547 }
548
549 cache.Bodies = kept
550 return saveEmailBodyCache(cache)
551}