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 CatchAll bool `json:"catch_all,omitempty"`
439}
440
441type secureDiskConfig struct {
442 Accounts []secureDiskAccount `json:"accounts"`
443 DisableImages bool `json:"disable_images,omitempty"`
444 HideTips bool `json:"hide_tips,omitempty"`
445 DisableNotifications bool `json:"disable_notifications,omitempty"`
446 EnableSplitPane bool `json:"enable_split_pane,omitempty"`
447 EnableThreaded bool `json:"enable_threaded,omitempty"`
448 EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"`
449 DisableSpellcheck bool `json:"disable_spellcheck,omitempty"`
450 DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"`
451 Theme string `json:"theme,omitempty"`
452 MailingLists []MailingList `json:"mailing_lists,omitempty"`
453 DateFormat string `json:"date_format,omitempty"`
454 Language string `json:"language,omitempty"`
455 PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
456}
457
458// SaveConfig saves the given configuration to the config file and passwords to the keyring.
459func SaveConfig(config *Config) error {
460 secureMode := GetSessionKey() != nil
461
462 if !secureMode {
463 // Save passwords and PGP PINs to the OS keyring before writing the JSON file.
464 // A silent keyring failure here would lose the credential on restart without
465 // any hint to the user. Log the error as a warning so the misconfiguration
466 // (no keyring backend, locked keyring, etc.) is at least visible. See #616.
467 for _, acc := range config.Accounts {
468 if acc.Password != "" {
469 if err := keyring.Set(keyringServiceName, acc.Email, acc.Password); err != nil {
470 log.Printf("matcha: failed to store password for %s in keyring: %v", acc.Email, err)
471 }
472 }
473 if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
474 if err := keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN); err != nil {
475 log.Printf("matcha: failed to store PGP PIN for %s in keyring: %v", acc.Email, err)
476 }
477 }
478 }
479 }
480
481 path, err := configFile()
482 if err != nil {
483 return err
484 }
485 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
486 return err
487 }
488
489 var data []byte
490 if secureMode {
491 // In secure mode, include passwords in the JSON (they'll be encrypted on disk)
492 sdc := secureDiskConfig{
493 DisableImages: config.DisableImages,
494 HideTips: config.HideTips,
495 DisableNotifications: config.DisableNotifications,
496 EnableSplitPane: config.EnableSplitPane,
497 EnableThreaded: config.EnableThreaded,
498 EnableDetailedDates: config.EnableDetailedDates,
499 DisableSpellcheck: config.DisableSpellcheck,
500 DisableSpellSuggestions: config.DisableSpellSuggestions,
501 Theme: config.Theme,
502 MailingLists: config.MailingLists,
503 DateFormat: config.DateFormat,
504 PluginSettings: config.PluginSettings,
505 }
506 for _, acc := range config.Accounts {
507 sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
508 ID: acc.ID,
509 Name: acc.Name,
510 Email: acc.Email,
511 Password: acc.Password,
512 ServiceProvider: acc.ServiceProvider,
513 FetchEmail: acc.FetchEmail,
514 SendAsEmail: acc.SendAsEmail,
515 IMAPServer: acc.IMAPServer,
516 IMAPPort: acc.IMAPPort,
517 SMTPServer: acc.SMTPServer,
518 SMTPPort: acc.SMTPPort,
519 Insecure: acc.Insecure,
520 SMIMECert: acc.SMIMECert,
521 SMIMEKey: acc.SMIMEKey,
522 SMIMESignByDefault: acc.SMIMESignByDefault,
523 PGPPublicKey: acc.PGPPublicKey,
524 PGPPrivateKey: acc.PGPPrivateKey,
525 PGPKeySource: acc.PGPKeySource,
526 PGPPIN: acc.PGPPIN,
527 PGPSignByDefault: acc.PGPSignByDefault,
528 AuthMethod: acc.AuthMethod,
529 Protocol: acc.Protocol,
530 JMAPEndpoint: acc.JMAPEndpoint,
531 POP3Server: acc.POP3Server,
532 POP3Port: acc.POP3Port,
533 CatchAll: acc.CatchAll,
534 })
535 }
536 data, err = json.MarshalIndent(sdc, "", " ")
537 } else {
538 data, err = json.MarshalIndent(config, "", " ")
539 }
540 if err != nil {
541 return err
542 }
543 return SecureWriteFile(path, data, 0600)
544}
545
546// LoadConfig loads the configuration from the config file and passwords from the keyring.
547// It automatically migrates plain-text passwords to the OS keyring if they exist.
548func LoadConfig() (*Config, error) {
549 path, err := configFile()
550 if err != nil {
551 return nil, err
552 }
553
554 if dir, err := configDir(); err == nil {
555 if err := LoadKeybindsFromDir(dir); err != nil {
556 log.Printf("matcha: keybinds load error (using defaults): %v", err)
557 }
558 }
559 data, err := SecureReadFile(path)
560 if err != nil {
561 return nil, err
562 }
563
564 secureMode := GetSessionKey() != nil
565
566 var config Config
567 var needsMigration bool
568
569 type rawAccount struct {
570 ID string `json:"id"`
571 Name string `json:"name"`
572 Email string `json:"email"`
573 Password string `json:"password,omitempty"`
574 ServiceProvider string `json:"service_provider"`
575 FetchEmail string `json:"fetch_email,omitempty"`
576 SendAsEmail string `json:"send_as_email,omitempty"`
577 IMAPServer string `json:"imap_server,omitempty"`
578 IMAPPort int `json:"imap_port,omitempty"`
579 SMTPServer string `json:"smtp_server,omitempty"`
580 SMTPPort int `json:"smtp_port,omitempty"`
581 Insecure bool `json:"insecure,omitempty"`
582 SMIMECert string `json:"smime_cert,omitempty"`
583 SMIMEKey string `json:"smime_key,omitempty"`
584 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
585 PGPPublicKey string `json:"pgp_public_key,omitempty"`
586 PGPPrivateKey string `json:"pgp_private_key,omitempty"`
587 PGPKeySource string `json:"pgp_key_source,omitempty"`
588 PGPPIN string `json:"pgp_pin,omitempty"`
589 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
590 AuthMethod string `json:"auth_method,omitempty"`
591 Protocol string `json:"protocol,omitempty"`
592 JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
593 POP3Server string `json:"pop3_server,omitempty"`
594 POP3Port int `json:"pop3_port,omitempty"`
595 CatchAll bool `json:"catch_all,omitempty"`
596 }
597 type diskConfig struct {
598 Accounts []rawAccount `json:"accounts"`
599 DisableImages bool `json:"disable_images,omitempty"`
600 HideTips bool `json:"hide_tips,omitempty"`
601 DisableNotifications bool `json:"disable_notifications,omitempty"`
602 EnableSplitPane bool `json:"enable_split_pane,omitempty"`
603 EnableThreaded bool `json:"enable_threaded,omitempty"`
604 EnableDetailedDates bool `json:"enable_detailed_dates,omitempty"`
605 DisableSpellcheck bool `json:"disable_spellcheck,omitempty"`
606 DisableSpellSuggestions bool `json:"disable_spell_suggestions,omitempty"`
607 Theme string `json:"theme,omitempty"`
608 MailingLists []MailingList `json:"mailing_lists,omitempty"`
609 DateFormat string `json:"date_format,omitempty"`
610 Language string `json:"language,omitempty"`
611 BodyCacheThresholdMB int `json:"body_cache_threshold_mb,omitempty"`
612 PluginSettings map[string]map[string]interface{} `json:"plugin_settings,omitempty"`
613 }
614
615 var raw diskConfig
616 if err := json.Unmarshal(data, &raw); err != nil {
617 var legacyConfig legacyConfigFormat
618 if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
619 config = Config{
620 Accounts: []Account{
621 {
622 ID: uuid.New().String(),
623 Name: legacyConfig.Name,
624 Email: legacyConfig.Email,
625 Password: legacyConfig.Password,
626 ServiceProvider: legacyConfig.ServiceProvider,
627 FetchEmail: legacyConfig.Email,
628 SC: &SessionCache{},
629 },
630 },
631 }
632 // SaveConfig automatically pushes the password to the keyring and strips it from JSON
633 if saveErr := SaveConfig(&config); saveErr != nil {
634 return nil, saveErr
635 }
636 return &config, nil
637 }
638 return nil, err
639 }
640
641 config.DisableImages = raw.DisableImages
642 config.HideTips = raw.HideTips
643 config.DisableNotifications = raw.DisableNotifications
644 config.EnableSplitPane = raw.EnableSplitPane
645 config.EnableThreaded = raw.EnableThreaded
646 config.EnableDetailedDates = raw.EnableDetailedDates
647 config.DisableSpellcheck = raw.DisableSpellcheck
648 config.DisableSpellSuggestions = raw.DisableSpellSuggestions
649 config.Theme = raw.Theme
650 config.MailingLists = raw.MailingLists
651 config.DateFormat = raw.DateFormat
652 config.Language = raw.Language
653 config.BodyCacheThresholdMB = raw.BodyCacheThresholdMB
654 config.PluginSettings = raw.PluginSettings
655
656 for _, rawAcc := range raw.Accounts {
657 acc := Account{
658 ID: rawAcc.ID,
659 Name: rawAcc.Name,
660 Email: rawAcc.Email,
661 ServiceProvider: rawAcc.ServiceProvider,
662 FetchEmail: rawAcc.FetchEmail,
663 SendAsEmail: rawAcc.SendAsEmail,
664 IMAPServer: rawAcc.IMAPServer,
665 IMAPPort: rawAcc.IMAPPort,
666 SMTPServer: rawAcc.SMTPServer,
667 SMTPPort: rawAcc.SMTPPort,
668 Insecure: rawAcc.Insecure,
669 SMIMECert: rawAcc.SMIMECert,
670 SMIMEKey: rawAcc.SMIMEKey,
671 SMIMESignByDefault: rawAcc.SMIMESignByDefault,
672 PGPPublicKey: rawAcc.PGPPublicKey,
673 PGPPrivateKey: rawAcc.PGPPrivateKey,
674 PGPKeySource: rawAcc.PGPKeySource,
675 PGPSignByDefault: rawAcc.PGPSignByDefault,
676 AuthMethod: rawAcc.AuthMethod,
677 Protocol: rawAcc.Protocol,
678 JMAPEndpoint: rawAcc.JMAPEndpoint,
679 POP3Server: rawAcc.POP3Server,
680 POP3Port: rawAcc.POP3Port,
681 CatchAll: rawAcc.CatchAll,
682 SC: &SessionCache{},
683 }
684
685 // Validate PGPKeySource
686 if acc.PGPKeySource != "" && acc.PGPKeySource != "file" && acc.PGPKeySource != "yubikey" {
687 return nil, fmt.Errorf("account %q: invalid pgp_key_source %q (must be \"file\" or \"yubikey\")", acc.Name, acc.PGPKeySource)
688 }
689
690 switch {
691 case secureMode:
692 // In secure mode, passwords and PINs are stored in the encrypted config JSON
693 acc.Password = rawAcc.Password
694 acc.PGPPIN = rawAcc.PGPPIN
695 case rawAcc.Password != "":
696 // Found a plain-text password! Move it to the OS Keyring.
697 if err := keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password); err != nil {
698 log.Printf("matcha: failed to migrate password for %s into keyring: %v", rawAcc.Email, err)
699 }
700 acc.Password = rawAcc.Password
701 needsMigration = true
702 default:
703 // No plaintext password in JSON, fetch from Keyring as normal.
704 if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
705 acc.Password = pwd
706 }
707 }
708
709 if !secureMode {
710 // Load YubiKey PIN from keyring if using YubiKey
711 if acc.PGPKeySource == "yubikey" {
712 if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
713 acc.PGPPIN = pin
714 }
715 }
716 }
717
718 config.Accounts = append(config.Accounts, acc)
719 }
720
721 if needsMigration {
722 if saveErr := SaveConfig(&config); saveErr != nil {
723 return nil, saveErr
724 }
725 }
726
727 return &config, nil
728}
729
730// legacyConfigFormat represents the old single-account configuration format.
731type legacyConfigFormat struct {
732 ServiceProvider string `json:"service_provider"`
733 Email string `json:"email"`
734 Password string `json:"password"`
735 Name string `json:"name"`
736}
737
738// AddAccount adds a new account to the configuration.
739func (c *Config) AddAccount(account Account) {
740 if account.ID == "" {
741 account.ID = uuid.New().String()
742 }
743 // Ensure FetchEmail defaults to the login Email if not explicitly set.
744 if account.FetchEmail == "" && account.Email != "" {
745 account.FetchEmail = account.Email
746 }
747 c.Accounts = append(c.Accounts, account)
748}
749
750// RemoveAccount removes an account by its ID and deletes its password from the keyring.
751func (c *Config) RemoveAccount(id string) bool {
752 for i, acc := range c.Accounts {
753 if acc.ID == id {
754 // Delete password from OS Keyring when account is removed. A
755 // missing entry is expected and not worth logging (keyring.Get is
756 // what we rely on elsewhere to detect that), but any other error
757 // means we failed to clean up a still-reachable secret.
758 if err := keyring.Delete(keyringServiceName, acc.Email); err != nil && !errors.Is(err, keyring.ErrNotFound) {
759 log.Printf("matcha: failed to delete password for %s from keyring: %v", acc.Email, err)
760 }
761 // Delete PGP PIN from OS Keyring if present
762 if err := keyring.Delete(keyringServiceName, acc.Email+":pgp-pin"); err != nil && !errors.Is(err, keyring.ErrNotFound) {
763 log.Printf("matcha: failed to delete PGP PIN for %s from keyring: %v", acc.Email, err)
764 }
765
766 c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
767 return true
768 }
769 }
770 return false
771}
772
773// GetAccountByID returns an account by its ID.
774func (c *Config) GetAccountByID(id string) *Account {
775 for i := range c.Accounts {
776 if c.Accounts[i].ID == id {
777 return &c.Accounts[i]
778 }
779 }
780 return nil
781}
782
783// GetAccountByEmail returns an account by its email address.
784func (c *Config) GetAccountByEmail(email string) *Account {
785 for i := range c.Accounts {
786 if c.Accounts[i].Email == email {
787 return &c.Accounts[i]
788 }
789 }
790 return nil
791}
792
793// HasAccounts returns true if there are any configured accounts.
794func (c *Config) HasAccounts() bool {
795 return len(c.Accounts) > 0
796}
797
798// GetAccountIDs returns the configured account IDs.
799func (c *Config) GetAccountIDs() []string {
800 ids := make([]string, 0, len(c.Accounts))
801 for _, acc := range c.Accounts {
802 if acc.ID != "" {
803 ids = append(ids, acc.ID)
804 }
805 }
806 return ids
807}
808
809// GetFirstAccount returns the first account or nil if none exist.
810func (c *Config) GetFirstAccount() *Account {
811 if len(c.Accounts) > 0 {
812 return &c.Accounts[0]
813 }
814 return nil
815}
816
817// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
818func EnsurePGPDir() error {
819 dir, err := configDir()
820 if err != nil {
821 return err
822 }
823 pgpDir := filepath.Join(dir, "pgp")
824 return os.MkdirAll(pgpDir, 0700)
825}