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