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