1package config
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7
8 "github.com/google/uuid"
9 "github.com/zalando/go-keyring"
10)
11
12const keyringServiceName = "matcha-email-client"
13
14// Account stores the configuration for a single email account.
15type Account struct {
16 ID string `json:"id"`
17 Name string `json:"name"`
18 Email string `json:"email"`
19 Password string `json:"-"` // "-" prevents the password from being saved to config.json
20 ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom"
21 // FetchEmail is the single email address for which messages should be fetched.
22 // If empty, it will default to `Email` when accounts are added.
23 FetchEmail string `json:"fetch_email,omitempty"`
24
25 // Custom server settings (used when ServiceProvider is "custom")
26 IMAPServer string `json:"imap_server,omitempty"`
27 IMAPPort int `json:"imap_port,omitempty"`
28 SMTPServer string `json:"smtp_server,omitempty"`
29 SMTPPort int `json:"smtp_port,omitempty"`
30 Insecure bool `json:"insecure,omitempty"`
31
32 // S/MIME settings
33 SMIMECert string `json:"smime_cert,omitempty"` // Path to the public certificate PEM
34 SMIMEKey string `json:"smime_key,omitempty"` // Path to the private key PEM
35 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default
36
37 // PGP settings
38 PGPPublicKey string `json:"pgp_public_key,omitempty"` // Path to public key (.asc or .gpg)
39 PGPPrivateKey string `json:"pgp_private_key,omitempty"` // Path to private key (.asc or .gpg)
40 PGPKeySource string `json:"pgp_key_source,omitempty"` // "file" (default) or "yubikey" for hardware key
41 PGPPIN string `json:"-"` // YubiKey PIN (stored in keyring, not JSON)
42 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails
43
44 // OAuth2 settings
45 AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
46
47 // Multi-protocol settings
48 Protocol string `json:"protocol,omitempty"` // "imap" (default), "jmap", or "pop3"
49 JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
50 POP3Server string `json:"pop3_server,omitempty"` // POP3 server hostname (for protocol=pop3)
51 POP3Port int `json:"pop3_port,omitempty"` // POP3 server port (for protocol=pop3)
52}
53
54// MailingList represents a named group of email addresses.
55type MailingList struct {
56 Name string `json:"name"`
57 Addresses []string `json:"addresses"`
58}
59
60// Config stores the user's email configuration with multiple accounts.
61type Config struct {
62 Accounts []Account `json:"accounts"`
63 DisableImages bool `json:"disable_images,omitempty"`
64 HideTips bool `json:"hide_tips,omitempty"`
65 DisableNotifications bool `json:"disable_notifications,omitempty"`
66 Theme string `json:"theme,omitempty"`
67 MailingLists []MailingList `json:"mailing_lists,omitempty"`
68}
69
70// GetIMAPServer returns the IMAP server address for the account.
71func (a *Account) GetIMAPServer() string {
72 switch a.ServiceProvider {
73 case "gmail":
74 return "imap.gmail.com"
75 case "icloud":
76 return "imap.mail.me.com"
77 case "custom":
78 return a.IMAPServer
79 default:
80 return ""
81 }
82}
83
84// GetIMAPPort returns the IMAP port for the account.
85func (a *Account) GetIMAPPort() int {
86 switch a.ServiceProvider {
87 case "gmail", "icloud":
88 return 993
89 case "custom":
90 if a.IMAPPort != 0 {
91 return a.IMAPPort
92 }
93 return 993 // Default IMAP SSL port
94 default:
95 return 993
96 }
97}
98
99// GetSMTPServer returns the SMTP server address for the account.
100func (a *Account) GetSMTPServer() string {
101 switch a.ServiceProvider {
102 case "gmail":
103 return "smtp.gmail.com"
104 case "icloud":
105 return "smtp.mail.me.com"
106 case "custom":
107 return a.SMTPServer
108 default:
109 return ""
110 }
111}
112
113// GetSMTPPort returns the SMTP port for the account.
114func (a *Account) GetSMTPPort() int {
115 switch a.ServiceProvider {
116 case "gmail", "icloud":
117 return 587
118 case "custom":
119 if a.SMTPPort != 0 {
120 return a.SMTPPort
121 }
122 return 587 // Default SMTP TLS port
123 default:
124 return 587
125 }
126}
127
128// GetPOP3Server returns the POP3 server address for the account.
129func (a *Account) GetPOP3Server() string {
130 if a.POP3Server != "" {
131 return a.POP3Server
132 }
133 return ""
134}
135
136// GetPOP3Port returns the POP3 port for the account.
137func (a *Account) GetPOP3Port() int {
138 if a.POP3Port != 0 {
139 return a.POP3Port
140 }
141 return 995 // Default POP3 SSL port
142}
143
144// GetConfigDir returns the path to the configuration directory (exported).
145func GetConfigDir() (string, error) {
146 return configDir()
147}
148
149// configDir returns the path to the configuration directory (internal).
150func configDir() (string, error) {
151 home, err := os.UserHomeDir()
152 if err != nil {
153 return "", err
154 }
155 return filepath.Join(home, ".config", "matcha"), nil
156}
157
158// GetCacheDir returns the path to the cache directory (exported).
159func GetCacheDir() (string, error) {
160 return cacheDir()
161}
162
163// cacheDir returns the path to the cache directory (internal).
164func cacheDir() (string, error) {
165 home, err := os.UserHomeDir()
166 if err != nil {
167 return "", err
168 }
169 return filepath.Join(home, ".cache", "matcha"), nil
170}
171
172// MigrateCacheFiles moves cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed.
173// This is a one-time migration for existing installations.
174func MigrateCacheFiles() error {
175 src, err := configDir()
176 if err != nil {
177 return err
178 }
179 dst, err := cacheDir()
180 if err != nil {
181 return err
182 }
183 if err := os.MkdirAll(dst, 0700); err != nil {
184 return err
185 }
186
187 // Files to migrate
188 files := []string{"email_cache.json", "contacts.json", "drafts.json", "folder_cache.json"}
189 for _, f := range files {
190 oldPath := filepath.Join(src, f)
191 newPath := filepath.Join(dst, f)
192 if _, err := os.Stat(oldPath); err == nil {
193 // Only migrate if destination doesn't already exist
194 if _, err := os.Stat(newPath); err != nil {
195 if err := os.Rename(oldPath, newPath); err != nil {
196 return err
197 }
198 }
199 }
200 }
201
202 // Migrate folder_emails directory
203 oldDir := filepath.Join(src, "folder_emails")
204 newDir := filepath.Join(dst, "folder_emails")
205 if info, err := os.Stat(oldDir); err == nil && info.IsDir() {
206 if _, err := os.Stat(newDir); err != nil {
207 if err := os.Rename(oldDir, newDir); err != nil {
208 return err
209 }
210 }
211 }
212
213 return nil
214}
215
216// configFile returns the full path to the configuration file.
217func configFile() (string, error) {
218 dir, err := configDir()
219 if err != nil {
220 return "", err
221 }
222 return filepath.Join(dir, "config.json"), nil
223}
224
225// secureDiskAccount includes the Password field in JSON when secure mode is active.
226type secureDiskAccount struct {
227 ID string `json:"id"`
228 Name string `json:"name"`
229 Email string `json:"email"`
230 Password string `json:"password,omitempty"`
231 ServiceProvider string `json:"service_provider"`
232 FetchEmail string `json:"fetch_email,omitempty"`
233 IMAPServer string `json:"imap_server,omitempty"`
234 IMAPPort int `json:"imap_port,omitempty"`
235 SMTPServer string `json:"smtp_server,omitempty"`
236 SMTPPort int `json:"smtp_port,omitempty"`
237 Insecure bool `json:"insecure,omitempty"`
238 SMIMECert string `json:"smime_cert,omitempty"`
239 SMIMEKey string `json:"smime_key,omitempty"`
240 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
241 PGPPublicKey string `json:"pgp_public_key,omitempty"`
242 PGPPrivateKey string `json:"pgp_private_key,omitempty"`
243 PGPKeySource string `json:"pgp_key_source,omitempty"`
244 PGPPIN string `json:"pgp_pin,omitempty"`
245 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
246 AuthMethod string `json:"auth_method,omitempty"`
247 Protocol string `json:"protocol,omitempty"`
248 JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
249 POP3Server string `json:"pop3_server,omitempty"`
250 POP3Port int `json:"pop3_port,omitempty"`
251}
252
253type secureDiskConfig struct {
254 Accounts []secureDiskAccount `json:"accounts"`
255 DisableImages bool `json:"disable_images,omitempty"`
256 HideTips bool `json:"hide_tips,omitempty"`
257 DisableNotifications bool `json:"disable_notifications,omitempty"`
258 Theme string `json:"theme,omitempty"`
259 MailingLists []MailingList `json:"mailing_lists,omitempty"`
260}
261
262// SaveConfig saves the given configuration to the config file and passwords to the keyring.
263func SaveConfig(config *Config) error {
264 secureMode := GetSessionKey() != nil
265
266 if !secureMode {
267 // Save passwords and PGP PINs to the OS keyring before writing the JSON file
268 for _, acc := range config.Accounts {
269 if acc.Password != "" {
270 _ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
271 }
272 if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
273 _ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
274 }
275 }
276 }
277
278 path, err := configFile()
279 if err != nil {
280 return err
281 }
282 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
283 return err
284 }
285
286 var data []byte
287 if secureMode {
288 // In secure mode, include passwords in the JSON (they'll be encrypted on disk)
289 sdc := secureDiskConfig{
290 DisableImages: config.DisableImages,
291 HideTips: config.HideTips,
292 DisableNotifications: config.DisableNotifications,
293 Theme: config.Theme,
294 MailingLists: config.MailingLists,
295 }
296 for _, acc := range config.Accounts {
297 sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
298 ID: acc.ID,
299 Name: acc.Name,
300 Email: acc.Email,
301 Password: acc.Password,
302 ServiceProvider: acc.ServiceProvider,
303 FetchEmail: acc.FetchEmail,
304 IMAPServer: acc.IMAPServer,
305 IMAPPort: acc.IMAPPort,
306 SMTPServer: acc.SMTPServer,
307 SMTPPort: acc.SMTPPort,
308 Insecure: acc.Insecure,
309 SMIMECert: acc.SMIMECert,
310 SMIMEKey: acc.SMIMEKey,
311 SMIMESignByDefault: acc.SMIMESignByDefault,
312 PGPPublicKey: acc.PGPPublicKey,
313 PGPPrivateKey: acc.PGPPrivateKey,
314 PGPKeySource: acc.PGPKeySource,
315 PGPPIN: acc.PGPPIN,
316 PGPSignByDefault: acc.PGPSignByDefault,
317 AuthMethod: acc.AuthMethod,
318 Protocol: acc.Protocol,
319 JMAPEndpoint: acc.JMAPEndpoint,
320 POP3Server: acc.POP3Server,
321 POP3Port: acc.POP3Port,
322 })
323 }
324 data, err = json.MarshalIndent(sdc, "", " ")
325 } else {
326 data, err = json.MarshalIndent(config, "", " ")
327 }
328 if err != nil {
329 return err
330 }
331 return SecureWriteFile(path, data, 0600)
332}
333
334// LoadConfig loads the configuration from the config file and passwords from the keyring.
335// It automatically migrates plain-text passwords to the OS keyring if they exist.
336func LoadConfig() (*Config, error) {
337 path, err := configFile()
338 if err != nil {
339 return nil, err
340 }
341 data, err := SecureReadFile(path)
342 if err != nil {
343 return nil, err
344 }
345
346 secureMode := GetSessionKey() != nil
347
348 var config Config
349 var needsMigration bool
350
351 type rawAccount struct {
352 ID string `json:"id"`
353 Name string `json:"name"`
354 Email string `json:"email"`
355 Password string `json:"password,omitempty"`
356 ServiceProvider string `json:"service_provider"`
357 FetchEmail string `json:"fetch_email,omitempty"`
358 IMAPServer string `json:"imap_server,omitempty"`
359 IMAPPort int `json:"imap_port,omitempty"`
360 SMTPServer string `json:"smtp_server,omitempty"`
361 SMTPPort int `json:"smtp_port,omitempty"`
362 Insecure bool `json:"insecure,omitempty"`
363 SMIMECert string `json:"smime_cert,omitempty"`
364 SMIMEKey string `json:"smime_key,omitempty"`
365 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
366 PGPPublicKey string `json:"pgp_public_key,omitempty"`
367 PGPPrivateKey string `json:"pgp_private_key,omitempty"`
368 PGPKeySource string `json:"pgp_key_source,omitempty"`
369 PGPPIN string `json:"pgp_pin,omitempty"`
370 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
371 AuthMethod string `json:"auth_method,omitempty"`
372 Protocol string `json:"protocol,omitempty"`
373 JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
374 POP3Server string `json:"pop3_server,omitempty"`
375 POP3Port int `json:"pop3_port,omitempty"`
376 }
377 type diskConfig struct {
378 Accounts []rawAccount `json:"accounts"`
379 DisableImages bool `json:"disable_images,omitempty"`
380 HideTips bool `json:"hide_tips,omitempty"`
381 DisableNotifications bool `json:"disable_notifications,omitempty"`
382 Theme string `json:"theme,omitempty"`
383 MailingLists []MailingList `json:"mailing_lists,omitempty"`
384 }
385
386 var raw diskConfig
387 if err := json.Unmarshal(data, &raw); err != nil {
388 var legacyConfig legacyConfigFormat
389 if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
390 config = Config{
391 Accounts: []Account{
392 {
393 ID: uuid.New().String(),
394 Name: legacyConfig.Name,
395 Email: legacyConfig.Email,
396 Password: legacyConfig.Password,
397 ServiceProvider: legacyConfig.ServiceProvider,
398 FetchEmail: legacyConfig.Email,
399 },
400 },
401 }
402 // SaveConfig automatically pushes the password to the keyring and strips it from JSON
403 if saveErr := SaveConfig(&config); saveErr != nil {
404 return nil, saveErr
405 }
406 return &config, nil
407 }
408 return nil, err
409 }
410
411 config.DisableImages = raw.DisableImages
412 config.HideTips = raw.HideTips
413 config.DisableNotifications = raw.DisableNotifications
414 config.Theme = raw.Theme
415 config.MailingLists = raw.MailingLists
416 for _, rawAcc := range raw.Accounts {
417 acc := Account{
418 ID: rawAcc.ID,
419 Name: rawAcc.Name,
420 Email: rawAcc.Email,
421 ServiceProvider: rawAcc.ServiceProvider,
422 FetchEmail: rawAcc.FetchEmail,
423 IMAPServer: rawAcc.IMAPServer,
424 IMAPPort: rawAcc.IMAPPort,
425 SMTPServer: rawAcc.SMTPServer,
426 SMTPPort: rawAcc.SMTPPort,
427 Insecure: rawAcc.Insecure,
428 SMIMECert: rawAcc.SMIMECert,
429 SMIMEKey: rawAcc.SMIMEKey,
430 SMIMESignByDefault: rawAcc.SMIMESignByDefault,
431 PGPPublicKey: rawAcc.PGPPublicKey,
432 PGPPrivateKey: rawAcc.PGPPrivateKey,
433 PGPKeySource: rawAcc.PGPKeySource,
434 PGPSignByDefault: rawAcc.PGPSignByDefault,
435 AuthMethod: rawAcc.AuthMethod,
436 Protocol: rawAcc.Protocol,
437 JMAPEndpoint: rawAcc.JMAPEndpoint,
438 POP3Server: rawAcc.POP3Server,
439 POP3Port: rawAcc.POP3Port,
440 }
441
442 if secureMode {
443 // In secure mode, passwords and PINs are stored in the encrypted config JSON
444 acc.Password = rawAcc.Password
445 acc.PGPPIN = rawAcc.PGPPIN
446 } else if rawAcc.Password != "" {
447 // Found a plain-text password! Move it to the OS Keyring.
448 _ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password)
449 acc.Password = rawAcc.Password
450 needsMigration = true
451 } else {
452 // No plaintext password in JSON, fetch from Keyring as normal.
453 if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
454 acc.Password = pwd
455 }
456 }
457
458 if !secureMode {
459 // Load YubiKey PIN from keyring if using YubiKey
460 if acc.PGPKeySource == "yubikey" {
461 if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
462 acc.PGPPIN = pin
463 }
464 }
465 }
466
467 config.Accounts = append(config.Accounts, acc)
468 }
469
470 if needsMigration {
471 if saveErr := SaveConfig(&config); saveErr != nil {
472 return nil, saveErr
473 }
474 }
475
476 return &config, nil
477}
478
479// legacyConfigFormat represents the old single-account configuration format.
480type legacyConfigFormat struct {
481 ServiceProvider string `json:"service_provider"`
482 Email string `json:"email"`
483 Password string `json:"password"`
484 Name string `json:"name"`
485}
486
487// AddAccount adds a new account to the configuration.
488func (c *Config) AddAccount(account Account) {
489 if account.ID == "" {
490 account.ID = uuid.New().String()
491 }
492 // Ensure FetchEmail defaults to the login Email if not explicitly set.
493 if account.FetchEmail == "" && account.Email != "" {
494 account.FetchEmail = account.Email
495 }
496 c.Accounts = append(c.Accounts, account)
497}
498
499// RemoveAccount removes an account by its ID and deletes its password from the keyring.
500func (c *Config) RemoveAccount(id string) bool {
501 for i, acc := range c.Accounts {
502 if acc.ID == id {
503 // Delete password from OS Keyring when account is removed
504 _ = keyring.Delete(keyringServiceName, acc.Email)
505 // Delete PGP PIN from OS Keyring if present
506 _ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
507
508 c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
509 return true
510 }
511 }
512 return false
513}
514
515// GetAccountByID returns an account by its ID.
516func (c *Config) GetAccountByID(id string) *Account {
517 for i := range c.Accounts {
518 if c.Accounts[i].ID == id {
519 return &c.Accounts[i]
520 }
521 }
522 return nil
523}
524
525// GetAccountByEmail returns an account by its email address.
526func (c *Config) GetAccountByEmail(email string) *Account {
527 for i := range c.Accounts {
528 if c.Accounts[i].Email == email {
529 return &c.Accounts[i]
530 }
531 }
532 return nil
533}
534
535// HasAccounts returns true if there are any configured accounts.
536func (c *Config) HasAccounts() bool {
537 return len(c.Accounts) > 0
538}
539
540// GetFirstAccount returns the first account or nil if none exist.
541func (c *Config) GetFirstAccount() *Account {
542 if len(c.Accounts) > 0 {
543 return &c.Accounts[0]
544 }
545 return nil
546}
547
548// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
549func EnsurePGPDir() error {
550 dir, err := configDir()
551 if err != nil {
552 return err
553 }
554 pgpDir := filepath.Join(dir, "pgp")
555 return os.MkdirAll(pgpDir, 0700)
556}