Detailed changes
@@ -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
@@ -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)
@@ -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).
@@ -717,3 +915,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),
+ )
+}
@@ -738,6 +738,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 {
@@ -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)
}
}
@@ -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 {
@@ -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
+ }
}
}
}
@@ -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)
+}
@@ -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)
+ }
+ })
+}
@@ -1155,6 +1155,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()
@@ -1213,9 +1216,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)
@@ -1559,7 +1566,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)
}
}
@@ -2383,7 +2390,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)
}
}
@@ -3901,6 +3908,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)
}
@@ -591,7 +591,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 {