fix: clean account caches on deletion (#1265)

FromSi created

Change summary

cli/contacts_export.go                   |   5 
cli/contacts_export_test.go              |  83 +----
config/cache.go                          | 313 ++++++++++++++++++++++++-
config/config.go                         |  11 
config/config_test.go                    | 253 ++++++++++++++++++++
config/folder_cache.go                   |  81 ++++++
config/macos_sync.go                     |  26 +
internal/collections/collections.go      |  28 ++
internal/collections/collections_test.go |  42 +++
main.go                                  |  20 +
tui/composer.go                          |   2 
11 files changed, 765 insertions(+), 99 deletions(-)

Detailed changes

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

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)

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).
@@ -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),
+	)
+}

config/config.go 🔗

@@ -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 {

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)
 	}
 }
 

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 {

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
+				}
 			}
 		}
 	}

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)
+}

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)
+		}
+	})
+}

main.go 🔗

@@ -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)
 			}

tui/composer.go 🔗

@@ -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 {