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