config.go

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