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