config.go

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