config.go

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