config.go

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