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