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