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