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