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