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