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