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}