config.go

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