1package config
2
3import (
4 "os"
5 "path/filepath"
6 "reflect"
7 "testing"
8 "time"
9
10 "github.com/zalando/go-keyring"
11)
12
13// TestSaveAndLoadConfig verifies that the config can be saved to and loaded from a file correctly.
14func TestSaveAndLoadConfig(t *testing.T) {
15 // Use an in-memory mock keyring so tests do not interact with the host OS keyring
16 keyring.MockInit()
17
18 // Create a temporary directory for the test to avoid interfering with actual user config.
19 tempDir := t.TempDir()
20
21 // Temporarily override the user home directory to our temp directory.
22 // This ensures that our config file is written to a predictable, temporary location.
23 t.Setenv("HOME", tempDir)
24
25 // Define a sample configuration to save with multiple accounts.
26 expectedConfig := &Config{
27 Accounts: []Account{
28 {
29 ID: "test-id-1",
30 Name: "Test User",
31 Email: "test@example.com",
32 Password: "supersecret",
33 ServiceProvider: "gmail",
34 SendAsEmail: "alias@example.com",
35 },
36 {
37 ID: "test-id-2",
38 Name: "Custom User",
39 Email: "custom@example.com",
40 Password: "customsecret",
41 ServiceProvider: "custom",
42 IMAPServer: "imap.custom.com",
43 IMAPPort: 993,
44 SMTPServer: "smtp.custom.com",
45 SMTPPort: 587,
46 CatchAll: true,
47 },
48 },
49 }
50
51 // Attempt to save the configuration.
52 err := SaveConfig(expectedConfig)
53 if err != nil {
54 t.Fatalf("SaveConfig() failed: %v", err)
55 }
56
57 // Attempt to load the configuration back.
58 loadedConfig, err := LoadConfig()
59 if err != nil {
60 t.Fatalf("LoadConfig() failed: %v", err)
61 }
62
63 // Compare the loaded configuration with the original one.
64 // reflect.DeepEqual is used for a deep comparison of the structs.
65 if !reflect.DeepEqual(loadedConfig, expectedConfig) {
66 t.Errorf("Loaded config does not match expected config.\nGot: %+v\nWant: %+v", loadedConfig, expectedConfig)
67 }
68}
69
70// TestAccountGetIMAPServer tests the logic that determines the IMAP server address.
71func TestAccountGetIMAPServer(t *testing.T) {
72 testCases := []struct {
73 name string
74 account Account
75 want string
76 }{
77 {"Gmail", Account{ServiceProvider: "gmail"}, "imap.gmail.com"},
78 {"iCloud", Account{ServiceProvider: "icloud"}, "imap.mail.me.com"},
79 {"Custom", Account{ServiceProvider: "custom", IMAPServer: "imap.custom.com"}, "imap.custom.com"},
80 {"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
81 {"Empty", Account{ServiceProvider: ""}, ""},
82 }
83
84 for _, tc := range testCases {
85 t.Run(tc.name, func(t *testing.T) {
86 got := tc.account.GetIMAPServer()
87 if got != tc.want {
88 t.Errorf("GetIMAPServer() = %q, want %q", got, tc.want)
89 }
90 })
91 }
92}
93
94// TestAccountGetSMTPServer tests the logic that determines the SMTP server address.
95func TestAccountGetSMTPServer(t *testing.T) {
96 testCases := []struct {
97 name string
98 account Account
99 want string
100 }{
101 {"Gmail", Account{ServiceProvider: "gmail"}, "smtp.gmail.com"},
102 {"iCloud", Account{ServiceProvider: "icloud"}, "smtp.mail.me.com"},
103 {"Custom", Account{ServiceProvider: "custom", SMTPServer: "smtp.custom.com"}, "smtp.custom.com"},
104 {"Unsupported", Account{ServiceProvider: "yahoo"}, ""},
105 {"Empty", Account{ServiceProvider: ""}, ""},
106 }
107
108 for _, tc := range testCases {
109 t.Run(tc.name, func(t *testing.T) {
110 got := tc.account.GetSMTPServer()
111 if got != tc.want {
112 t.Errorf("GetSMTPServer() = %q, want %q", got, tc.want)
113 }
114 })
115 }
116}
117
118// TestConfigAddRemoveAccount tests adding and removing accounts from config.
119func TestConfigAddRemoveAccount(t *testing.T) {
120 // Use an in-memory mock keyring to test the deletion step cleanly
121 keyring.MockInit()
122
123 cfg := &Config{}
124
125 // Add an account
126 account := Account{
127 Name: "Test",
128 Email: "test@example.com",
129 ServiceProvider: "gmail",
130 }
131 cfg.AddAccount(account)
132
133 if len(cfg.Accounts) != 1 {
134 t.Fatalf("Expected 1 account, got %d", len(cfg.Accounts))
135 }
136
137 // Check that ID was auto-generated
138 if cfg.Accounts[0].ID == "" {
139 t.Error("Expected account ID to be auto-generated")
140 }
141
142 // Remove the account
143 accountID := cfg.Accounts[0].ID
144 removed := cfg.RemoveAccount(accountID)
145 if !removed {
146 t.Error("RemoveAccount should return true when account exists")
147 }
148
149 if len(cfg.Accounts) != 0 {
150 t.Fatalf("Expected 0 accounts after removal, got %d", len(cfg.Accounts))
151 }
152
153 // Try to remove non-existent account
154 removed = cfg.RemoveAccount("non-existent")
155 if removed {
156 t.Error("RemoveAccount should return false for non-existent account")
157 }
158}
159
160// TestConfigGetAccountByID tests retrieving accounts by ID.
161func TestConfigGetAccountByID(t *testing.T) {
162 cfg := &Config{
163 Accounts: []Account{
164 {ID: "id-1", Email: "test1@example.com"},
165 {ID: "id-2", Email: "test2@example.com"},
166 },
167 }
168
169 account := cfg.GetAccountByID("id-1")
170 if account == nil {
171 t.Fatal("Expected to find account with id-1")
172 }
173 if account.Email != "test1@example.com" {
174 t.Errorf("Expected email test1@example.com, got %s", account.Email)
175 }
176
177 // Non-existent ID
178 account = cfg.GetAccountByID("non-existent")
179 if account != nil {
180 t.Error("Expected nil for non-existent account ID")
181 }
182}
183
184// TestConfigGetAccountByEmail tests retrieving accounts by email.
185func TestConfigGetAccountByEmail(t *testing.T) {
186 cfg := &Config{
187 Accounts: []Account{
188 {ID: "id-1", Email: "test1@example.com"},
189 {ID: "id-2", Email: "test2@example.com"},
190 },
191 }
192
193 account := cfg.GetAccountByEmail("test2@example.com")
194 if account == nil {
195 t.Fatal("Expected to find account with test2@example.com")
196 }
197 if account.ID != "id-2" {
198 t.Errorf("Expected ID id-2, got %s", account.ID)
199 }
200
201 // Non-existent email
202 account = cfg.GetAccountByEmail("nonexistent@example.com")
203 if account != nil {
204 t.Error("Expected nil for non-existent account email")
205 }
206}
207
208func TestAddContactNormalizesEmailAndDeduplicates(t *testing.T) {
209 t.Setenv("HOME", t.TempDir())
210
211 if err := AddContactForAccount("Alice", "Alice@Example.com", "account-1"); err != nil {
212 t.Fatalf("AddContactForAccount() failed: %v", err)
213 }
214 if err := AddContactForAccount("", "alice@example.com", "account-1"); err != nil {
215 t.Fatalf("AddContactForAccount() failed: %v", err)
216 }
217
218 cache, err := LoadContactsCache()
219 if err != nil {
220 t.Fatalf("LoadContactsCache() failed: %v", err)
221 }
222
223 if len(cache.Contacts) != 1 {
224 t.Fatalf("Expected 1 contact after deduplication, got %d", len(cache.Contacts))
225 }
226
227 contact := cache.Contacts[0]
228 if contact.Email != "alice@example.com" {
229 t.Errorf("Expected normalized email alice@example.com, got %s", contact.Email)
230 }
231 usage := contact.Usage["account-1"]
232 if usage.UseCount != 2 {
233 t.Errorf("Expected UseCount 2 after duplicate add, got %d", usage.UseCount)
234 }
235}
236
237func TestMigrateContactsCacheUsageExpandsLegacyUsage(t *testing.T) {
238 t.Setenv("HOME", t.TempDir())
239
240 lastUsed := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC)
241 path, err := GetContactsCachePath()
242 if err != nil {
243 t.Fatalf("GetContactsCachePath() failed: %v", err)
244 }
245 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
246 t.Fatalf("MkdirAll() failed: %v", err)
247 }
248 legacyJSON := `{"contacts":[{"name":"Alice","email":"alice@example.com","last_used":"` + lastUsed.Format(time.RFC3339) + `","use_count":7}]}`
249 if err := os.WriteFile(path, []byte(legacyJSON), 0600); err != nil {
250 t.Fatalf("WriteFile() failed: %v", err)
251 }
252
253 if err := MigrateContactsCacheUsage([]string{"account-1", "account-2"}); err != nil {
254 t.Fatalf("MigrateContactsCacheUsage() failed: %v", err)
255 }
256
257 cache, err := LoadContactsCache()
258 if err != nil {
259 t.Fatalf("LoadContactsCache() failed: %v", err)
260 }
261 if len(cache.Contacts) != 1 {
262 t.Fatalf("Expected 1 contact, got %d", len(cache.Contacts))
263 }
264 for _, accountID := range []string{"account-1", "account-2"} {
265 usage, ok := cache.Contacts[0].Usage[accountID]
266 if !ok {
267 t.Fatalf("Expected usage for %s", accountID)
268 }
269 if usage.UseCount != 7 || !usage.LastUsed.Equal(lastUsed) {
270 t.Fatalf("Unexpected usage for %s: %+v", accountID, usage)
271 }
272 }
273 if _, ok := cache.Contacts[0].Usage[legacyContactUsageKey]; ok {
274 t.Fatal("Legacy usage key should be removed after migration")
275 }
276}
277
278func TestSearchContactsForAccountFiltersAndSortsByUsage(t *testing.T) {
279 t.Setenv("HOME", t.TempDir())
280
281 now := time.Now()
282 cache := &ContactsCache{Contacts: []Contact{
283 {
284 Name: "Alice",
285 Email: "alice@example.com",
286 Usage: map[string]ContactUsage{
287 "account-1": {UseCount: 1, LastUsed: now},
288 },
289 },
290 {
291 Name: "Alicia",
292 Email: "alicia@example.com",
293 Usage: map[string]ContactUsage{
294 "account-2": {UseCount: 9, LastUsed: now.Add(time.Hour)},
295 },
296 },
297 {
298 Name: "Alina",
299 Email: "alina@example.com",
300 Usage: map[string]ContactUsage{
301 "account-1": {UseCount: 3, LastUsed: now.Add(-time.Hour)},
302 },
303 },
304 }}
305 if err := SaveContactsCache(cache); err != nil {
306 t.Fatalf("SaveContactsCache() failed: %v", err)
307 }
308
309 matches := SearchContactsForAccount("ali", "account-1")
310 if len(matches) != 2 {
311 t.Fatalf("Expected 2 account-1 matches, got %d", len(matches))
312 }
313 if matches[0].Email != "alina@example.com" {
314 t.Fatalf("Expected highest account-1 usage first, got %s", matches[0].Email)
315 }
316}
317
318func TestCleanupAccountCacheRemovesOnlyTargetAccountData(t *testing.T) {
319 t.Setenv("HOME", t.TempDir())
320
321 now := time.Now()
322 emailFor := func(accountID string, uid uint32) CachedEmail {
323 return CachedEmail{
324 UID: uid,
325 From: accountID + "@example.com",
326 Subject: "subject",
327 Date: now,
328 AccountID: accountID,
329 }
330 }
331
332 if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{
333 emailFor("account-1", 1),
334 emailFor("account-2", 2),
335 }}); err != nil {
336 t.Fatalf("SaveEmailCache() failed: %v", err)
337 }
338 if err := SaveFolderCache(&FolderCache{Accounts: []CachedFolders{
339 {AccountID: "account-1", Folders: []string{"INBOX"}},
340 {AccountID: "account-2", Folders: []string{"INBOX", "Sent"}},
341 }}); err != nil {
342 t.Fatalf("SaveFolderCache() failed: %v", err)
343 }
344 if err := SaveFolderEmailCache("INBOX", []CachedEmail{
345 emailFor("account-1", 1),
346 emailFor("account-2", 2),
347 }); err != nil {
348 t.Fatalf("SaveFolderEmailCache(INBOX) failed: %v", err)
349 }
350 if err := SaveFolderEmailCache("OnlyDeleted", []CachedEmail{
351 emailFor("account-1", 3),
352 }); err != nil {
353 t.Fatalf("SaveFolderEmailCache(OnlyDeleted) failed: %v", err)
354 }
355 if err := SaveDraftsCache(&DraftsCache{Drafts: []Draft{
356 {ID: "draft-1", AccountID: "account-1", Subject: "delete"},
357 {ID: "draft-2", AccountID: "account-2", Subject: "keep"},
358 }}); err != nil {
359 t.Fatalf("SaveDraftsCache() failed: %v", err)
360 }
361 if err := SaveContactsCache(&ContactsCache{Contacts: []Contact{
362 {
363 Name: "Shared",
364 Email: "shared@example.com",
365 Usage: map[string]ContactUsage{
366 "account-1": {UseCount: 1, LastUsed: now},
367 "account-2": {UseCount: 2, LastUsed: now},
368 },
369 },
370 {
371 Name: "Only Deleted",
372 Email: "deleted@example.com",
373 Usage: map[string]ContactUsage{
374 "account-1": {UseCount: 1, LastUsed: now},
375 },
376 },
377 }}); err != nil {
378 t.Fatalf("SaveContactsCache() failed: %v", err)
379 }
380 if err := SaveEmailBody("INBOX", CachedEmailBody{
381 UID: 1,
382 AccountID: "account-1",
383 Body: "delete",
384 }, 1<<20); err != nil {
385 t.Fatalf("SaveEmailBody(account-1) failed: %v", err)
386 }
387 if err := SaveEmailBody("INBOX", CachedEmailBody{
388 UID: 2,
389 AccountID: "account-2",
390 Body: "keep",
391 }, 1<<20); err != nil {
392 t.Fatalf("SaveEmailBody(account-2) failed: %v", err)
393 }
394 if err := SaveEmailBody("OnlyDeleted", CachedEmailBody{
395 UID: 3,
396 AccountID: "account-1",
397 Body: "delete",
398 }, 1<<20); err != nil {
399 t.Fatalf("SaveEmailBody(OnlyDeleted) failed: %v", err)
400 }
401
402 if err := CleanupAccountCache("account-1"); err != nil {
403 t.Fatalf("CleanupAccountCache() failed: %v", err)
404 }
405
406 emailCache, err := LoadEmailCache()
407 if err != nil {
408 t.Fatalf("LoadEmailCache() failed: %v", err)
409 }
410 if len(emailCache.Emails) != 1 || emailCache.Emails[0].AccountID != "account-2" {
411 t.Fatalf("Unexpected email cache after cleanup: %+v", emailCache.Emails)
412 }
413
414 folderCache, err := LoadFolderCache()
415 if err != nil {
416 t.Fatalf("LoadFolderCache() failed: %v", err)
417 }
418 if len(folderCache.Accounts) != 1 || folderCache.Accounts[0].AccountID != "account-2" {
419 t.Fatalf("Unexpected folder cache after cleanup: %+v", folderCache.Accounts)
420 }
421
422 folderEmails, err := LoadFolderEmailCache("INBOX")
423 if err != nil {
424 t.Fatalf("LoadFolderEmailCache(INBOX) failed: %v", err)
425 }
426 if len(folderEmails) != 1 || folderEmails[0].AccountID != "account-2" {
427 t.Fatalf("Unexpected folder emails after cleanup: %+v", folderEmails)
428 }
429 onlyDeletedFolderPath, err := folderEmailCacheFile("OnlyDeleted")
430 if err != nil {
431 t.Fatalf("folderEmailCacheFile() failed: %v", err)
432 }
433 if _, err := os.Stat(onlyDeletedFolderPath); !os.IsNotExist(err) {
434 t.Fatalf("Expected folder email cache with only deleted account to be removed, stat err=%v", err)
435 }
436
437 draftsCache, err := LoadDraftsCache()
438 if err != nil {
439 t.Fatalf("LoadDraftsCache() failed: %v", err)
440 }
441 if len(draftsCache.Drafts) != 1 || draftsCache.Drafts[0].AccountID != "account-2" {
442 t.Fatalf("Unexpected drafts after cleanup: %+v", draftsCache.Drafts)
443 }
444
445 contactsCache, err := LoadContactsCache()
446 if err != nil {
447 t.Fatalf("LoadContactsCache() failed: %v", err)
448 }
449 if len(contactsCache.Contacts) != 1 || contactsCache.Contacts[0].Email != "shared@example.com" {
450 t.Fatalf("Unexpected contacts after cleanup: %+v", contactsCache.Contacts)
451 }
452 if _, ok := contactsCache.Contacts[0].Usage["account-1"]; ok {
453 t.Fatal("Deleted account usage should be removed from shared contact")
454 }
455 if _, ok := contactsCache.Contacts[0].Usage["account-2"]; !ok {
456 t.Fatal("Remaining account usage should stay on shared contact")
457 }
458
459 bodyCache, err := LoadEmailBodyCache("INBOX")
460 if err != nil {
461 t.Fatalf("LoadEmailBodyCache(INBOX) failed: %v", err)
462 }
463 if len(bodyCache.Bodies) != 1 || bodyCache.Bodies[0].AccountID != "account-2" {
464 t.Fatalf("Unexpected body cache after cleanup: %+v", bodyCache.Bodies)
465 }
466 onlyDeletedBodyPath, err := bodyCacheFile("OnlyDeleted")
467 if err != nil {
468 t.Fatalf("bodyCacheFile() failed: %v", err)
469 }
470 if _, err := os.Stat(onlyDeletedBodyPath); !os.IsNotExist(err) {
471 t.Fatalf("Expected body cache with only deleted account to be removed, stat err=%v", err)
472 }
473}
474
475// TestConfigHasAccounts tests the HasAccounts method.
476func TestConfigHasAccounts(t *testing.T) {
477 cfg := &Config{}
478 if cfg.HasAccounts() {
479 t.Error("Expected HasAccounts to return false for empty config")
480 }
481
482 cfg.AddAccount(Account{Email: "test@example.com"})
483 if !cfg.HasAccounts() {
484 t.Error("Expected HasAccounts to return true after adding account")
485 }
486}
487
488// TestAccountGetPorts tests the port retrieval methods.
489func TestAccountGetPorts(t *testing.T) {
490 // Gmail account should use default ports
491 gmailAccount := Account{ServiceProvider: "gmail"}
492 if gmailAccount.GetIMAPPort() != 993 {
493 t.Errorf("Expected Gmail IMAP port 993, got %d", gmailAccount.GetIMAPPort())
494 }
495 if gmailAccount.GetSMTPPort() != 587 {
496 t.Errorf("Expected Gmail SMTP port 587, got %d", gmailAccount.GetSMTPPort())
497 }
498
499 // Custom account with custom ports
500 customAccount := Account{
501 ServiceProvider: "custom",
502 IMAPPort: 1993,
503 SMTPPort: 1587,
504 }
505 if customAccount.GetIMAPPort() != 1993 {
506 t.Errorf("Expected custom IMAP port 1993, got %d", customAccount.GetIMAPPort())
507 }
508 if customAccount.GetSMTPPort() != 1587 {
509 t.Errorf("Expected custom SMTP port 1587, got %d", customAccount.GetSMTPPort())
510 }
511
512 // Custom account with default ports (0 means use default)
513 customDefaultAccount := Account{ServiceProvider: "custom"}
514 if customDefaultAccount.GetIMAPPort() != 993 {
515 t.Errorf("Expected default IMAP port 993 for custom with no port, got %d", customDefaultAccount.GetIMAPPort())
516 }
517 if customDefaultAccount.GetSMTPPort() != 587 {
518 t.Errorf("Expected default SMTP port 587 for custom with no port, got %d", customDefaultAccount.GetSMTPPort())
519 }
520}
521
522func TestAccountSendIdentityHelpers(t *testing.T) {
523 t.Run("send as takes precedence", func(t *testing.T) {
524 account := Account{
525 Name: "Alias User",
526 Email: "login@gmail.com",
527 FetchEmail: "inbox@gmail.com",
528 SendAsEmail: "alias@example.com",
529 }
530
531 if got := account.GetFetchEmail(); got != "inbox@gmail.com" {
532 t.Fatalf("GetFetchEmail() = %q, want %q", got, "inbox@gmail.com")
533 }
534 if got := account.GetSendAsEmail(); got != "alias@example.com" {
535 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "alias@example.com")
536 }
537 if got := account.FormatFromHeader(); got != "Alias User <alias@example.com>" {
538 t.Fatalf("FormatFromHeader() = %q, want %q", got, "Alias User <alias@example.com>")
539 }
540 })
541
542 t.Run("send as falls back to fetch then login", func(t *testing.T) {
543 account := Account{
544 Name: "Fallback User",
545 Email: "login@gmail.com",
546 FetchEmail: "inbox@gmail.com",
547 }
548 if got := account.GetSendAsEmail(); got != "inbox@gmail.com" {
549 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "inbox@gmail.com")
550 }
551
552 account.FetchEmail = ""
553 if got := account.GetSendAsEmail(); got != "login@gmail.com" {
554 t.Fatalf("GetSendAsEmail() = %q, want %q", got, "login@gmail.com")
555 }
556 })
557
558 t.Run("format from header avoids double wrapping", func(t *testing.T) {
559 account := Account{
560 Name: "Account Name",
561 SendAsEmail: "Custom Name <custom@example.com>",
562 }
563 if got := account.FormatFromHeader(); got != "Custom Name <custom@example.com>" {
564 t.Fatalf("FormatFromHeader() = %q, want %q", got, "Custom Name <custom@example.com>")
565 }
566 })
567}
568
569func TestTranslateDateFormat(t *testing.T) {
570 testCases := []struct {
571 name string
572 input string
573 want string
574 sample string // expected output of time.Format for a fixed sample instant
575 }{
576 {"EU preset", DateFormatEU, "02/01/2006 15:04", "17/04/2026 09:05"},
577 {"US preset", DateFormatUS, "01/02/2006 03:04 PM", "04/17/2026 09:05 AM"},
578 {"ISO preset", DateFormatISO, "2006-01-02 15:04", "2026-04-17 09:05"},
579 {"seconds", "HH:MM:SS", "15:04:05", "09:05:00"},
580 {"explicit minutes", "YYYY-MM-DD HH:mm", "2006-01-02 15:04", "2026-04-17 09:05"},
581 {"2-digit year", "DD/MM/YY", "02/01/06", "17/04/26"},
582 {"literal passthrough", "some text", "some text", "some text"},
583 }
584
585 sample := time.Date(2026, 4, 17, 9, 5, 0, 0, time.UTC)
586 for _, tc := range testCases {
587 t.Run(tc.name, func(t *testing.T) {
588 got := translateDateFormat(tc.input)
589 if got != tc.want {
590 t.Fatalf("translateDateFormat(%q) = %q, want %q", tc.input, got, tc.want)
591 }
592 if rendered := sample.Format(got); rendered != tc.sample {
593 t.Fatalf("sample.Format(%q) = %q, want %q", got, rendered, tc.sample)
594 }
595 })
596 }
597}
598
599func TestConfigGetDateFormatDefault(t *testing.T) {
600 c := &Config{}
601 got := c.GetDateFormat()
602 want := translateDateFormat(DateFormatEU)
603 if got != want {
604 t.Fatalf("GetDateFormat() with empty DateFormat = %q, want %q", got, want)
605 }
606}
607
608func TestConfigGetDateFormatCustom(t *testing.T) {
609 c := &Config{DateFormat: "DD/MM/YYYY HH:MM"}
610 if got, want := c.GetDateFormat(), "02/01/2006 15:04"; got != want {
611 t.Fatalf("GetDateFormat() = %q, want %q", got, want)
612 }
613}