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