config.go

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