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