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