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