config.go

  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}