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	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}