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