diff --git a/cli/contacts_export.go b/cli/contacts_export.go index bfc34901..c85ad06d 100644 --- a/cli/contacts_export.go +++ b/cli/contacts_export.go @@ -131,11 +131,12 @@ func exportToCSV(contacts []config.Contact, noHeader bool) ([]byte, error) { } for _, c := range contacts { + usage := config.ContactAggregateUsage(c) record := []string{ escapeCSV(c.Name), escapeCSV(c.Email), - c.LastUsed.Format("2006-01-02T15:04:05Z07:00"), - fmt.Sprintf("%d", c.UseCount), + usage.LastUsed.Format("2006-01-02T15:04:05Z07:00"), + fmt.Sprintf("%d", usage.UseCount), } if err := writer.Write(record); err != nil { return nil, err diff --git a/cli/contacts_export_test.go b/cli/contacts_export_test.go index 74f96114..30cb944e 100644 --- a/cli/contacts_export_test.go +++ b/cli/contacts_export_test.go @@ -11,20 +11,23 @@ import ( "github.com/floatpane/matcha/config" ) +func testContact(name, email string, lastUsed time.Time, useCount int) config.Contact { + return config.Contact{ + Name: name, + Email: email, + Usage: map[string]config.ContactUsage{ + "account-1": { + LastUsed: lastUsed, + UseCount: useCount, + }, + }, + } +} + func TestExportToJSON(t *testing.T) { contacts := []config.Contact{ - { - Name: "John Doe", - Email: "john@example.com", - LastUsed: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), - UseCount: 5, - }, - { - Name: "Jane Smith", - Email: "jane@test.com", - LastUsed: time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), - UseCount: 10, - }, + testContact("John Doe", "john@example.com", time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 5), + testContact("Jane Smith", "jane@test.com", time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), 10), } data, err := exportToJSON(contacts) @@ -48,18 +51,8 @@ func TestExportToJSON(t *testing.T) { func TestExportToCSV(t *testing.T) { contacts := []config.Contact{ - { - Name: "John Doe", - Email: "john@example.com", - LastUsed: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), - UseCount: 5, - }, - { - Name: "Jane Smith", - Email: "jane@test.com", - LastUsed: time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), - UseCount: 10, - }, + testContact("John Doe", "john@example.com", time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 5), + testContact("Jane Smith", "jane@test.com", time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), 10), } data, err := exportToCSV(contacts, false) @@ -89,18 +82,8 @@ func TestExportToCSV(t *testing.T) { func TestExportToCSVNoHeader(t *testing.T) { contacts := []config.Contact{ - { - Name: "John Doe", - Email: "john@example.com", - LastUsed: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), - UseCount: 5, - }, - { - Name: "Jane Smith", - Email: "jane@test.com", - LastUsed: time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), - UseCount: 10, - }, + testContact("John Doe", "john@example.com", time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), 5), + testContact("Jane Smith", "jane@test.com", time.Date(2024, 2, 20, 14, 0, 0, 0, time.UTC), 10), } data, err := exportToCSV(contacts, true) @@ -156,18 +139,8 @@ func TestEscapeCSV(t *testing.T) { func TestExportToCSVWithSpecialChars(t *testing.T) { contacts := []config.Contact{ - { - Name: "Test, User", - Email: "test@example.com", - LastUsed: time.Now(), - UseCount: 1, - }, - { - Name: `Test "Quotes"`, - Email: "quotes@test.com", - LastUsed: time.Now(), - UseCount: 2, - }, + testContact("Test, User", "test@example.com", time.Now(), 1), + testContact(`Test "Quotes"`, "quotes@test.com", time.Now(), 2), } data, err := exportToCSV(contacts, false) @@ -195,12 +168,7 @@ func TestExportJSONToFile(t *testing.T) { outputPath := filepath.Join(tmpDir, "contacts.json") contacts := []config.Contact{ - { - Name: "Test User", - Email: "test@example.com", - LastUsed: time.Now(), - UseCount: 1, - }, + testContact("Test User", "test@example.com", time.Now(), 1), } data, err := exportToJSON(contacts) @@ -233,12 +201,7 @@ func TestExportCSVToFile(t *testing.T) { outputPath := filepath.Join(tmpDir, "contacts.csv") contacts := []config.Contact{ - { - Name: "Test User", - Email: "test@example.com", - LastUsed: time.Now(), - UseCount: 1, - }, + testContact("Test User", "test@example.com", time.Now(), 1), } data, err := exportToCSV(contacts, false) diff --git a/config/cache.go b/config/cache.go index 0774516f..eb6e5aed 100644 --- a/config/cache.go +++ b/config/cache.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "errors" "os" "path/filepath" "sort" @@ -91,16 +92,70 @@ func ClearEmailCache() error { return os.Remove(path) } +func removeAccountFromEmailCache(accountID string) error { + cache, err := LoadEmailCache() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + filtered := cache.Emails[:0] + for _, email := range cache.Emails { + if email.AccountID != accountID { + filtered = append(filtered, email) + } + } + if len(filtered) == len(cache.Emails) { + return nil + } + cache.Emails = filtered + return SaveEmailCache(cache) +} + // --- Contacts Cache --- -// Contact stores a contact's name and email address. -type Contact struct { - Name string `json:"name"` - Email string `json:"email"` +const legacyContactUsageKey = "__legacy__" + +// ContactUsage stores per-account contact usage metadata. +type ContactUsage struct { LastUsed time.Time `json:"last_used"` UseCount int `json:"use_count"` } +// Contact stores a contact's name, email address, and per-account usage. +type Contact struct { + Name string `json:"name"` + Email string `json:"email"` + Usage map[string]ContactUsage `json:"usage_by_account"` +} + +// UnmarshalJSON accepts both the current usage_by_account format and the +// legacy last_used/use_count fields so old contacts can be migrated. +func (c *Contact) UnmarshalJSON(data []byte) error { + type contactAlias Contact + aux := struct { + *contactAlias + LastUsed time.Time `json:"last_used"` + UseCount int `json:"use_count"` + }{ + contactAlias: (*contactAlias)(c), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + if c.Usage == nil { + c.Usage = make(map[string]ContactUsage) + } + if len(c.Usage) == 0 && (!aux.LastUsed.IsZero() || aux.UseCount > 0) { + c.Usage[legacyContactUsageKey] = ContactUsage{ + LastUsed: aux.LastUsed, + UseCount: aux.UseCount, + } + } + return nil +} + // ContactsCache stores all known contacts. type ContactsCache struct { Contacts []Contact `json:"contacts"` @@ -125,6 +180,11 @@ func SaveContactsCache(cache *ContactsCache) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } + for i := range cache.Contacts { + if cache.Contacts[i].Usage == nil { + cache.Contacts[i].Usage = make(map[string]ContactUsage) + } + } cache.UpdatedAt = time.Now() data, err := json.MarshalIndent(cache, "", " ") if err != nil { @@ -154,8 +214,13 @@ func normalizeContactEmail(email string) string { return strings.ToLower(strings.Trim(strings.TrimSpace(email), ",<>")) } -// AddContact adds or updates a contact in the cache. +// AddContact adds or updates a global contact in the cache. func AddContact(name, email string) error { + return AddContactForAccount(name, email, "") +} + +// AddContactForAccount adds or updates a contact in the cache for an account. +func AddContactForAccount(name, email, accountID string) error { if email == "" { return nil } @@ -174,8 +239,13 @@ func AddContact(name, email string) error { if strings.EqualFold(c.Email, email) { // Normalize the stored email to a canonical lowercase form. cache.Contacts[i].Email = email - cache.Contacts[i].UseCount++ - cache.Contacts[i].LastUsed = time.Now() + if cache.Contacts[i].Usage == nil { + cache.Contacts[i].Usage = make(map[string]ContactUsage) + } + usage := cache.Contacts[i].Usage[accountID] + usage.UseCount++ + usage.LastUsed = time.Now() + cache.Contacts[i].Usage[accountID] = usage // Update name if we have a better one if name != "" && (c.Name == "" || c.Name == email) { cache.Contacts[i].Name = name @@ -187,18 +257,54 @@ func AddContact(name, email string) error { if !found { cache.Contacts = append(cache.Contacts, Contact{ - Name: name, - Email: email, - LastUsed: time.Now(), - UseCount: 1, + Name: name, + Email: email, + Usage: map[string]ContactUsage{ + accountID: { + LastUsed: time.Now(), + UseCount: 1, + }, + }, }) } return SaveContactsCache(cache) } -// SearchContacts searches for contacts matching the query. +func contactUsageForAccount(c Contact, accountID string) (ContactUsage, bool) { + if len(c.Usage) == 0 { + return ContactUsage{}, accountID == "" + } + if accountID != "" { + if usage, ok := c.Usage[legacyContactUsageKey]; ok { + return usage, true + } + usage, ok := c.Usage[accountID] + return usage, ok + } + var aggregate ContactUsage + for _, usage := range c.Usage { + aggregate.UseCount += usage.UseCount + if usage.LastUsed.After(aggregate.LastUsed) { + aggregate.LastUsed = usage.LastUsed + } + } + return aggregate, true +} + +// ContactAggregateUsage returns a contact's total usage across accounts. +func ContactAggregateUsage(c Contact) ContactUsage { + usage, _ := contactUsageForAccount(c, "") + return usage +} + +// SearchContacts searches for contacts matching the query across all accounts. func SearchContacts(query string) []Contact { + return SearchContactsForAccount(query, "") +} + +// SearchContactsForAccount searches for contacts matching the query for an account. +func SearchContactsForAccount(query, accountID string) []Contact { cache, err := LoadContactsCache() if err != nil { return nil @@ -218,10 +324,14 @@ func SearchContacts(query string) []Contact { if strings.Contains(strings.ToLower(list.Name), query) { // Convert mailing list to a virtual contact matches = append(matches, Contact{ - Name: list.Name, - Email: strings.Join(list.Addresses, ", "), - UseCount: 9999, // Ensure lists appear at the top - LastUsed: time.Now(), + Name: list.Name, + Email: strings.Join(list.Addresses, ", "), + Usage: map[string]ContactUsage{ + accountID: { + UseCount: 9999, // Ensure lists appear at the top + LastUsed: time.Now(), + }, + }, }) } } @@ -230,16 +340,20 @@ func SearchContacts(query string) []Contact { for _, c := range cache.Contacts { if strings.Contains(strings.ToLower(c.Email), query) || strings.Contains(strings.ToLower(c.Name), query) { - matches = append(matches, c) + if _, ok := contactUsageForAccount(c, accountID); ok { + matches = append(matches, c) + } } } // Sort by use count (most used first), then by last used sort.Slice(matches, func(i, j int) bool { - if matches[i].UseCount != matches[j].UseCount { - return matches[i].UseCount > matches[j].UseCount + left, _ := contactUsageForAccount(matches[i], accountID) + right, _ := contactUsageForAccount(matches[j], accountID) + if left.UseCount != right.UseCount { + return left.UseCount > right.UseCount } - return matches[i].LastUsed.After(matches[j].LastUsed) + return left.LastUsed.After(right.LastUsed) }) // Limit to 5 suggestions @@ -250,6 +364,69 @@ func SearchContacts(query string) []Contact { return matches } +// MigrateContactsCacheUsage expands legacy global contact usage to all accounts. +func MigrateContactsCacheUsage(accountIDs []string) error { + cache, err := LoadContactsCache() + if err != nil { + return nil + } + + changed := false + for i := range cache.Contacts { + if cache.Contacts[i].Usage == nil { + cache.Contacts[i].Usage = make(map[string]ContactUsage) + changed = true + } + legacyUsage, hasLegacy := cache.Contacts[i].Usage[legacyContactUsageKey] + if !hasLegacy { + continue + } + delete(cache.Contacts[i].Usage, legacyContactUsageKey) + for _, accountID := range accountIDs { + if accountID == "" { + continue + } + if _, ok := cache.Contacts[i].Usage[accountID]; !ok { + cache.Contacts[i].Usage[accountID] = legacyUsage + } + } + changed = true + } + if !changed { + return nil + } + return SaveContactsCache(cache) +} + +func removeAccountFromContactsCache(accountID string) error { + cache, err := LoadContactsCache() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + changed := false + filtered := cache.Contacts[:0] + for _, contact := range cache.Contacts { + if _, ok := contact.Usage[accountID]; ok { + delete(contact.Usage, accountID) + changed = true + } + if len(contact.Usage) > 0 { + filtered = append(filtered, contact) + } else { + changed = true + } + } + if !changed { + return nil + } + cache.Contacts = filtered + return SaveContactsCache(cache) +} + // --- Drafts Cache --- // Draft stores a saved email draft. @@ -405,6 +582,27 @@ func HasDrafts() bool { return len(cache.Drafts) > 0 } +func removeAccountFromDraftsCache(accountID string) error { + cache, err := LoadDraftsCache() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + filtered := cache.Drafts[:0] + for _, draft := range cache.Drafts { + if draft.AccountID != accountID { + filtered = append(filtered, draft) + } + } + if len(filtered) == len(cache.Drafts) { + return nil + } + cache.Drafts = filtered + return SaveDraftsCache(cache) +} + // --- Email Body Cache --- // CachedAttachment stores attachment metadata (not the binary data). @@ -596,3 +794,78 @@ func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error { cache.Bodies = kept return saveEmailBodyCache(cache) } + +func removeAccountFromEmailBodyCaches(accountID string) error { + dir, err := bodyCacheDir() + if err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var errs []error + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name()) + data, err := SecureReadFile(path) + if err != nil { + errs = append(errs, err) + continue + } + var cache EmailBodyCache + if err := json.Unmarshal(data, &cache); err != nil { + errs = append(errs, err) + continue + } + + filtered := cache.Bodies[:0] + for _, body := range cache.Bodies { + if body.AccountID != accountID { + filtered = append(filtered, body) + } + } + if len(filtered) == len(cache.Bodies) { + continue + } + if len(filtered) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + errs = append(errs, err) + } + continue + } + cache.Bodies = filtered + cache.UpdatedAt = time.Now() + data, err = json.Marshal(cache) + if err != nil { + errs = append(errs, err) + continue + } + if err := SecureWriteFile(path, data, 0600); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// CleanupAccountCache removes cached data associated with an account. +func CleanupAccountCache(accountID string) error { + if accountID == "" { + return nil + } + + return errors.Join( + removeAccountFromEmailCache(accountID), + removeAccountFromFolderCache(accountID), + removeAccountFromFolderEmailCaches(accountID), + removeAccountFromEmailBodyCaches(accountID), + removeAccountFromContactsCache(accountID), + removeAccountFromDraftsCache(accountID), + ) +} diff --git a/config/config.go b/config/config.go index d3c860df..f1304cda 100644 --- a/config/config.go +++ b/config/config.go @@ -730,6 +730,17 @@ func (c *Config) HasAccounts() bool { return len(c.Accounts) > 0 } +// GetAccountIDs returns the configured account IDs. +func (c *Config) GetAccountIDs() []string { + ids := make([]string, 0, len(c.Accounts)) + for _, acc := range c.Accounts { + if acc.ID != "" { + ids = append(ids, acc.ID) + } + } + return ids +} + // GetFirstAccount returns the first account or nil if none exist. func (c *Config) GetFirstAccount() *Account { if len(c.Accounts) > 0 { diff --git a/config/config_test.go b/config/config_test.go index 87d602fe..e7974d18 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "reflect" "testing" "time" @@ -206,11 +208,11 @@ func TestConfigGetAccountByEmail(t *testing.T) { func TestAddContactNormalizesEmailAndDeduplicates(t *testing.T) { t.Setenv("HOME", t.TempDir()) - if err := AddContact("Alice", "Alice@Example.com"); err != nil { - t.Fatalf("AddContact() failed: %v", err) + if err := AddContactForAccount("Alice", "Alice@Example.com", "account-1"); err != nil { + t.Fatalf("AddContactForAccount() failed: %v", err) } - if err := AddContact("", "alice@example.com"); err != nil { - t.Fatalf("AddContact() failed: %v", err) + if err := AddContactForAccount("", "alice@example.com", "account-1"); err != nil { + t.Fatalf("AddContactForAccount() failed: %v", err) } cache, err := LoadContactsCache() @@ -226,8 +228,247 @@ func TestAddContactNormalizesEmailAndDeduplicates(t *testing.T) { if contact.Email != "alice@example.com" { t.Errorf("Expected normalized email alice@example.com, got %s", contact.Email) } - if contact.UseCount != 2 { - t.Errorf("Expected UseCount 2 after duplicate add, got %d", contact.UseCount) + usage := contact.Usage["account-1"] + if usage.UseCount != 2 { + t.Errorf("Expected UseCount 2 after duplicate add, got %d", usage.UseCount) + } +} + +func TestMigrateContactsCacheUsageExpandsLegacyUsage(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + lastUsed := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC) + path, err := GetContactsCachePath() + if err != nil { + t.Fatalf("GetContactsCachePath() failed: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("MkdirAll() failed: %v", err) + } + legacyJSON := `{"contacts":[{"name":"Alice","email":"alice@example.com","last_used":"` + lastUsed.Format(time.RFC3339) + `","use_count":7}]}` + if err := os.WriteFile(path, []byte(legacyJSON), 0600); err != nil { + t.Fatalf("WriteFile() failed: %v", err) + } + + if err := MigrateContactsCacheUsage([]string{"account-1", "account-2"}); err != nil { + t.Fatalf("MigrateContactsCacheUsage() failed: %v", err) + } + + cache, err := LoadContactsCache() + if err != nil { + t.Fatalf("LoadContactsCache() failed: %v", err) + } + if len(cache.Contacts) != 1 { + t.Fatalf("Expected 1 contact, got %d", len(cache.Contacts)) + } + for _, accountID := range []string{"account-1", "account-2"} { + usage, ok := cache.Contacts[0].Usage[accountID] + if !ok { + t.Fatalf("Expected usage for %s", accountID) + } + if usage.UseCount != 7 || !usage.LastUsed.Equal(lastUsed) { + t.Fatalf("Unexpected usage for %s: %+v", accountID, usage) + } + } + if _, ok := cache.Contacts[0].Usage[legacyContactUsageKey]; ok { + t.Fatal("Legacy usage key should be removed after migration") + } +} + +func TestSearchContactsForAccountFiltersAndSortsByUsage(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + now := time.Now() + cache := &ContactsCache{Contacts: []Contact{ + { + Name: "Alice", + Email: "alice@example.com", + Usage: map[string]ContactUsage{ + "account-1": {UseCount: 1, LastUsed: now}, + }, + }, + { + Name: "Alicia", + Email: "alicia@example.com", + Usage: map[string]ContactUsage{ + "account-2": {UseCount: 9, LastUsed: now.Add(time.Hour)}, + }, + }, + { + Name: "Alina", + Email: "alina@example.com", + Usage: map[string]ContactUsage{ + "account-1": {UseCount: 3, LastUsed: now.Add(-time.Hour)}, + }, + }, + }} + if err := SaveContactsCache(cache); err != nil { + t.Fatalf("SaveContactsCache() failed: %v", err) + } + + matches := SearchContactsForAccount("ali", "account-1") + if len(matches) != 2 { + t.Fatalf("Expected 2 account-1 matches, got %d", len(matches)) + } + if matches[0].Email != "alina@example.com" { + t.Fatalf("Expected highest account-1 usage first, got %s", matches[0].Email) + } +} + +func TestCleanupAccountCacheRemovesOnlyTargetAccountData(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + now := time.Now() + emailFor := func(accountID string, uid uint32) CachedEmail { + return CachedEmail{ + UID: uid, + From: accountID + "@example.com", + Subject: "subject", + Date: now, + AccountID: accountID, + } + } + + if err := SaveEmailCache(&EmailCache{Emails: []CachedEmail{ + emailFor("account-1", 1), + emailFor("account-2", 2), + }}); err != nil { + t.Fatalf("SaveEmailCache() failed: %v", err) + } + if err := SaveFolderCache(&FolderCache{Accounts: []CachedFolders{ + {AccountID: "account-1", Folders: []string{"INBOX"}}, + {AccountID: "account-2", Folders: []string{"INBOX", "Sent"}}, + }}); err != nil { + t.Fatalf("SaveFolderCache() failed: %v", err) + } + if err := SaveFolderEmailCache("INBOX", []CachedEmail{ + emailFor("account-1", 1), + emailFor("account-2", 2), + }); err != nil { + t.Fatalf("SaveFolderEmailCache(INBOX) failed: %v", err) + } + if err := SaveFolderEmailCache("OnlyDeleted", []CachedEmail{ + emailFor("account-1", 3), + }); err != nil { + t.Fatalf("SaveFolderEmailCache(OnlyDeleted) failed: %v", err) + } + if err := SaveDraftsCache(&DraftsCache{Drafts: []Draft{ + {ID: "draft-1", AccountID: "account-1", Subject: "delete"}, + {ID: "draft-2", AccountID: "account-2", Subject: "keep"}, + }}); err != nil { + t.Fatalf("SaveDraftsCache() failed: %v", err) + } + if err := SaveContactsCache(&ContactsCache{Contacts: []Contact{ + { + Name: "Shared", + Email: "shared@example.com", + Usage: map[string]ContactUsage{ + "account-1": {UseCount: 1, LastUsed: now}, + "account-2": {UseCount: 2, LastUsed: now}, + }, + }, + { + Name: "Only Deleted", + Email: "deleted@example.com", + Usage: map[string]ContactUsage{ + "account-1": {UseCount: 1, LastUsed: now}, + }, + }, + }}); err != nil { + t.Fatalf("SaveContactsCache() failed: %v", err) + } + if err := SaveEmailBody("INBOX", CachedEmailBody{ + UID: 1, + AccountID: "account-1", + Body: "delete", + }, 1<<20); err != nil { + t.Fatalf("SaveEmailBody(account-1) failed: %v", err) + } + if err := SaveEmailBody("INBOX", CachedEmailBody{ + UID: 2, + AccountID: "account-2", + Body: "keep", + }, 1<<20); err != nil { + t.Fatalf("SaveEmailBody(account-2) failed: %v", err) + } + if err := SaveEmailBody("OnlyDeleted", CachedEmailBody{ + UID: 3, + AccountID: "account-1", + Body: "delete", + }, 1<<20); err != nil { + t.Fatalf("SaveEmailBody(OnlyDeleted) failed: %v", err) + } + + if err := CleanupAccountCache("account-1"); err != nil { + t.Fatalf("CleanupAccountCache() failed: %v", err) + } + + emailCache, err := LoadEmailCache() + if err != nil { + t.Fatalf("LoadEmailCache() failed: %v", err) + } + if len(emailCache.Emails) != 1 || emailCache.Emails[0].AccountID != "account-2" { + t.Fatalf("Unexpected email cache after cleanup: %+v", emailCache.Emails) + } + + folderCache, err := LoadFolderCache() + if err != nil { + t.Fatalf("LoadFolderCache() failed: %v", err) + } + if len(folderCache.Accounts) != 1 || folderCache.Accounts[0].AccountID != "account-2" { + t.Fatalf("Unexpected folder cache after cleanup: %+v", folderCache.Accounts) + } + + folderEmails, err := LoadFolderEmailCache("INBOX") + if err != nil { + t.Fatalf("LoadFolderEmailCache(INBOX) failed: %v", err) + } + if len(folderEmails) != 1 || folderEmails[0].AccountID != "account-2" { + t.Fatalf("Unexpected folder emails after cleanup: %+v", folderEmails) + } + onlyDeletedFolderPath, err := folderEmailCacheFile("OnlyDeleted") + if err != nil { + t.Fatalf("folderEmailCacheFile() failed: %v", err) + } + if _, err := os.Stat(onlyDeletedFolderPath); !os.IsNotExist(err) { + t.Fatalf("Expected folder email cache with only deleted account to be removed, stat err=%v", err) + } + + draftsCache, err := LoadDraftsCache() + if err != nil { + t.Fatalf("LoadDraftsCache() failed: %v", err) + } + if len(draftsCache.Drafts) != 1 || draftsCache.Drafts[0].AccountID != "account-2" { + t.Fatalf("Unexpected drafts after cleanup: %+v", draftsCache.Drafts) + } + + contactsCache, err := LoadContactsCache() + if err != nil { + t.Fatalf("LoadContactsCache() failed: %v", err) + } + if len(contactsCache.Contacts) != 1 || contactsCache.Contacts[0].Email != "shared@example.com" { + t.Fatalf("Unexpected contacts after cleanup: %+v", contactsCache.Contacts) + } + if _, ok := contactsCache.Contacts[0].Usage["account-1"]; ok { + t.Fatal("Deleted account usage should be removed from shared contact") + } + if _, ok := contactsCache.Contacts[0].Usage["account-2"]; !ok { + t.Fatal("Remaining account usage should stay on shared contact") + } + + bodyCache, err := LoadEmailBodyCache("INBOX") + if err != nil { + t.Fatalf("LoadEmailBodyCache(INBOX) failed: %v", err) + } + if len(bodyCache.Bodies) != 1 || bodyCache.Bodies[0].AccountID != "account-2" { + t.Fatalf("Unexpected body cache after cleanup: %+v", bodyCache.Bodies) + } + onlyDeletedBodyPath, err := bodyCacheFile("OnlyDeleted") + if err != nil { + t.Fatalf("bodyCacheFile() failed: %v", err) + } + if _, err := os.Stat(onlyDeletedBodyPath); !os.IsNotExist(err) { + t.Fatalf("Expected body cache with only deleted account to be removed, stat err=%v", err) } } diff --git a/config/folder_cache.go b/config/folder_cache.go index 222cc1ec..ab30fae6 100644 --- a/config/folder_cache.go +++ b/config/folder_cache.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "errors" "os" "path/filepath" "strconv" @@ -110,6 +111,27 @@ func SaveAccountFolders(accountID string, folders []string) error { return SaveFolderCache(cache) } +func removeAccountFromFolderCache(accountID string) error { + cache, err := LoadFolderCache() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + filtered := cache.Accounts[:0] + for _, account := range cache.Accounts { + if account.AccountID != accountID { + filtered = append(filtered, account) + } + } + if len(filtered) == len(cache.Accounts) { + return nil + } + cache.Accounts = filtered + return SaveFolderCache(cache) +} + // --- Per-folder email cache --- // FolderEmailCache stores cached emails for a specific folder. @@ -184,6 +206,65 @@ func LoadFolderEmailCache(folderName string) ([]CachedEmail, error) { return cache.Emails, nil } +func removeAccountFromFolderEmailCaches(accountID string) error { + dir, err := folderEmailCacheDir() + if err != nil { + return err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var errs []error + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(dir, entry.Name()) + data, err := SecureReadFile(path) + if err != nil { + errs = append(errs, err) + continue + } + var cache FolderEmailCache + if err := json.Unmarshal(data, &cache); err != nil { + errs = append(errs, err) + continue + } + + filtered := cache.Emails[:0] + for _, email := range cache.Emails { + if email.AccountID != accountID { + filtered = append(filtered, email) + } + } + if len(filtered) == len(cache.Emails) { + continue + } + if len(filtered) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + errs = append(errs, err) + } + continue + } + cache.Emails = filtered + cache.UpdatedAt = time.Now() + data, err = json.Marshal(cache) + if err != nil { + errs = append(errs, err) + continue + } + if err := SecureWriteFile(path, data, 0600); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + func LoadFolderEmailHeaders(folderName string) ([]threading.EmailHeader, error) { emails, err := LoadFolderEmailCache(folderName) if err != nil { diff --git a/config/macos_sync.go b/config/macos_sync.go index dd8b9d4e..fa5fae27 100644 --- a/config/macos_sync.go +++ b/config/macos_sync.go @@ -5,26 +5,42 @@ import ( "runtime" "github.com/floatpane/matcha/clib/macos" + "github.com/floatpane/matcha/internal/collections" ) // SyncMacOSContacts fetches contacts from the macOS Contacts framework -// and merges them into the local contacts cache. +// and merges them into the local contacts cache for all configured accounts. func SyncMacOSContacts() error { if runtime.GOOS != "darwin" { return nil } + cfg, err := LoadConfig() + if err != nil { + return err + } + return SyncMacOSContactsForAccounts(cfg.GetAccountIDs()) +} + +// SyncMacOSContactsForAccounts fetches contacts from the macOS Contacts framework +// and merges them into the local contacts cache for the given accounts. +func SyncMacOSContactsForAccounts(accountIDs []string) error { + if runtime.GOOS != "darwin" { + return nil + } macContacts, err := macos.FetchContacts() if err != nil { return fmt.Errorf("failed to fetch macOS contacts: %w", err) } + accountIDs = collections.UniqueNonEmpty(accountIDs) for _, mc := range macContacts { for _, email := range mc.Emails { - // AddContact handles deduplication and name updates - if err := AddContact(mc.Name, email); err != nil { - // We continue even if one fails - continue + for _, accountID := range accountIDs { + if err := AddContactForAccount(mc.Name, email, accountID); err != nil { + // We continue even if one fails + continue + } } } } diff --git a/internal/collections/collections.go b/internal/collections/collections.go new file mode 100644 index 00000000..4fc6a721 --- /dev/null +++ b/internal/collections/collections.go @@ -0,0 +1,28 @@ +package collections + +// Unique returns values with duplicates removed, preserving first-seen order. +func Unique[S ~[]E, E comparable](values S) S { + seen := make(map[E]struct{}, len(values)) + unique := make(S, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +// UniqueNonEmpty returns values with zero values and duplicates removed. +func UniqueNonEmpty[S ~[]E, E comparable](values S) S { + var zero E + nonEmpty := make(S, 0, len(values)) + for _, value := range values { + if value == zero { + continue + } + nonEmpty = append(nonEmpty, value) + } + return Unique(nonEmpty) +} diff --git a/internal/collections/collections_test.go b/internal/collections/collections_test.go new file mode 100644 index 00000000..7901f24a --- /dev/null +++ b/internal/collections/collections_test.go @@ -0,0 +1,42 @@ +package collections + +import ( + "reflect" + "testing" +) + +func TestUniqueRemovesDuplicates(t *testing.T) { + t.Run("With strings", func(t *testing.T) { + got := Unique([]string{"acct-1", "acct-2", "acct-1", "", "acct-3", ""}) + want := []string{"acct-1", "acct-2", "", "acct-3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Unique() = %#v, want %#v", got, want) + } + }) + + t.Run("With ints", func(t *testing.T) { + got := Unique([]int{1, 2, 1, 0, 3, 0}) + want := []int{1, 2, 0, 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Unique() = %#v, want %#v", got, want) + } + }) +} + +func TestUniqueNonEmptyRemovesZeroValuesAndDuplicates(t *testing.T) { + t.Run("With strings", func(t *testing.T) { + got := UniqueNonEmpty([]string{"", "acct-1", "acct-2", "acct-1", "", "acct-3"}) + want := []string{"acct-1", "acct-2", "acct-3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("UniqueNonEmpty() = %#v, want %#v", got, want) + } + }) + + t.Run("With ints", func(t *testing.T) { + got := UniqueNonEmpty([]int{0, 1, 2, 1, 0, 3}) + want := []int{1, 2, 3} + if !reflect.DeepEqual(got, want) { + t.Fatalf("UniqueNonEmpty() = %#v, want %#v", got, want) + } + }) +} diff --git a/main.go b/main.go index 6e683d11..d6d73a1b 100644 --- a/main.go +++ b/main.go @@ -1130,6 +1130,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { config.SetSessionKey(msg.Key) cfg, err := config.LoadConfig() if err == nil { + if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil { + log.Printf("warning: contacts migration failed: %v", migrateErr) + } if cfg.Theme != "" { theme.SetTheme(cfg.Theme) tui.RebuildStyles() @@ -1188,9 +1191,13 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.DeleteAccountMsg: if m.config != nil { - m.config.RemoveAccount(msg.AccountID) - if err := config.SaveConfig(m.config); err != nil { - log.Printf("could not save config: %v", err) + if m.config.RemoveAccount(msg.AccountID) { + if err := config.CleanupAccountCache(msg.AccountID); err != nil { + log.Printf("could not clean account cache: %v", err) + } + if err := config.SaveConfig(m.config); err != nil { + log.Printf("could not save config: %v", err) + } } // Remove emails for this account delete(m.emailsByAcct, msg.AccountID) @@ -1534,7 +1541,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { continue } name, email := parseEmailAddress(r) - if err := config.AddContact(name, email); err != nil { + if err := config.AddContactForAccount(name, email, msg.AccountID); err != nil { log.Printf("Error saving contact: %v", err) } } @@ -2358,7 +2365,7 @@ func saveEmailsToCache(emails []fetcher.Email) { // Save sender as a contact if email.From != "" { name, emailAddr := parseEmailAddress(email.From) - if err := config.AddContact(name, emailAddr); err != nil { + if err := config.AddContactForAccount(name, emailAddr, email.AccountID); err != nil { log.Printf("Error saving contact from email: %v", err) } } @@ -3860,6 +3867,9 @@ func main() { } else { cfg, err := config.LoadConfig() if err == nil { + if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil { + log.Printf("warning: contacts migration failed: %v", migrateErr) + } if cfg.Theme != "" { theme.SetTheme(cfg.Theme) } diff --git a/tui/composer.go b/tui/composer.go index 8614cb1d..09700a10 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -553,7 +553,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { lastPart := strings.TrimSpace(parts[len(parts)-1]) if len(lastPart) >= 2 { - m.suggestions = config.SearchContacts(lastPart) + m.suggestions = config.SearchContactsForAccount(lastPart, m.GetSelectedAccountID()) m.showSuggestions = len(m.suggestions) > 0 m.selectedSuggestion = 0 } else {