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