1package config
2
3import (
4 "crypto/tls"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13
14 "github.com/google/uuid"
15 "github.com/zalando/go-keyring"
16)
17
18const keyringServiceName = "matcha-email-client"
19
20const (
21 ProviderGmail = "gmail"
22 ProviderICloud = "icloud"
23 ProviderCustom = "custom"
24)
25
26// Date format presets use human-readable tokens. Supported tokens:
27//
28// YYYY (4-digit year), YY (2-digit year)
29// MM (month, or minutes when following an hour token + colon)
30// mm (minutes, explicit)
31// DD (day)
32// HH (24-hour), hh (12-hour, zero-padded)
33// SS, ss (seconds)
34// AM, PM (meridiem marker)
35const (
36 DateFormatISO = "YYYY-MM-DD HH:MM"
37 DateFormatUS = "MM/DD/YYYY hh:MM AM"
38 DateFormatEU = "DD/MM/YYYY HH:MM"
39)
40
41var cacheFiles = []string{
42 "email_cache.json",
43 "contacts.json",
44 "drafts.json",
45 "folder_cache.json",
46}
47
48var cacheDirectories = []string{
49 "folder_emails",
50 "email_bodies",
51}
52
53type SessionCache struct {
54 once sync.Once
55 cache tls.ClientSessionCache
56}
57
58// Account stores the configuration for a single email account.
59type Account struct {
60 ID string `json:"id"`
61 Name string `json:"name"`
62 Email string `json:"email"`
63 Password string `json:"-"` // "-" prevents the password from being saved to config.json
64 ServiceProvider string `json:"service_provider"` // "gmail", "outlook", "icloud", or "custom"
65 // FetchEmail is the single email address for which messages should be fetched.
66 // If empty, it will default to `Email` when accounts are added.
67 FetchEmail string `json:"fetch_email,omitempty"`
68 // SendAsEmail controls the visible From header on outgoing mail.
69 // If empty, it defaults to FetchEmail, then Email.
70 SendAsEmail string `json:"send_as_email,omitempty"`
71 // CatchAll skips per-address filtering so all inbox messages are shown,
72 // regardless of which address they were delivered to.
73 CatchAll bool `json:"catch_all,omitempty"`
74
75 SC *SessionCache `json:"-"` // "-" prevents the SessionCache from being saved to config.json
76
77 // Custom server settings (used when ServiceProvider is "custom")
78 IMAPServer string `json:"imap_server,omitempty"`
79 IMAPPort int `json:"imap_port,omitempty"`
80 SMTPServer string `json:"smtp_server,omitempty"`
81 SMTPPort int `json:"smtp_port,omitempty"`
82 Insecure bool `json:"insecure,omitempty"`
83
84 // S/MIME settings
85 SMIMECert string `json:"smime_cert,omitempty"` // Path to the public certificate PEM
86 SMIMEKey string `json:"smime_key,omitempty"` // Path to the private key PEM
87 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default
88
89 // PGP settings
90 PGPPublicKey string `json:"pgp_public_key,omitempty"` // Path to public key (.asc or .gpg)
91 PGPPrivateKey string `json:"pgp_private_key,omitempty"` // Path to private key (.asc or .gpg)
92 PGPKeySource string `json:"pgp_key_source,omitempty"` // "file" (default) or "yubikey" for hardware key
93 PGPPIN string `json:"-"` // YubiKey PIN (stored in keyring, not JSON)
94 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails
95
96 // OAuth2 settings
97 AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
98
99 // Multi-protocol settings
100 Protocol string `json:"protocol,omitempty"` // "imap" (default), "jmap", or "pop3"
101 JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
102 POP3Server string `json:"pop3_server,omitempty"` // POP3 server hostname (for protocol=pop3)
103 POP3Port int `json:"pop3_port,omitempty"` // POP3 server port (for protocol=pop3)
104 MaildirPath string `json:"maildir_path,omitempty"` // Local Maildir root (for protocol=maildir)
105
106 // Per-account signature (overrides global signature)
107 Signature string `json:"signature,omitempty"`
108}
109
110// MailingList represents a named group of email addresses.
111type MailingList struct {
112 Name string `json:"name"`
113 Addresses []string `json:"addresses"`
114}
115
116// Config stores the user's email configuration with multiple accounts.
117type Config struct {
118 Accounts []Account `json:"accounts"`
119 DisableImages bool `json:"disable_images,omitempty"`
120 HideTips bool `json:"hide_tips,omitempty"`
121 DisableNotifications bool `json:"disable_notifications,omitempty"`
122 EnableSplitPane bool `json:"enable_split_pane,omitempty"`
123 EnableThreaded bool `json:"enable_threaded,omitempty"`
124 EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"`
125 DisableSpellcheck bool `json:"disable_spellcheck,omitempty"`
126 DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"`
127 Theme string `json:"theme,omitempty"`
128 MailingLists []MailingList `json:"mailing_lists,omitempty"`
129 DateFormat string `json:"date_format,omitempty"`
130 Language string `json:"language,omitempty"` // Language code (e.g., "en", "es", "de")
131 BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
132 // PluginSettings stores user-configurable values for installed plugins,
133 // keyed by plugin name then setting key. Values are JSON-native types
134 // (bool, float64, string) matching the plugin's declared schema.
135 PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
136}
137
138// GetBodyCacheThreshold returns the email body cache threshold in bytes.
139// It defaults to 100MB if unset or zero.
140func (c *Config) GetBodyCacheThreshold() int {
141 if c.BodyCacheThresholdMB <= 0 {
142 return 100 * 1024 * 1024
143 }
144 return c.BodyCacheThresholdMB * 1024 * 1024
145}
146
147// GetDateFormat returns the Go time reference layout translated from the
148// user's configured human-readable format. Defaults to EU when unset.
149func (c *Config) GetDateFormat() string {
150 f := c.DateFormat
151 if f == "" {
152 f = DateFormatEU
153 }
154 return translateDateFormat(f)
155}
156
157// GetLanguage returns the configured language code, defaulting to "en".
158func (c *Config) GetLanguage() string {
159 if c.Language == "" {
160 return "en"
161 }
162 return c.Language
163}
164
165// translateDateFormat converts a human-readable format string (e.g.
166// "DD/MM/YYYY HH:MM") into a Go reference-time layout usable by
167// time.Format. MM is disambiguated by context: when it directly follows
168// an hour token plus ":", it maps to minutes; otherwise to month.
169func translateDateFormat(f string) string {
170 var b strings.Builder
171 i := 0
172 for i < len(f) {
173 rest := f[i:]
174 switch {
175 case strings.HasPrefix(rest, "YYYY"):
176 b.WriteString("2006")
177 i += 4
178 case strings.HasPrefix(rest, "YY"):
179 b.WriteString("06")
180 i += 2
181 case strings.HasPrefix(rest, "DD"):
182 b.WriteString("02")
183 i += 2
184 case strings.HasPrefix(rest, "HH"):
185 b.WriteString("15")
186 i += 2
187 case strings.HasPrefix(rest, "hh"):
188 b.WriteString("03")
189 i += 2
190 case strings.HasPrefix(rest, "mm"):
191 b.WriteString("04")
192 i += 2
193 case strings.HasPrefix(rest, "SS"), strings.HasPrefix(rest, "ss"):
194 b.WriteString("05")
195 i += 2
196 case strings.HasPrefix(rest, "MM"):
197 cur := b.String()
198 if strings.HasSuffix(cur, "15:") || strings.HasSuffix(cur, "03:") {
199 b.WriteString("04")
200 } else {
201 b.WriteString("01")
202 }
203 i += 2
204 case strings.HasPrefix(rest, "AM"), strings.HasPrefix(rest, "PM"):
205 b.WriteString("PM")
206 i += 2
207 default:
208 b.WriteByte(f[i])
209 i++
210 }
211 }
212 return b.String()
213}
214
215// GetIMAPServer returns the IMAP server address for the account.
216func (a *Account) GetIMAPServer() string {
217 switch a.ServiceProvider {
218 case ProviderGmail:
219 return "imap.gmail.com"
220 case "outlook":
221 return "outlook.office365.com"
222 case ProviderICloud:
223 return "imap.mail.me.com"
224 case ProviderCustom:
225 return a.IMAPServer
226 default:
227 return ""
228 }
229}
230
231// GetIMAPPort returns the IMAP port for the account.
232func (a *Account) GetIMAPPort() int {
233 switch a.ServiceProvider {
234 case ProviderGmail, "outlook", "icloud":
235 return 993
236 case ProviderCustom:
237 if a.IMAPPort != 0 {
238 return a.IMAPPort
239 }
240 return 993 // Default IMAP SSL port
241 default:
242 return 993
243 }
244}
245
246// GetSMTPServer returns the SMTP server address for the account.
247func (a *Account) GetSMTPServer() string {
248 switch a.ServiceProvider {
249 case ProviderGmail:
250 return "smtp.gmail.com"
251 case "outlook":
252 return "smtp.office365.com"
253 case ProviderICloud:
254 return "smtp.mail.me.com"
255 case ProviderCustom:
256 return a.SMTPServer
257 default:
258 return ""
259 }
260}
261
262func (a *Account) GetClientSessionCache() tls.ClientSessionCache {
263 a.SC.once.Do(func() {
264 a.SC.cache = tls.NewLRUClientSessionCache(64)
265 })
266
267 return a.SC.cache
268}
269
270// GetSMTPPort returns the SMTP port for the account.
271func (a *Account) GetSMTPPort() int {
272 switch a.ServiceProvider {
273 case ProviderGmail, "outlook", "icloud":
274 return 587
275 case ProviderCustom:
276 if a.SMTPPort != 0 {
277 return a.SMTPPort
278 }
279 return 587 // Default SMTP TLS port
280 default:
281 return 587
282 }
283}
284
285// GetFetchEmail returns the configured fetch identity, falling back to Email.
286func (a *Account) GetFetchEmail() string {
287 if a.FetchEmail != "" {
288 return a.FetchEmail
289 }
290 return a.Email
291}
292
293// GetSendAsEmail returns the visible sender address for outgoing mail.
294func (a *Account) GetSendAsEmail() string {
295 if a.SendAsEmail != "" {
296 return a.SendAsEmail
297 }
298 return a.GetFetchEmail()
299}
300
301// FormatFromHeader returns the display-ready From header value.
302func (a *Account) FormatFromHeader() string {
303 sendAs := a.GetSendAsEmail()
304 if strings.Contains(sendAs, "<") && strings.Contains(sendAs, ">") {
305 return sendAs
306 }
307 if a.Name != "" && sendAs != "" {
308 return fmt.Sprintf("%s <%s>", a.Name, sendAs)
309 }
310 return sendAs
311}
312
313// GetPOP3Server returns the POP3 server address for the account.
314func (a *Account) GetPOP3Server() string {
315 if a.POP3Server != "" {
316 return a.POP3Server
317 }
318 return ""
319}
320
321// GetPOP3Port returns the POP3 port for the account.
322func (a *Account) GetPOP3Port() int {
323 if a.POP3Port != 0 {
324 return a.POP3Port
325 }
326 return 995 // Default POP3 SSL port
327}
328
329// GetConfigDir returns the path to the configuration directory (exported).
330func GetConfigDir() (string, error) {
331 return configDir()
332}
333
334// configDir returns the path to the configuration directory (internal).
335func configDir() (string, error) {
336 home, err := os.UserHomeDir()
337 if err != nil {
338 return "", err
339 }
340 return filepath.Join(home, ".config", "matcha"), nil
341}
342
343// GetCacheDir returns the path to the cache directory (exported).
344func GetCacheDir() (string, error) {
345 return cacheDir()
346}
347
348// cacheDir returns the path to the cache directory (internal).
349func cacheDir() (string, error) {
350 home, err := os.UserHomeDir()
351 if err != nil {
352 return "", err
353 }
354 return filepath.Join(home, ".cache", "matcha"), nil
355}
356
357func migrate(src, dst string) error {
358 if _, err := os.Stat(src); err != nil {
359 if os.IsNotExist(err) {
360 return nil
361 }
362 return err
363 }
364 if _, err := os.Stat(dst); err == nil {
365 return nil
366 } else if !os.IsNotExist(err) {
367 return err
368 }
369 return os.Rename(src, dst)
370}
371
372// MigrateCacheFiles moves cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed.
373// This is a one-time migration for existing installations.
374func MigrateCacheFiles() error {
375 src, err := configDir()
376 if err != nil {
377 return err
378 }
379 dst, err := cacheDir()
380 if err != nil {
381 return err
382 }
383 if err := os.MkdirAll(dst, 0700); err != nil {
384 return err
385 }
386
387 for _, f := range cacheFiles {
388 if err := migrate(filepath.Join(src, f), filepath.Join(dst, f)); err != nil {
389 return err
390 }
391 }
392
393 for _, f := range cacheDirectories {
394 if err := migrate(filepath.Join(src, f), filepath.Join(dst, f)); err != nil {
395 return err
396 }
397 }
398
399 return nil
400}
401
402// configFile returns the full path to the configuration file.
403func configFile() (string, error) {
404 dir, err := configDir()
405 if err != nil {
406 return "", err
407 }
408 return filepath.Join(dir, "config.json"), nil
409}
410
411// secureDiskAccount includes the Password field in JSON when secure mode is active.
412type secureDiskAccount struct {
413 ID string `json:"id"`
414 Name string `json:"name"`
415 Email string `json:"email"`
416 Password string `json:"password,omitempty"`
417 ServiceProvider string `json:"service_provider"`
418 FetchEmail string `json:"fetch_email,omitempty"`
419 SendAsEmail string `json:"send_as_email,omitempty"`
420 IMAPServer string `json:"imap_server,omitempty"`
421 IMAPPort int `json:"imap_port,omitempty"`
422 SMTPServer string `json:"smtp_server,omitempty"`
423 SMTPPort int `json:"smtp_port,omitempty"`
424 Insecure bool `json:"insecure,omitempty"`
425 SMIMECert string `json:"smime_cert,omitempty"`
426 SMIMEKey string `json:"smime_key,omitempty"`
427 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
428 PGPPublicKey string `json:"pgp_public_key,omitempty"`
429 PGPPrivateKey string `json:"pgp_private_key,omitempty"`
430 PGPKeySource string `json:"pgp_key_source,omitempty"`
431 PGPPIN string `json:"pgp_pin,omitempty"`
432 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
433 AuthMethod string `json:"auth_method,omitempty"`
434 Protocol string `json:"protocol,omitempty"`
435 JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
436 POP3Server string `json:"pop3_server,omitempty"`
437 POP3Port int `json:"pop3_port,omitempty"`
438 MaildirPath string `json:"maildir_path,omitempty"`
439 CatchAll bool `json:"catch_all,omitempty"`
440}
441
442type secureDiskConfig struct {
443 Accounts []secureDiskAccount `json:"accounts"`
444 DisableImages bool `json:"disable_images,omitempty"`
445 HideTips bool `json:"hide_tips,omitempty"`
446 DisableNotifications bool `json:"disable_notifications,omitempty"`
447 EnableSplitPane bool `json:"enable_split_pane,omitempty"`
448 EnableThreaded bool `json:"enable_threaded,omitempty"`
449 EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"`
450 DisableSpellcheck bool `json:"disable_spellcheck,omitempty"`
451 DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"`
452 Theme string `json:"theme,omitempty"`
453 MailingLists []MailingList `json:"mailing_lists,omitempty"`
454 DateFormat string `json:"date_format,omitempty"`
455 Language string `json:"language,omitempty"`
456 PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
457}
458
459// SaveConfig saves the given configuration to the config file and passwords to the keyring.
460func SaveConfig(config *Config) error {
461 secureMode := GetSessionKey() != nil
462
463 if !secureMode {
464 // Save passwords and PGP PINs to the OS keyring before writing the JSON file.
465 // A silent keyring failure here would lose the credential on restart without
466 // any hint to the user. Log the error as a warning so the misconfiguration
467 // (no keyring backend, locked keyring, etc.) is at least visible. See #616.
468 for _, acc := range config.Accounts {
469 if acc.Password != "" {
470 if err := keyring.Set(keyringServiceName, acc.Email, acc.Password); err != nil {
471 log.Printf("matcha: failed to store password for %s in keyring: %v", acc.Email, err)
472 }
473 }
474 if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
475 if err := keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN); err != nil {
476 log.Printf("matcha: failed to store PGP PIN for %s in keyring: %v", acc.Email, err)
477 }
478 }
479 }
480 }
481
482 path, err := configFile()
483 if err != nil {
484 return err
485 }
486 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
487 return err
488 }
489
490 var data []byte
491 if secureMode {
492 // In secure mode, include passwords in the JSON (they'll be encrypted on disk)
493 sdc := secureDiskConfig{
494 DisableImages: config.DisableImages,
495 HideTips: config.HideTips,
496 DisableNotifications: config.DisableNotifications,
497 EnableSplitPane: config.EnableSplitPane,
498 EnableThreaded: config.EnableThreaded,
499 EnableDetailedDates: config.EnableDetailedDates,
500 DisableSpellcheck: config.DisableSpellcheck,
501 DisableSpellSuggestions: config.DisableSpellSuggestions,
502 Theme: config.Theme,
503 MailingLists: config.MailingLists,
504 DateFormat: config.DateFormat,
505 PluginSettings: config.PluginSettings,
506 }
507 for _, acc := range config.Accounts {
508 sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
509 ID: acc.ID,
510 Name: acc.Name,
511 Email: acc.Email,
512 Password: acc.Password,
513 ServiceProvider: acc.ServiceProvider,
514 FetchEmail: acc.FetchEmail,
515 SendAsEmail: acc.SendAsEmail,
516 IMAPServer: acc.IMAPServer,
517 IMAPPort: acc.IMAPPort,
518 SMTPServer: acc.SMTPServer,
519 SMTPPort: acc.SMTPPort,
520 Insecure: acc.Insecure,
521 SMIMECert: acc.SMIMECert,
522 SMIMEKey: acc.SMIMEKey,
523 SMIMESignByDefault: acc.SMIMESignByDefault,
524 PGPPublicKey: acc.PGPPublicKey,
525 PGPPrivateKey: acc.PGPPrivateKey,
526 PGPKeySource: acc.PGPKeySource,
527 PGPPIN: acc.PGPPIN,
528 PGPSignByDefault: acc.PGPSignByDefault,
529 AuthMethod: acc.AuthMethod,
530 Protocol: acc.Protocol,
531 JMAPEndpoint: acc.JMAPEndpoint,
532 POP3Server: acc.POP3Server,
533 POP3Port: acc.POP3Port,
534 MaildirPath: acc.MaildirPath,
535 CatchAll: acc.CatchAll,
536 })
537 }
538 data, err = json.MarshalIndent(sdc, "", " ")
539 } else {
540 data, err = json.MarshalIndent(config, "", " ")
541 }
542 if err != nil {
543 return err
544 }
545 return SecureWriteFile(path, data, 0600)
546}
547
548// LoadConfig loads the configuration from the config file and passwords from the keyring.
549// It automatically migrates plain-text passwords to the OS keyring if they exist.
550func LoadConfig() (*Config, error) {
551 path, err := configFile()
552 if err != nil {
553 return nil, err
554 }
555
556 if dir, err := configDir(); err == nil {
557 if err := LoadKeybindsFromDir(dir); err != nil {
558 log.Printf("matcha: keybinds load error (using defaults): %v", err)
559 }
560 }
561 data, err := SecureReadFile(path)
562 if err != nil {
563 return nil, err
564 }
565
566 secureMode := GetSessionKey() != nil
567
568 var config Config
569 var needsMigration bool
570
571 type rawAccount struct {
572 ID string `json:"id"`
573 Name string `json:"name"`
574 Email string `json:"email"`
575 Password string `json:"password,omitempty"`
576 ServiceProvider string `json:"service_provider"`
577 FetchEmail string `json:"fetch_email,omitempty"`
578 SendAsEmail string `json:"send_as_email,omitempty"`
579 IMAPServer string `json:"imap_server,omitempty"`
580 IMAPPort int `json:"imap_port,omitempty"`
581 SMTPServer string `json:"smtp_server,omitempty"`
582 SMTPPort int `json:"smtp_port,omitempty"`
583 Insecure bool `json:"insecure,omitempty"`
584 SMIMECert string `json:"smime_cert,omitempty"`
585 SMIMEKey string `json:"smime_key,omitempty"`
586 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
587 PGPPublicKey string `json:"pgp_public_key,omitempty"`
588 PGPPrivateKey string `json:"pgp_private_key,omitempty"`
589 PGPKeySource string `json:"pgp_key_source,omitempty"`
590 PGPPIN string `json:"pgp_pin,omitempty"`
591 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
592 AuthMethod string `json:"auth_method,omitempty"`
593 Protocol string `json:"protocol,omitempty"`
594 JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
595 POP3Server string `json:"pop3_server,omitempty"`
596 POP3Port int `json:"pop3_port,omitempty"`
597 MaildirPath string `json:"maildir_path,omitempty"`
598 CatchAll bool `json:"catch_all,omitempty"`
599 }
600 type diskConfig struct {
601 Accounts []rawAccount `json:"accounts"`
602 DisableImages bool `json:"disable_images,omitempty"`
603 HideTips bool `json:"hide_tips,omitempty"`
604 DisableNotifications bool `json:"disable_notifications,omitempty"`
605 EnableSplitPane bool `json:"enable_split_pane,omitempty"`
606 EnableThreaded bool `json:"enable_threaded,omitempty"`
607 EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"`
608 DisableSpellcheck bool `json:"disable_spellcheck,omitempty"`
609 DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"`
610 Theme string `json:"theme,omitempty"`
611 MailingLists []MailingList `json:"mailing_lists,omitempty"`
612 DateFormat string `json:"date_format,omitempty"`
613 Language string `json:"language,omitempty"`
614 BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
615 PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
616 }
617
618 var raw diskConfig
619 if err := json.Unmarshal(data, &raw); err != nil {
620 var legacyConfig legacyConfigFormat
621 if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
622 config = Config{
623 Accounts: []Account{
624 {
625 ID: uuid.New().String(),
626 Name: legacyConfig.Name,
627 Email: legacyConfig.Email,
628 Password: legacyConfig.Password,
629 ServiceProvider: legacyConfig.ServiceProvider,
630 FetchEmail: legacyConfig.Email,
631 SC: &SessionCache{},
632 },
633 },
634 }
635 // SaveConfig automatically pushes the password to the keyring and strips it from JSON
636 if saveErr := SaveConfig(&config); saveErr != nil {
637 return nil, saveErr
638 }
639 return &config, nil
640 }
641 return nil, err
642 }
643
644 config.DisableImages = raw.DisableImages
645 config.HideTips = raw.HideTips
646 config.DisableNotifications = raw.DisableNotifications
647 config.EnableSplitPane = raw.EnableSplitPane
648 config.EnableThreaded = raw.EnableThreaded
649 config.EnableDetailedDates = raw.EnableDetailedDates
650 config.DisableSpellcheck = raw.DisableSpellcheck
651 config.DisableSpellSuggestions = raw.DisableSpellSuggestions
652 config.Theme = raw.Theme
653 config.MailingLists = raw.MailingLists
654 config.DateFormat = raw.DateFormat
655 config.Language = raw.Language
656 config.BodyCacheThresholdMB = raw.BodyCacheThresholdMB
657 config.PluginSettings = raw.PluginSettings
658
659 for _, rawAcc := range raw.Accounts {
660 acc := Account{
661 ID: rawAcc.ID,
662 Name: rawAcc.Name,
663 Email: rawAcc.Email,
664 ServiceProvider: rawAcc.ServiceProvider,
665 FetchEmail: rawAcc.FetchEmail,
666 SendAsEmail: rawAcc.SendAsEmail,
667 IMAPServer: rawAcc.IMAPServer,
668 IMAPPort: rawAcc.IMAPPort,
669 SMTPServer: rawAcc.SMTPServer,
670 SMTPPort: rawAcc.SMTPPort,
671 Insecure: rawAcc.Insecure,
672 SMIMECert: rawAcc.SMIMECert,
673 SMIMEKey: rawAcc.SMIMEKey,
674 SMIMESignByDefault: rawAcc.SMIMESignByDefault,
675 PGPPublicKey: rawAcc.PGPPublicKey,
676 PGPPrivateKey: rawAcc.PGPPrivateKey,
677 PGPKeySource: rawAcc.PGPKeySource,
678 PGPSignByDefault: rawAcc.PGPSignByDefault,
679 AuthMethod: rawAcc.AuthMethod,
680 Protocol: rawAcc.Protocol,
681 JMAPEndpoint: rawAcc.JMAPEndpoint,
682 POP3Server: rawAcc.POP3Server,
683 POP3Port: rawAcc.POP3Port,
684 MaildirPath: rawAcc.MaildirPath,
685 CatchAll: rawAcc.CatchAll,
686 SC: &SessionCache{},
687 }
688
689 // Validate PGPKeySource
690 if acc.PGPKeySource != "" && acc.PGPKeySource != "file" && acc.PGPKeySource != "yubikey" {
691 return nil, fmt.Errorf("account %q: invalid pgp_key_source %q (must be \"file\" or \"yubikey\")", acc.Name, acc.PGPKeySource)
692 }
693
694 switch {
695 case secureMode:
696 // In secure mode, passwords and PINs are stored in the encrypted config JSON
697 acc.Password = rawAcc.Password
698 acc.PGPPIN = rawAcc.PGPPIN
699 case rawAcc.Password != "":
700 // Found a plain-text password! Move it to the OS Keyring.
701 if err := keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password); err != nil {
702 log.Printf("matcha: failed to migrate password for %s into keyring: %v", rawAcc.Email, err)
703 }
704 acc.Password = rawAcc.Password
705 needsMigration = true
706 default:
707 // No plaintext password in JSON, fetch from Keyring as normal.
708 if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
709 acc.Password = pwd
710 }
711 }
712
713 if !secureMode {
714 // Load YubiKey PIN from keyring if using YubiKey
715 if acc.PGPKeySource == "yubikey" {
716 if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
717 acc.PGPPIN = pin
718 }
719 }
720 }
721
722 config.Accounts = append(config.Accounts, acc)
723 }
724
725 if needsMigration {
726 if saveErr := SaveConfig(&config); saveErr != nil {
727 return nil, saveErr
728 }
729 }
730
731 return &config, nil
732}
733
734// legacyConfigFormat represents the old single-account configuration format.
735type legacyConfigFormat struct {
736 ServiceProvider string `json:"service_provider"`
737 Email string `json:"email"`
738 Password string `json:"password"`
739 Name string `json:"name"`
740}
741
742// AddAccount adds a new account to the configuration.
743func (c *Config) AddAccount(account Account) {
744 if account.ID == "" {
745 account.ID = uuid.New().String()
746 }
747 // Ensure FetchEmail defaults to the login Email if not explicitly set.
748 if account.FetchEmail == "" && account.Email != "" {
749 account.FetchEmail = account.Email
750 }
751 c.Accounts = append(c.Accounts, account)
752}
753
754// RemoveAccount removes an account by its ID and deletes its password from the keyring.
755func (c *Config) RemoveAccount(id string) bool {
756 for i, acc := range c.Accounts {
757 if acc.ID == id {
758 // Delete password from OS Keyring when account is removed. A
759 // missing entry is expected and not worth logging (keyring.Get is
760 // what we rely on elsewhere to detect that), but any other error
761 // means we failed to clean up a still-reachable secret.
762 if err := keyring.Delete(keyringServiceName, acc.Email); err != nil && !errors.Is(err, keyring.ErrNotFound) {
763 log.Printf("matcha: failed to delete password for %s from keyring: %v", acc.Email, err)
764 }
765 // Delete PGP PIN from OS Keyring if present
766 if err := keyring.Delete(keyringServiceName, acc.Email+":pgp-pin"); err != nil && !errors.Is(err, keyring.ErrNotFound) {
767 log.Printf("matcha: failed to delete PGP PIN for %s from keyring: %v", acc.Email, err)
768 }
769
770 c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
771 return true
772 }
773 }
774 return false
775}
776
777// GetAccountByID returns an account by its ID.
778func (c *Config) GetAccountByID(id string) *Account {
779 for i := range c.Accounts {
780 if c.Accounts[i].ID == id {
781 return &c.Accounts[i]
782 }
783 }
784 return nil
785}
786
787// GetAccountByEmail returns an account by its email address.
788func (c *Config) GetAccountByEmail(email string) *Account {
789 for i := range c.Accounts {
790 if c.Accounts[i].Email == email {
791 return &c.Accounts[i]
792 }
793 }
794 return nil
795}
796
797// HasAccounts returns true if there are any configured accounts.
798func (c *Config) HasAccounts() bool {
799 return len(c.Accounts) > 0
800}
801
802// GetAccountIDs returns the configured account IDs.
803func (c *Config) GetAccountIDs() []string {
804 ids := make([]string, 0, len(c.Accounts))
805 for _, acc := range c.Accounts {
806 if acc.ID != "" {
807 ids = append(ids, acc.ID)
808 }
809 }
810 return ids
811}
812
813// GetFirstAccount returns the first account or nil if none exist.
814func (c *Config) GetFirstAccount() *Account {
815 if len(c.Accounts) > 0 {
816 return &c.Accounts[0]
817 }
818 return nil
819}
820
821// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
822func EnsurePGPDir() error {
823 dir, err := configDir()
824 if err != nil {
825 return err
826 }
827 pgpDir := filepath.Join(dir, "pgp")
828 return os.MkdirAll(pgpDir, 0700)
829}