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