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