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