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}