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