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