diff --git a/config/cache.go b/config/cache.go index a196a5c..d464f38 100644 --- a/config/cache.go +++ b/config/cache.go @@ -2,7 +2,6 @@ package config import ( "encoding/json" - "errors" "os" "path/filepath" "sort" @@ -92,70 +91,16 @@ 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 --- -const legacyContactUsageKey = "__legacy__" - -// ContactUsage stores per-account contact usage metadata. -type ContactUsage struct { +// Contact stores a contact's name and email address. +type Contact struct { + Name string `json:"name"` + Email string `json:"email"` 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"` @@ -180,11 +125,6 @@ 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 { @@ -214,13 +154,8 @@ func normalizeContactEmail(email string) string { return strings.ToLower(strings.Trim(strings.TrimSpace(email), ",<>")) } -// AddContact adds or updates a global contact in the cache. +// AddContact adds or updates a 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 } @@ -239,13 +174,8 @@ func AddContactForAccount(name, email, accountID string) error { if strings.EqualFold(c.Email, email) { // Normalize the stored email to a canonical lowercase form. cache.Contacts[i].Email = email - 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 + cache.Contacts[i].UseCount++ + cache.Contacts[i].LastUsed = time.Now() // Update name if we have a better one if name != "" && (c.Name == "" || c.Name == email) { cache.Contacts[i].Name = name @@ -257,54 +187,18 @@ func AddContactForAccount(name, email, accountID string) error { if !found { cache.Contacts = append(cache.Contacts, Contact{ - Name: name, - Email: email, - Usage: map[string]ContactUsage{ - accountID: { - LastUsed: time.Now(), - UseCount: 1, - }, - }, + Name: name, + Email: email, + LastUsed: time.Now(), + UseCount: 1, }) } return SaveContactsCache(cache) } -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. +// SearchContacts searches for contacts matching the query. 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 @@ -324,14 +218,10 @@ func SearchContactsForAccount(query, accountID 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, ", "), - Usage: map[string]ContactUsage{ - accountID: { - UseCount: 9999, // Ensure lists appear at the top - LastUsed: time.Now(), - }, - }, + Name: list.Name, + Email: strings.Join(list.Addresses, ", "), + UseCount: 9999, // Ensure lists appear at the top + LastUsed: time.Now(), }) } } @@ -340,20 +230,16 @@ func SearchContactsForAccount(query, accountID string) []Contact { for _, c := range cache.Contacts { if strings.Contains(strings.ToLower(c.Email), query) || strings.Contains(strings.ToLower(c.Name), query) { - if _, ok := contactUsageForAccount(c, accountID); ok { - matches = append(matches, c) - } + matches = append(matches, c) } } // Sort by use count (most used first), then by last used sort.Slice(matches, func(i, j int) bool { - left, _ := contactUsageForAccount(matches[i], accountID) - right, _ := contactUsageForAccount(matches[j], accountID) - if left.UseCount != right.UseCount { - return left.UseCount > right.UseCount + if matches[i].UseCount != matches[j].UseCount { + return matches[i].UseCount > matches[j].UseCount } - return left.LastUsed.After(right.LastUsed) + return matches[i].LastUsed.After(matches[j].LastUsed) }) // Limit to 5 suggestions @@ -364,69 +250,6 @@ func SearchContactsForAccount(query, accountID 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. @@ -582,27 +405,6 @@ 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). @@ -648,7 +450,7 @@ func bodyCacheDir() (string, error) { return filepath.Join(dir, "email_bodies"), nil } -// bodyBacheFile returns the file path for a folder's body cache. +// bodyCacheFile returns the file path for a folder's body cache. func bodyCacheFile(folderName string) (string, error) { dir, err := bodyCacheDir() if err != nil { @@ -692,19 +494,14 @@ func saveEmailBodyCache(cache *EmailBodyCache) error { return SecureWriteFile(path, data, 0600) } -// GetCachedEmailBody returns the cached body for a specific email, or nil if not cached. -// LastAccessedAt is updated by SaveEmailBody, not here -- a read should not -// mutate cache state. -func GetCachedEmailBody(folderName string, uid uint32, accountID string) *CachedEmailBody { - cache, err := LoadEmailBodyCache(folderName) - if err != nil { - return nil - } - for i, b := range cache.Bodies { - if b.UID == uid && b.AccountID == accountID { - return &cache.Bodies[i] - } +func GetCachedEmailBody(folderName string, uid uint32, accountID string, threshold int) *CachedEmailBody { + + lru := GetLRUInstance(threshold) + + if node := lru.Get(folderName, uid, accountID); node != nil { + return node.Body } + return nil } @@ -729,264 +526,40 @@ func calculateTotalCacheSize(cache *EmailBodyCache) int { return total } -type bodyCacheFileState struct { - path string - cache EmailBodyCache -} - -type bodyCacheEntryRef struct { - fileIndex int - bodyIndex int -} - -func loadAllEmailBodyCaches() ([]bodyCacheFileState, error) { - dir, err := bodyCacheDir() - if err != nil { - return nil, err - } - - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var caches []bodyCacheFileState - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { - continue - } - - path := filepath.Join(dir, entry.Name()) - data, err := SecureReadFile(path) - if err != nil { - return nil, err - } - - var cache EmailBodyCache - if err := json.Unmarshal(data, &cache); err != nil { - return nil, err - } - for i := range cache.Bodies { - if cache.Bodies[i].SizeBytes <= 0 { - cache.Bodies[i].SizeBytes = calculateEmailBodySize(&cache.Bodies[i]) - } - } - - caches = append(caches, bodyCacheFileState{ - path: path, - cache: cache, - }) - } - - return caches, nil -} - -func saveEmailBodyCacheFile(state *bodyCacheFileState) error { - if err := os.MkdirAll(filepath.Dir(state.path), 0700); err != nil { - return err - } - - state.cache.UpdatedAt = time.Now() - data, err := json.Marshal(&state.cache) - if err != nil { - return err - } - return SecureWriteFile(state.path, data, 0600) -} - -func pruneEmailBodyCacheSize(threshold int) error { - if threshold <= 0 { - return nil - } - - caches, err := loadAllEmailBodyCaches() - if err != nil { - return err - } - - totalSize := 0 - var refs []bodyCacheEntryRef - for fileIndex := range caches { - for bodyIndex, body := range caches[fileIndex].cache.Bodies { - totalSize += body.SizeBytes - refs = append(refs, bodyCacheEntryRef{ - fileIndex: fileIndex, - bodyIndex: bodyIndex, - }) - } - } - if totalSize <= threshold { - return nil - } - - sort.Slice(refs, func(i, j int) bool { - left := caches[refs[i].fileIndex].cache.Bodies[refs[i].bodyIndex] - right := caches[refs[j].fileIndex].cache.Bodies[refs[j].bodyIndex] - return left.LastAccessedAt.Before(right.LastAccessedAt) - }) - - remove := make(map[int]map[int]struct{}) - for _, ref := range refs { - if totalSize <= threshold { - break - } - - body := caches[ref.fileIndex].cache.Bodies[ref.bodyIndex] - totalSize -= body.SizeBytes - if remove[ref.fileIndex] == nil { - remove[ref.fileIndex] = make(map[int]struct{}) - } - remove[ref.fileIndex][ref.bodyIndex] = struct{}{} - } - - for fileIndex, bodyIndexes := range remove { - bodies := caches[fileIndex].cache.Bodies - kept := bodies[:0] - for bodyIndex, body := range bodies { - if _, ok := bodyIndexes[bodyIndex]; !ok { - kept = append(kept, body) - } - } - caches[fileIndex].cache.Bodies = kept - if err := saveEmailBodyCacheFile(&caches[fileIndex]); err != nil { - return err - } - } - - return nil -} - -// SaveEmailBody saves or updates a cached email body for a folder. func SaveEmailBody(folderName string, body CachedEmailBody, threshold int) error { - cache, err := LoadEmailBodyCache(folderName) - if err != nil { - cache = &EmailBodyCache{FolderName: folderName} - } - body.CachedAt = time.Now() - body.LastAccessedAt = time.Now() body.SizeBytes = calculateEmailBodySize(&body) - // Replace existing or append - found := false - for i, b := range cache.Bodies { - if b.UID == body.UID && b.AccountID == body.AccountID { - if body.SizeBytes <= threshold { - cache.Bodies[i] = body - } else { - cache.Bodies = append(cache.Bodies[:i], cache.Bodies[i+1:]...) - } - found = true - break - } - } - if !found && body.SizeBytes <= threshold { - cache.Bodies = append(cache.Bodies, body) - } + lru := GetLRUInstance(threshold) + lru.Put(folderName, body.UID, body.AccountID, &body) - if err := saveEmailBodyCache(cache); err != nil { - return err - } - return pruneEmailBodyCacheSize(threshold) + return nil } // PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder. // validUIDs is a map of UID -> AccountID for emails still present. -func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error { +func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string, threshold int) error { cache, err := LoadEmailBodyCache(folderName) + if err != nil { - return nil // No cache to prune + return nil } + lru := GetLRUInstance(threshold) + var kept []CachedEmailBody for _, b := range cache.Bodies { if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID { kept = append(kept, b) + } else { + lru.Delete(folderName, b.UID, b.AccountID) } } if len(kept) == len(cache.Bodies) { - return nil // Nothing pruned + return nil } 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/lru.go b/config/lru.go new file mode 100644 index 0000000..010a2a6 --- /dev/null +++ b/config/lru.go @@ -0,0 +1,267 @@ +package config + +import ( + "container/list" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type Node struct { + Key string // key = folder:uid:accountID + Folder string + Body *CachedEmailBody +} + +type LRU struct { + threshold int + currentSize int + cache map[string]*list.Element + ll *list.List +} + +var lru *LRU +var once sync.Once + +func GetLRUInstance(threshold int) *LRU { + once.Do( + func() { + lru = &LRU{ + threshold: threshold, + cache: make(map[string]*list.Element), + ll: list.New(), + } + + if err := lru.LoadFromDisk(); err != nil { + fmt.Printf("Failed to load LRU from disk: %v\n", err) + } + + }) + + return lru +} + +func (lru *LRU) makeKey(folder string, uid uint32, accountID string) string { + return fmt.Sprintf("%s:%d:%s", folder, uid, accountID) +} + +func removeBodyFromDisk(folder string, uid uint32, accountID string) error { + cache, err := LoadEmailBodyCache(folder) + + if err != nil { + return nil + } + + kept := cache.Bodies[:0] + for _, b := range cache.Bodies { + if !(b.UID == uid && b.AccountID == accountID) { + kept = append(kept, b) + } + } + + if len(kept) == len(cache.Bodies) { + return nil + } + + cache.Bodies = kept + return saveEmailBodyCache(cache) +} + +func (lru *LRU) evict() { + for lru.currentSize > lru.threshold { + back := lru.ll.Back() + + if back == nil { + break + } + + node := back.Value.(*Node) + + lru.ll.Remove(back) + delete(lru.cache, node.Key) + lru.currentSize -= node.Body.SizeBytes + + _ = removeBodyFromDisk(node.Folder, node.Body.UID, node.Body.AccountID) + } +} + +func (lru *LRU) LoadFromDisk() error { + dir, err := bodyCacheDir() + + if err != nil { + return err + } + + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + var caches []EmailBodyCache + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + + path := filepath.Join(dir, entry.Name()) + data, err := SecureReadFile(path) + if err != nil { + continue + } + + var cache EmailBodyCache + if err := json.Unmarshal(data, &cache); err != nil { + continue + } + + for i := range cache.Bodies { + if cache.Bodies[i].SizeBytes <= 0 { + cache.Bodies[i].SizeBytes = calculateEmailBodySize(&cache.Bodies[i]) + } + } + + caches = append(caches, cache) + } + + type bodyWithFolder struct { + folder string + body CachedEmailBody + } + + var allBodies []bodyWithFolder + + for _, cache := range caches { + for _, body := range cache.Bodies { + allBodies = append(allBodies, bodyWithFolder{ + folder: cache.FolderName, + body: body, + }) + } + } + + sort.Slice(allBodies, func(i, j int) bool { + ti := allBodies[i].body.LastAccessedAt + tj := allBodies[j].body.LastAccessedAt + return ti.After(tj) + }) + + for i := len(allBodies) - 1; i >= 0; i-- { + item := allBodies[i] + + if item.body.SizeBytes > lru.threshold { + continue + } + + key := lru.makeKey(item.folder, item.body.UID, item.body.AccountID) + + bodyCopy := item.body + node := &Node{ + Key: key, + Folder: item.folder, + Body: &bodyCopy, + } + + e := lru.ll.PushFront(node) + lru.cache[key] = e + lru.currentSize += item.body.SizeBytes + } + + if lru.currentSize > lru.threshold { + lru.evict() + } + return nil +} + +func saveEmailBodyToDisk(folder string, body *CachedEmailBody) error { + cache, err := LoadEmailBodyCache(folder) + + if err != nil { + cache = &EmailBodyCache{FolderName: folder} + } + + found := false + for i, b := range cache.Bodies { + if b.UID == body.UID && b.AccountID == body.AccountID { + cache.Bodies[i] = *body + found = true + break + } + } + if !found { + cache.Bodies = append(cache.Bodies, *body) + } + + return saveEmailBodyCache(cache) +} + +func (lru *LRU) Get(folder string, uid uint32, accountID string) *Node { + key := lru.makeKey(folder, uid, accountID) + + e, ok := lru.cache[key] + + if !ok { + return nil + } + + lru.ll.MoveToFront(e) + + node := e.Value.(*Node) + node.Body.LastAccessedAt = time.Now() + + _ = saveEmailBodyToDisk(folder, node.Body) + + return node +} + +func (lru *LRU) removeKey(key string) { + if e, ok := lru.cache[key]; ok { + node := e.Value.(*Node) + + lru.currentSize -= node.Body.SizeBytes + lru.ll.Remove(e) + delete(lru.cache, key) + } +} + +func (lru *LRU) Put(folder string, uid uint32, accountID string, body *CachedEmailBody) { + key := lru.makeKey(folder, uid, accountID) + + if body.SizeBytes > lru.threshold { + lru.removeKey(key) + return + } + + body.LastAccessedAt = time.Now() + + if e, ok := lru.cache[key]; ok { + node := e.Value.(*Node) + lru.currentSize -= node.Body.SizeBytes + lru.currentSize += body.SizeBytes + node.Body = body + lru.ll.MoveToFront(e) + } else { + node := &Node{ + Key: key, + Folder: folder, + Body: body, + } + e := lru.ll.PushFront(node) + lru.cache[key] = e + lru.currentSize += body.SizeBytes + } + + lru.evict() + + _ = saveEmailBodyToDisk(folder, body) +} + +func (lru *LRU) Delete(folder string, uid uint32, accountID string) { + key := lru.makeKey(folder, uid, accountID) + lru.removeKey(key) + _ = removeBodyFromDisk(folder, uid, accountID) +} diff --git a/main.go b/main.go index f3a4c30..feeb504 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,6 @@ import ( "strings" "sync" "time" - "unicode/utf8" tea "charm.land/bubbletea/v2" "github.com/floatpane/matcha/backend" @@ -102,8 +101,7 @@ type mainModel struct { idleWatcher *fetcher.IdleWatcher idleUpdates chan fetcher.IdleUpdate // Multi-protocol backend providers (keyed by account ID) - providers map[string]backend.Provider - providersMu sync.RWMutex + providers map[string]backend.Provider // Daemon client service (daemon or direct fallback) service daemonclient.Service // Plugin prompt waiting for user input @@ -152,38 +150,20 @@ func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel { } // ensureProviders creates backend providers for all configured accounts. -// newSettings constructs a settings model and wires it to the plugin manager -// so the Plugins category can list and edit plugin-declared settings. -func (m *mainModel) newSettings() *tui.Settings { - s := tui.NewSettings(m.config) - if m.plugins != nil { - s.SetPlugins(m.plugins) - } - return s -} - func (m *mainModel) ensureProviders() { if m.config == nil { return } for _, acct := range m.config.Accounts { - m.providersMu.RLock() - _, ok := m.providers[acct.ID] - m.providersMu.RUnlock() - - if ok { + if _, ok := m.providers[acct.ID]; ok { continue } - p, err := backend.New(&acct) if err != nil { log.Printf("backend: failed to create provider for %s: %v", acct.Email, err) continue } - - m.providersMu.Lock() m.providers[acct.ID] = p - m.providersMu.Unlock() } } @@ -192,12 +172,7 @@ func (m *mainModel) getProvider(acct *config.Account) backend.Provider { if acct == nil { return nil } - - m.providersMu.RLock() - p := m.providers[acct.ID] - m.providersMu.RUnlock() - - return p + return m.providers[acct.ID] } func (m *mainModel) Init() tea.Cmd { @@ -456,7 +431,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if isEdit { - m.current = m.newSettings() + m.current = tui.NewSettings(m.config) } else { m.current = tui.NewChoice() } @@ -667,7 +642,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, e := range msg.Emails { validUIDs[e.UID] = e.AccountID } - _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs) + _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs, m.config.GetBodyCacheThreshold()) }() // Only update the view if the user is still on this folder if m.folderInbox.GetCurrentFolder() != msg.FolderName { @@ -736,7 +711,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } folderName := m.folderInbox.GetCurrentFolder() // Check cache first - if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil { + if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil { var attachments []fetcher.Attachment for _, ca := range cached.Attachments { att := fetcher.Attachment{ @@ -1057,7 +1032,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch curr := m.current.(type) { case *tui.Settings: // Preserve settings state when rebuilding - newSettings := m.newSettings() + newSettings := tui.NewSettings(m.config) newSettings.RestoreState(curr.GetState()) m.current = newSettings case *tui.Composer: @@ -1067,7 +1042,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = tui.NewChoice() case *tui.FolderInbox: // Just rebuild settings view, folder inbox will be recreated on next navigation - m.current = m.newSettings() + m.current = tui.NewSettings(m.config) default: // For other views, return to choice menu m.current = tui.NewChoice() @@ -1076,7 +1051,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() case tui.GoToSettingsMsg: - m.current = m.newSettings() + m.current = tui.NewSettings(m.config) m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() @@ -1136,7 +1111,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } // Return to settings - m.current = m.newSettings() + m.current = tui.NewSettings(m.config) // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default. m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() @@ -1155,9 +1130,6 @@ 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() @@ -1216,13 +1188,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.DeleteAccountMsg: if m.config != nil { - 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) - } + m.config.RemoveAccount(msg.AccountID) + 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) @@ -1235,7 +1203,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.emails = allEmails // Go back to settings - m.current = m.newSettings() + m.current = tui.NewSettings(m.config) m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) } return m, m.current.Init() @@ -1276,7 +1244,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) } // Check body cache first - if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil { + if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID, m.config.GetBodyCacheThreshold()); cached != nil { // Convert cached attachments back to fetcher.Attachment var attachments []fetcher.Attachment for _, ca := range cached.Attachments { @@ -1566,7 +1534,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { continue } name, email := parseEmailAddress(r) - if err := config.AddContactForAccount(name, email, msg.AccountID); err != nil { + if err := config.AddContact(name, email); err != nil { log.Printf("Error saving contact: %v", err) } } @@ -2390,7 +2358,7 @@ func saveEmailsToCache(emails []fetcher.Email) { // Save sender as a contact if email.From != "" { name, emailAddr := parseEmailAddress(email.From) - if err := config.AddContactForAccount(name, emailAddr, email.AccountID); err != nil { + if err := config.AddContact(name, emailAddr); err != nil { log.Printf("Error saving contact from email: %v", err) } } @@ -3016,29 +2984,13 @@ func sanitizeFilename(name string) string { if len(name) > maxFilenameLen { ext := filepath.Ext(name) if len(ext) > maxFilenameLen { - ext = truncateUTF8(ext, maxFilenameLen) + ext = ext[:maxFilenameLen] } - base := strings.TrimSuffix(name, ext) - name = truncateUTF8(base, maxFilenameLen-len(ext)) + ext + name = name[:maxFilenameLen-len(ext)] + ext } return name } -func truncateUTF8(s string, maxBytes int) string { - if maxBytes <= 0 { - return "" - } - if len(s) <= maxBytes { - return s - } - s = s[:maxBytes] - for !utf8.ValidString(s) { - _, size := utf8.DecodeLastRuneInString(s) - s = s[:len(s)-size] - } - return s -} - func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd { return func() tea.Msg { // Download and decode the attachment using encoding provided in msg.Encoding. @@ -3908,9 +3860,6 @@ 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) } @@ -3935,9 +3884,6 @@ func main() { // Initialize plugin system plugins := plugin.NewManager() plugins.LoadPlugins() - if initialModel.config != nil { - plugins.LoadSettingValues(initialModel.config.PluginSettings) - } initialModel.plugins = plugins tui.BodyTransformer = func(body string, email fetcher.Email) string { folder := "INBOX" @@ -3979,7 +3925,6 @@ func main() { plugins.CallHook(plugin.HookShutdown) plugins.Close() } - func runDaemonCLI(args []string) { if len(args) == 0 { fmt.Println("Usage: matcha daemon ")