config.go

  1package config
  2
  3import (
  4	"encoding/json"
  5	"os"
  6	"path/filepath"
  7
  8	"github.com/google/uuid"
  9	"github.com/zalando/go-keyring"
 10)
 11
 12const keyringServiceName = "matcha-email-client"
 13
 14// Account stores the configuration for a single email account.
 15type Account struct {
 16	ID              string `json:"id"`
 17	Name            string `json:"name"`
 18	Email           string `json:"email"`
 19	Password        string `json:"-"`                // "-" prevents the password from being saved to config.json
 20	ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom"
 21	// FetchEmail is the single email address for which messages should be fetched.
 22	// If empty, it will default to `Email` when accounts are added.
 23	FetchEmail string `json:"fetch_email,omitempty"`
 24
 25	// Custom server settings (used when ServiceProvider is "custom")
 26	IMAPServer string `json:"imap_server,omitempty"`
 27	IMAPPort   int    `json:"imap_port,omitempty"`
 28	SMTPServer string `json:"smtp_server,omitempty"`
 29	SMTPPort   int    `json:"smtp_port,omitempty"`
 30	Insecure   bool   `json:"insecure,omitempty"`
 31
 32	// S/MIME settings
 33	SMIMECert          string `json:"smime_cert,omitempty"`            // Path to the public certificate PEM
 34	SMIMEKey           string `json:"smime_key,omitempty"`             // Path to the private key PEM
 35	SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default
 36
 37	// PGP settings
 38	PGPPublicKey     string `json:"pgp_public_key,omitempty"`      // Path to public key (.asc or .gpg)
 39	PGPPrivateKey    string `json:"pgp_private_key,omitempty"`     // Path to private key (.asc or .gpg)
 40	PGPKeySource     string `json:"pgp_key_source,omitempty"`      // "file" (default) or "yubikey" for hardware key
 41	PGPPIN           string `json:"-"`                             // YubiKey PIN (stored in keyring, not JSON)
 42	PGPSignByDefault bool   `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails
 43
 44	// OAuth2 settings
 45	AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
 46
 47	// Multi-protocol settings
 48	Protocol     string `json:"protocol,omitempty"`      // "imap" (default), "jmap", or "pop3"
 49	JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
 50	POP3Server   string `json:"pop3_server,omitempty"`   // POP3 server hostname (for protocol=pop3)
 51	POP3Port     int    `json:"pop3_port,omitempty"`     // POP3 server port (for protocol=pop3)
 52}
 53
 54// MailingList represents a named group of email addresses.
 55type MailingList struct {
 56	Name      string   `json:"name"`
 57	Addresses []string `json:"addresses"`
 58}
 59
 60// Config stores the user's email configuration with multiple accounts.
 61type Config struct {
 62	Accounts             []Account     `json:"accounts"`
 63	DisableImages        bool          `json:"disable_images,omitempty"`
 64	HideTips             bool          `json:"hide_tips,omitempty"`
 65	DisableNotifications bool          `json:"disable_notifications,omitempty"`
 66	Theme                string        `json:"theme,omitempty"`
 67	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 68}
 69
 70// GetIMAPServer returns the IMAP server address for the account.
 71func (a *Account) GetIMAPServer() string {
 72	switch a.ServiceProvider {
 73	case "gmail":
 74		return "imap.gmail.com"
 75	case "icloud":
 76		return "imap.mail.me.com"
 77	case "custom":
 78		return a.IMAPServer
 79	default:
 80		return ""
 81	}
 82}
 83
 84// GetIMAPPort returns the IMAP port for the account.
 85func (a *Account) GetIMAPPort() int {
 86	switch a.ServiceProvider {
 87	case "gmail", "icloud":
 88		return 993
 89	case "custom":
 90		if a.IMAPPort != 0 {
 91			return a.IMAPPort
 92		}
 93		return 993 // Default IMAP SSL port
 94	default:
 95		return 993
 96	}
 97}
 98
 99// GetSMTPServer returns the SMTP server address for the account.
100func (a *Account) GetSMTPServer() string {
101	switch a.ServiceProvider {
102	case "gmail":
103		return "smtp.gmail.com"
104	case "icloud":
105		return "smtp.mail.me.com"
106	case "custom":
107		return a.SMTPServer
108	default:
109		return ""
110	}
111}
112
113// GetSMTPPort returns the SMTP port for the account.
114func (a *Account) GetSMTPPort() int {
115	switch a.ServiceProvider {
116	case "gmail", "icloud":
117		return 587
118	case "custom":
119		if a.SMTPPort != 0 {
120			return a.SMTPPort
121		}
122		return 587 // Default SMTP TLS port
123	default:
124		return 587
125	}
126}
127
128// GetPOP3Server returns the POP3 server address for the account.
129func (a *Account) GetPOP3Server() string {
130	if a.POP3Server != "" {
131		return a.POP3Server
132	}
133	return ""
134}
135
136// GetPOP3Port returns the POP3 port for the account.
137func (a *Account) GetPOP3Port() int {
138	if a.POP3Port != 0 {
139		return a.POP3Port
140	}
141	return 995 // Default POP3 SSL port
142}
143
144// GetConfigDir returns the path to the configuration directory (exported).
145func GetConfigDir() (string, error) {
146	return configDir()
147}
148
149// configDir returns the path to the configuration directory (internal).
150func configDir() (string, error) {
151	home, err := os.UserHomeDir()
152	if err != nil {
153		return "", err
154	}
155	return filepath.Join(home, ".config", "matcha"), nil
156}
157
158// GetCacheDir returns the path to the cache directory (exported).
159func GetCacheDir() (string, error) {
160	return cacheDir()
161}
162
163// cacheDir returns the path to the cache directory (internal).
164func cacheDir() (string, error) {
165	home, err := os.UserHomeDir()
166	if err != nil {
167		return "", err
168	}
169	return filepath.Join(home, ".cache", "matcha"), nil
170}
171
172// MigrateCacheFiles moves cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed.
173// This is a one-time migration for existing installations.
174func MigrateCacheFiles() error {
175	src, err := configDir()
176	if err != nil {
177		return err
178	}
179	dst, err := cacheDir()
180	if err != nil {
181		return err
182	}
183	if err := os.MkdirAll(dst, 0700); err != nil {
184		return err
185	}
186
187	// Files to migrate
188	files := []string{"email_cache.json", "contacts.json", "drafts.json", "folder_cache.json"}
189	for _, f := range files {
190		oldPath := filepath.Join(src, f)
191		newPath := filepath.Join(dst, f)
192		if _, err := os.Stat(oldPath); err == nil {
193			// Only migrate if destination doesn't already exist
194			if _, err := os.Stat(newPath); err != nil {
195				if err := os.Rename(oldPath, newPath); err != nil {
196					return err
197				}
198			}
199		}
200	}
201
202	// Migrate folder_emails directory
203	oldDir := filepath.Join(src, "folder_emails")
204	newDir := filepath.Join(dst, "folder_emails")
205	if info, err := os.Stat(oldDir); err == nil && info.IsDir() {
206		if _, err := os.Stat(newDir); err != nil {
207			if err := os.Rename(oldDir, newDir); err != nil {
208				return err
209			}
210		}
211	}
212
213	return nil
214}
215
216// configFile returns the full path to the configuration file.
217func configFile() (string, error) {
218	dir, err := configDir()
219	if err != nil {
220		return "", err
221	}
222	return filepath.Join(dir, "config.json"), nil
223}
224
225// secureDiskAccount includes the Password field in JSON when secure mode is active.
226type secureDiskAccount struct {
227	ID                 string `json:"id"`
228	Name               string `json:"name"`
229	Email              string `json:"email"`
230	Password           string `json:"password,omitempty"`
231	ServiceProvider    string `json:"service_provider"`
232	FetchEmail         string `json:"fetch_email,omitempty"`
233	IMAPServer         string `json:"imap_server,omitempty"`
234	IMAPPort           int    `json:"imap_port,omitempty"`
235	SMTPServer         string `json:"smtp_server,omitempty"`
236	SMTPPort           int    `json:"smtp_port,omitempty"`
237	Insecure           bool   `json:"insecure,omitempty"`
238	SMIMECert          string `json:"smime_cert,omitempty"`
239	SMIMEKey           string `json:"smime_key,omitempty"`
240	SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"`
241	PGPPublicKey       string `json:"pgp_public_key,omitempty"`
242	PGPPrivateKey      string `json:"pgp_private_key,omitempty"`
243	PGPKeySource       string `json:"pgp_key_source,omitempty"`
244	PGPPIN             string `json:"pgp_pin,omitempty"`
245	PGPSignByDefault   bool   `json:"pgp_sign_by_default,omitempty"`
246	AuthMethod         string `json:"auth_method,omitempty"`
247	Protocol           string `json:"protocol,omitempty"`
248	JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
249	POP3Server         string `json:"pop3_server,omitempty"`
250	POP3Port           int    `json:"pop3_port,omitempty"`
251}
252
253type secureDiskConfig struct {
254	Accounts             []secureDiskAccount `json:"accounts"`
255	DisableImages        bool                `json:"disable_images,omitempty"`
256	HideTips             bool                `json:"hide_tips,omitempty"`
257	DisableNotifications bool                `json:"disable_notifications,omitempty"`
258	Theme                string              `json:"theme,omitempty"`
259	MailingLists         []MailingList       `json:"mailing_lists,omitempty"`
260}
261
262// SaveConfig saves the given configuration to the config file and passwords to the keyring.
263func SaveConfig(config *Config) error {
264	secureMode := GetSessionKey() != nil
265
266	if !secureMode {
267		// Save passwords and PGP PINs to the OS keyring before writing the JSON file
268		for _, acc := range config.Accounts {
269			if acc.Password != "" {
270				_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
271			}
272			if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
273				_ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
274			}
275		}
276	}
277
278	path, err := configFile()
279	if err != nil {
280		return err
281	}
282	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
283		return err
284	}
285
286	var data []byte
287	if secureMode {
288		// In secure mode, include passwords in the JSON (they'll be encrypted on disk)
289		sdc := secureDiskConfig{
290			DisableImages:        config.DisableImages,
291			HideTips:             config.HideTips,
292			DisableNotifications: config.DisableNotifications,
293			Theme:                config.Theme,
294			MailingLists:         config.MailingLists,
295		}
296		for _, acc := range config.Accounts {
297			sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
298				ID:                 acc.ID,
299				Name:               acc.Name,
300				Email:              acc.Email,
301				Password:           acc.Password,
302				ServiceProvider:    acc.ServiceProvider,
303				FetchEmail:         acc.FetchEmail,
304				IMAPServer:         acc.IMAPServer,
305				IMAPPort:           acc.IMAPPort,
306				SMTPServer:         acc.SMTPServer,
307				SMTPPort:           acc.SMTPPort,
308				Insecure:           acc.Insecure,
309				SMIMECert:          acc.SMIMECert,
310				SMIMEKey:           acc.SMIMEKey,
311				SMIMESignByDefault: acc.SMIMESignByDefault,
312				PGPPublicKey:       acc.PGPPublicKey,
313				PGPPrivateKey:      acc.PGPPrivateKey,
314				PGPKeySource:       acc.PGPKeySource,
315				PGPPIN:             acc.PGPPIN,
316				PGPSignByDefault:   acc.PGPSignByDefault,
317				AuthMethod:         acc.AuthMethod,
318				Protocol:           acc.Protocol,
319				JMAPEndpoint:       acc.JMAPEndpoint,
320				POP3Server:         acc.POP3Server,
321				POP3Port:           acc.POP3Port,
322			})
323		}
324		data, err = json.MarshalIndent(sdc, "", "  ")
325	} else {
326		data, err = json.MarshalIndent(config, "", "  ")
327	}
328	if err != nil {
329		return err
330	}
331	return SecureWriteFile(path, data, 0600)
332}
333
334// LoadConfig loads the configuration from the config file and passwords from the keyring.
335// It automatically migrates plain-text passwords to the OS keyring if they exist.
336func LoadConfig() (*Config, error) {
337	path, err := configFile()
338	if err != nil {
339		return nil, err
340	}
341	data, err := SecureReadFile(path)
342	if err != nil {
343		return nil, err
344	}
345
346	secureMode := GetSessionKey() != nil
347
348	var config Config
349	var needsMigration bool
350
351	type rawAccount struct {
352		ID                 string `json:"id"`
353		Name               string `json:"name"`
354		Email              string `json:"email"`
355		Password           string `json:"password,omitempty"`
356		ServiceProvider    string `json:"service_provider"`
357		FetchEmail         string `json:"fetch_email,omitempty"`
358		IMAPServer         string `json:"imap_server,omitempty"`
359		IMAPPort           int    `json:"imap_port,omitempty"`
360		SMTPServer         string `json:"smtp_server,omitempty"`
361		SMTPPort           int    `json:"smtp_port,omitempty"`
362		Insecure           bool   `json:"insecure,omitempty"`
363		SMIMECert          string `json:"smime_cert,omitempty"`
364		SMIMEKey           string `json:"smime_key,omitempty"`
365		SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"`
366		PGPPublicKey       string `json:"pgp_public_key,omitempty"`
367		PGPPrivateKey      string `json:"pgp_private_key,omitempty"`
368		PGPKeySource       string `json:"pgp_key_source,omitempty"`
369		PGPPIN             string `json:"pgp_pin,omitempty"`
370		PGPSignByDefault   bool   `json:"pgp_sign_by_default,omitempty"`
371		AuthMethod         string `json:"auth_method,omitempty"`
372		Protocol           string `json:"protocol,omitempty"`
373		JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
374		POP3Server         string `json:"pop3_server,omitempty"`
375		POP3Port           int    `json:"pop3_port,omitempty"`
376	}
377	type diskConfig struct {
378		Accounts             []rawAccount  `json:"accounts"`
379		DisableImages        bool          `json:"disable_images,omitempty"`
380		HideTips             bool          `json:"hide_tips,omitempty"`
381		DisableNotifications bool          `json:"disable_notifications,omitempty"`
382		Theme                string        `json:"theme,omitempty"`
383		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
384	}
385
386	var raw diskConfig
387	if err := json.Unmarshal(data, &raw); err != nil {
388		var legacyConfig legacyConfigFormat
389		if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
390			config = Config{
391				Accounts: []Account{
392					{
393						ID:              uuid.New().String(),
394						Name:            legacyConfig.Name,
395						Email:           legacyConfig.Email,
396						Password:        legacyConfig.Password,
397						ServiceProvider: legacyConfig.ServiceProvider,
398						FetchEmail:      legacyConfig.Email,
399					},
400				},
401			}
402			// SaveConfig automatically pushes the password to the keyring and strips it from JSON
403			if saveErr := SaveConfig(&config); saveErr != nil {
404				return nil, saveErr
405			}
406			return &config, nil
407		}
408		return nil, err
409	}
410
411	config.DisableImages = raw.DisableImages
412	config.HideTips = raw.HideTips
413	config.DisableNotifications = raw.DisableNotifications
414	config.Theme = raw.Theme
415	config.MailingLists = raw.MailingLists
416	for _, rawAcc := range raw.Accounts {
417		acc := Account{
418			ID:                 rawAcc.ID,
419			Name:               rawAcc.Name,
420			Email:              rawAcc.Email,
421			ServiceProvider:    rawAcc.ServiceProvider,
422			FetchEmail:         rawAcc.FetchEmail,
423			IMAPServer:         rawAcc.IMAPServer,
424			IMAPPort:           rawAcc.IMAPPort,
425			SMTPServer:         rawAcc.SMTPServer,
426			SMTPPort:           rawAcc.SMTPPort,
427			Insecure:           rawAcc.Insecure,
428			SMIMECert:          rawAcc.SMIMECert,
429			SMIMEKey:           rawAcc.SMIMEKey,
430			SMIMESignByDefault: rawAcc.SMIMESignByDefault,
431			PGPPublicKey:       rawAcc.PGPPublicKey,
432			PGPPrivateKey:      rawAcc.PGPPrivateKey,
433			PGPKeySource:       rawAcc.PGPKeySource,
434			PGPSignByDefault:   rawAcc.PGPSignByDefault,
435			AuthMethod:         rawAcc.AuthMethod,
436			Protocol:           rawAcc.Protocol,
437			JMAPEndpoint:       rawAcc.JMAPEndpoint,
438			POP3Server:         rawAcc.POP3Server,
439			POP3Port:           rawAcc.POP3Port,
440		}
441
442		if secureMode {
443			// In secure mode, passwords and PINs are stored in the encrypted config JSON
444			acc.Password = rawAcc.Password
445			acc.PGPPIN = rawAcc.PGPPIN
446		} else if rawAcc.Password != "" {
447			// Found a plain-text password! Move it to the OS Keyring.
448			_ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password)
449			acc.Password = rawAcc.Password
450			needsMigration = true
451		} else {
452			// No plaintext password in JSON, fetch from Keyring as normal.
453			if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
454				acc.Password = pwd
455			}
456		}
457
458		if !secureMode {
459			// Load YubiKey PIN from keyring if using YubiKey
460			if acc.PGPKeySource == "yubikey" {
461				if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
462					acc.PGPPIN = pin
463				}
464			}
465		}
466
467		config.Accounts = append(config.Accounts, acc)
468	}
469
470	if needsMigration {
471		if saveErr := SaveConfig(&config); saveErr != nil {
472			return nil, saveErr
473		}
474	}
475
476	return &config, nil
477}
478
479// legacyConfigFormat represents the old single-account configuration format.
480type legacyConfigFormat struct {
481	ServiceProvider string `json:"service_provider"`
482	Email           string `json:"email"`
483	Password        string `json:"password"`
484	Name            string `json:"name"`
485}
486
487// AddAccount adds a new account to the configuration.
488func (c *Config) AddAccount(account Account) {
489	if account.ID == "" {
490		account.ID = uuid.New().String()
491	}
492	// Ensure FetchEmail defaults to the login Email if not explicitly set.
493	if account.FetchEmail == "" && account.Email != "" {
494		account.FetchEmail = account.Email
495	}
496	c.Accounts = append(c.Accounts, account)
497}
498
499// RemoveAccount removes an account by its ID and deletes its password from the keyring.
500func (c *Config) RemoveAccount(id string) bool {
501	for i, acc := range c.Accounts {
502		if acc.ID == id {
503			// Delete password from OS Keyring when account is removed
504			_ = keyring.Delete(keyringServiceName, acc.Email)
505			// Delete PGP PIN from OS Keyring if present
506			_ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
507
508			c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
509			return true
510		}
511	}
512	return false
513}
514
515// GetAccountByID returns an account by its ID.
516func (c *Config) GetAccountByID(id string) *Account {
517	for i := range c.Accounts {
518		if c.Accounts[i].ID == id {
519			return &c.Accounts[i]
520		}
521	}
522	return nil
523}
524
525// GetAccountByEmail returns an account by its email address.
526func (c *Config) GetAccountByEmail(email string) *Account {
527	for i := range c.Accounts {
528		if c.Accounts[i].Email == email {
529			return &c.Accounts[i]
530		}
531	}
532	return nil
533}
534
535// HasAccounts returns true if there are any configured accounts.
536func (c *Config) HasAccounts() bool {
537	return len(c.Accounts) > 0
538}
539
540// GetFirstAccount returns the first account or nil if none exist.
541func (c *Config) GetFirstAccount() *Account {
542	if len(c.Accounts) > 0 {
543		return &c.Accounts[0]
544	}
545	return nil
546}
547
548// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
549func EnsurePGPDir() error {
550	dir, err := configDir()
551	if err != nil {
552		return err
553	}
554	pgpDir := filepath.Join(dir, "pgp")
555	return os.MkdirAll(pgpDir, 0700)
556}