folder_cache.go

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