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