folder_cache.go

  1package config
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"os"
  7	"path/filepath"
  8	"strconv"
  9	"strings"
 10	"time"
 11
 12	"github.com/floatpane/matcha/internal/threading"
 13)
 14
 15// CachedFolders stores folder names for a single account.
 16type CachedFolders struct {
 17	AccountID string    `json:"account_id"`
 18	Folders   []string  `json:"folders"`
 19	UpdatedAt time.Time `json:"updated_at"`
 20}
 21
 22// FolderCache stores cached folders for all accounts.
 23type FolderCache struct {
 24	Accounts        []CachedFolders `json:"accounts"`
 25	ThreadedFolders map[string]bool `json:"threaded_folders,omitempty"`
 26	UpdatedAt       time.Time       `json:"updated_at"`
 27}
 28
 29// folderCacheFile returns the full path to the folder cache file.
 30func folderCacheFile() (string, error) {
 31	dir, err := cacheDir()
 32	if err != nil {
 33		return "", err
 34	}
 35	return filepath.Join(dir, "folder_cache.json"), nil
 36}
 37
 38// SaveFolderCache saves the folder cache to disk.
 39func SaveFolderCache(cache *FolderCache) error {
 40	path, err := folderCacheFile()
 41	if err != nil {
 42		return err
 43	}
 44	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
 45		return err
 46	}
 47	cache.UpdatedAt = time.Now()
 48	data, err := json.MarshalIndent(cache, "", "  ")
 49	if err != nil {
 50		return err
 51	}
 52	return SecureWriteFile(path, data, 0600)
 53}
 54
 55// LoadFolderCache loads the folder cache from disk.
 56func LoadFolderCache() (*FolderCache, error) {
 57	path, err := folderCacheFile()
 58	if err != nil {
 59		return nil, err
 60	}
 61	data, err := SecureReadFile(path)
 62	if err != nil {
 63		return nil, err
 64	}
 65	var cache FolderCache
 66	if err := json.Unmarshal(data, &cache); err != nil {
 67		return nil, err
 68	}
 69	return &cache, nil
 70}
 71
 72// GetCachedFolders returns cached folder names for a specific account.
 73func GetCachedFolders(accountID string) []string {
 74	cache, err := LoadFolderCache()
 75	if err != nil {
 76		return nil
 77	}
 78	for _, acc := range cache.Accounts {
 79		if acc.AccountID == accountID {
 80			return acc.Folders
 81		}
 82	}
 83	return nil
 84}
 85
 86// SaveAccountFolders saves folder names for a specific account, merging into the existing cache.
 87func SaveAccountFolders(accountID string, folders []string) error {
 88	cache, err := LoadFolderCache()
 89	if err != nil {
 90		cache = &FolderCache{}
 91	}
 92
 93	found := false
 94	for i, acc := range cache.Accounts {
 95		if acc.AccountID == accountID {
 96			cache.Accounts[i].Folders = folders
 97			cache.Accounts[i].UpdatedAt = time.Now()
 98			found = true
 99			break
100		}
101	}
102
103	if !found {
104		cache.Accounts = append(cache.Accounts, CachedFolders{
105			AccountID: accountID,
106			Folders:   folders,
107			UpdatedAt: time.Now(),
108		})
109	}
110
111	return SaveFolderCache(cache)
112}
113
114func removeAccountFromFolderCache(accountID string) error {
115	cache, err := LoadFolderCache()
116	if err != nil {
117		if os.IsNotExist(err) {
118			return nil
119		}
120		return err
121	}
122	filtered := cache.Accounts[:0]
123	for _, account := range cache.Accounts {
124		if account.AccountID != accountID {
125			filtered = append(filtered, account)
126		}
127	}
128	if len(filtered) == len(cache.Accounts) {
129		return nil
130	}
131	cache.Accounts = filtered
132	return SaveFolderCache(cache)
133}
134
135// --- Per-folder email cache ---
136
137// FolderEmailCache stores cached emails for a specific folder.
138type FolderEmailCache struct {
139	FolderName string        `json:"folder_name"`
140	Emails     []CachedEmail `json:"emails"`
141	UpdatedAt  time.Time     `json:"updated_at"`
142}
143
144// folderEmailCacheDir returns the directory for folder email cache files.
145func folderEmailCacheDir() (string, error) {
146	dir, err := cacheDir()
147	if err != nil {
148		return "", err
149	}
150	return filepath.Join(dir, "folder_emails"), nil
151}
152
153// folderEmailCacheFile returns the file path for a folder's email cache.
154// Uses a sanitized folder name to avoid filesystem issues.
155func folderEmailCacheFile(folderName string) (string, error) {
156	dir, err := folderEmailCacheDir()
157	if err != nil {
158		return "", err
159	}
160	// Sanitize folder name for use as filename
161	safe := sanitizeFolderName(folderName)
162	return filepath.Join(dir, safe+".json"), nil
163}
164
165func sanitizeFolderName(name string) string {
166	// Replace path separators and other problematic chars
167	replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
168	return replacer.Replace(name)
169}
170
171// SaveFolderEmailCache saves emails for a folder to disk.
172func SaveFolderEmailCache(folderName string, emails []CachedEmail) error {
173	path, err := folderEmailCacheFile(folderName)
174	if err != nil {
175		return err
176	}
177	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
178		return err
179	}
180	cache := FolderEmailCache{
181		FolderName: folderName,
182		Emails:     emails,
183		UpdatedAt:  time.Now(),
184	}
185	data, err := json.Marshal(cache)
186	if err != nil {
187		return err
188	}
189	return SecureWriteFile(path, data, 0600)
190}
191
192// LoadFolderEmailCache loads cached emails for a folder from disk.
193func LoadFolderEmailCache(folderName string) ([]CachedEmail, error) {
194	path, err := folderEmailCacheFile(folderName)
195	if err != nil {
196		return nil, err
197	}
198	data, err := SecureReadFile(path)
199	if err != nil {
200		return nil, err
201	}
202	var cache FolderEmailCache
203	if err := json.Unmarshal(data, &cache); err != nil {
204		return nil, err
205	}
206	return cache.Emails, nil
207}
208
209func removeAccountFromFolderEmailCaches(accountID string) error {
210	dir, err := folderEmailCacheDir()
211	if err != nil {
212		return err
213	}
214	entries, err := os.ReadDir(dir)
215	if err != nil {
216		if os.IsNotExist(err) {
217			return nil
218		}
219		return err
220	}
221
222	var errs []error
223	for _, entry := range entries {
224		if entry.IsDir() {
225			continue
226		}
227		path := filepath.Join(dir, entry.Name())
228		data, err := SecureReadFile(path)
229		if err != nil {
230			errs = append(errs, err)
231			continue
232		}
233		var cache FolderEmailCache
234		if err := json.Unmarshal(data, &cache); err != nil {
235			errs = append(errs, err)
236			continue
237		}
238
239		filtered := cache.Emails[:0]
240		for _, email := range cache.Emails {
241			if email.AccountID != accountID {
242				filtered = append(filtered, email)
243			}
244		}
245		if len(filtered) == len(cache.Emails) {
246			continue
247		}
248		if len(filtered) == 0 {
249			if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
250				errs = append(errs, err)
251			}
252			continue
253		}
254		cache.Emails = filtered
255		cache.UpdatedAt = time.Now()
256		data, err = json.Marshal(cache)
257		if err != nil {
258			errs = append(errs, err)
259			continue
260		}
261		if err := SecureWriteFile(path, data, 0600); err != nil {
262			errs = append(errs, err)
263		}
264	}
265	return errors.Join(errs...)
266}
267
268func LoadFolderEmailHeaders(folderName string) ([]threading.EmailHeader, error) {
269	emails, err := LoadFolderEmailCache(folderName)
270	if err != nil {
271		return nil, err
272	}
273	headers := make([]threading.EmailHeader, 0, len(emails))
274	for _, email := range emails {
275		headers = append(headers, threading.EmailHeader{
276			ID:         email.MessageID,
277			InReplyTo:  email.InReplyTo,
278			References: email.References,
279			Subject:    email.Subject,
280			Date:       email.Date,
281			EmailID:    cachedEmailID(email),
282			Sender:     email.From,
283		})
284	}
285	return headers, nil
286}
287
288// IsFolderThreaded returns the threading state for a folder. If the user has
289// explicitly toggled threading for this folder, that override is returned.
290// Otherwise defaultEnabled (from Config.EnableThreaded) is used.
291func IsFolderThreaded(folderName string, defaultEnabled bool) bool {
292	cache, err := LoadFolderCache()
293	if err != nil || cache.ThreadedFolders == nil {
294		return defaultEnabled
295	}
296	v, ok := cache.ThreadedFolders[folderName]
297	if !ok {
298		return defaultEnabled
299	}
300	return v
301}
302
303// SetFolderThreaded stores an explicit per-folder threading override.
304func SetFolderThreaded(folderName string, threaded bool) error {
305	cache, err := LoadFolderCache()
306	if err != nil {
307		cache = &FolderCache{}
308	}
309	if cache.ThreadedFolders == nil {
310		cache.ThreadedFolders = make(map[string]bool)
311	}
312	cache.ThreadedFolders[folderName] = threaded
313	return SaveFolderCache(cache)
314}
315
316func cachedEmailID(email CachedEmail) string {
317	return email.AccountID + ":" + formatUID(email.UID)
318}
319
320func formatUID(uid uint32) string {
321	return strconv.FormatUint(uint64(uid), 10)
322}