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