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