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