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