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	PGPEncryptByDefault bool   `json:"pgp_encrypt_by_default,omitempty"` // Auto-encrypt when recipient keys available
 44
 45	// OAuth2 settings
 46	AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
 47
 48	// Multi-protocol settings
 49	Protocol     string `json:"protocol,omitempty"`      // "imap" (default), "jmap", or "pop3"
 50	JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
 51	POP3Server   string `json:"pop3_server,omitempty"`   // POP3 server hostname (for protocol=pop3)
 52	POP3Port     int    `json:"pop3_port,omitempty"`     // POP3 server port (for protocol=pop3)
 53}
 54
 55// MailingList represents a named group of email addresses.
 56type MailingList struct {
 57	Name      string   `json:"name"`
 58	Addresses []string `json:"addresses"`
 59}
 60
 61// Config stores the user's email configuration with multiple accounts.
 62type Config struct {
 63	Accounts             []Account     `json:"accounts"`
 64	DisableImages        bool          `json:"disable_images,omitempty"`
 65	HideTips             bool          `json:"hide_tips,omitempty"`
 66	DisableNotifications bool          `json:"disable_notifications,omitempty"`
 67	Theme                string        `json:"theme,omitempty"`
 68	MailingLists         []MailingList `json:"mailing_lists,omitempty"`
 69}
 70
 71// GetIMAPServer returns the IMAP server address for the account.
 72func (a *Account) GetIMAPServer() string {
 73	switch a.ServiceProvider {
 74	case "gmail":
 75		return "imap.gmail.com"
 76	case "icloud":
 77		return "imap.mail.me.com"
 78	case "custom":
 79		return a.IMAPServer
 80	default:
 81		return ""
 82	}
 83}
 84
 85// GetIMAPPort returns the IMAP port for the account.
 86func (a *Account) GetIMAPPort() int {
 87	switch a.ServiceProvider {
 88	case "gmail", "icloud":
 89		return 993
 90	case "custom":
 91		if a.IMAPPort != 0 {
 92			return a.IMAPPort
 93		}
 94		return 993 // Default IMAP SSL port
 95	default:
 96		return 993
 97	}
 98}
 99
100// GetSMTPServer returns the SMTP server address for the account.
101func (a *Account) GetSMTPServer() string {
102	switch a.ServiceProvider {
103	case "gmail":
104		return "smtp.gmail.com"
105	case "icloud":
106		return "smtp.mail.me.com"
107	case "custom":
108		return a.SMTPServer
109	default:
110		return ""
111	}
112}
113
114// GetSMTPPort returns the SMTP port for the account.
115func (a *Account) GetSMTPPort() int {
116	switch a.ServiceProvider {
117	case "gmail", "icloud":
118		return 587
119	case "custom":
120		if a.SMTPPort != 0 {
121			return a.SMTPPort
122		}
123		return 587 // Default SMTP TLS port
124	default:
125		return 587
126	}
127}
128
129// GetPOP3Server returns the POP3 server address for the account.
130func (a *Account) GetPOP3Server() string {
131	if a.POP3Server != "" {
132		return a.POP3Server
133	}
134	return ""
135}
136
137// GetPOP3Port returns the POP3 port for the account.
138func (a *Account) GetPOP3Port() int {
139	if a.POP3Port != 0 {
140		return a.POP3Port
141	}
142	return 995 // Default POP3 SSL port
143}
144
145// GetConfigDir returns the path to the configuration directory (exported).
146func GetConfigDir() (string, error) {
147	return configDir()
148}
149
150// configDir returns the path to the configuration directory (internal).
151func configDir() (string, error) {
152	home, err := os.UserHomeDir()
153	if err != nil {
154		return "", err
155	}
156	return filepath.Join(home, ".config", "matcha"), nil
157}
158
159// configFile returns the full path to the configuration file.
160func configFile() (string, error) {
161	dir, err := configDir()
162	if err != nil {
163		return "", err
164	}
165	return filepath.Join(dir, "config.json"), nil
166}
167
168// SaveConfig saves the given configuration to the config file and passwords to the keyring.
169func SaveConfig(config *Config) error {
170	// Save passwords and PGP PINs to the OS keyring before writing the JSON file
171	for _, acc := range config.Accounts {
172		if acc.Password != "" {
173			// We ignore the error here because some environments (like headless CI)
174			// might not have a keyring service, but we still want to save the rest of the config.
175			_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
176		}
177		// Save YubiKey PIN if present
178		if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
179			_ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
180		}
181	}
182
183	path, err := configFile()
184	if err != nil {
185		return err
186	}
187	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
188		return err
189	}
190	data, err := json.MarshalIndent(config, "", "  ")
191	if err != nil {
192		return err
193	}
194	return os.WriteFile(path, data, 0600)
195}
196
197// LoadConfig loads the configuration from the config file and passwords from the keyring.
198// It automatically migrates plain-text passwords to the OS keyring if they exist.
199func LoadConfig() (*Config, error) {
200	path, err := configFile()
201	if err != nil {
202		return nil, err
203	}
204	data, err := os.ReadFile(path)
205	if err != nil {
206		return nil, err
207	}
208
209	var config Config
210	var needsMigration bool
211
212	type rawAccount struct {
213		ID                  string `json:"id"`
214		Name                string `json:"name"`
215		Email               string `json:"email"`
216		Password            string `json:"password,omitempty"`
217		ServiceProvider     string `json:"service_provider"`
218		FetchEmail          string `json:"fetch_email,omitempty"`
219		IMAPServer          string `json:"imap_server,omitempty"`
220		IMAPPort            int    `json:"imap_port,omitempty"`
221		SMTPServer          string `json:"smtp_server,omitempty"`
222		SMTPPort            int    `json:"smtp_port,omitempty"`
223		Insecure            bool   `json:"insecure,omitempty"`
224		SMIMECert           string `json:"smime_cert,omitempty"`
225		SMIMEKey            string `json:"smime_key,omitempty"`
226		SMIMESignByDefault  bool   `json:"smime_sign_by_default,omitempty"`
227		PGPPublicKey        string `json:"pgp_public_key,omitempty"`
228		PGPPrivateKey       string `json:"pgp_private_key,omitempty"`
229		PGPKeySource        string `json:"pgp_key_source,omitempty"`
230		PGPSignByDefault    bool   `json:"pgp_sign_by_default,omitempty"`
231		PGPEncryptByDefault bool   `json:"pgp_encrypt_by_default,omitempty"`
232		AuthMethod          string `json:"auth_method,omitempty"`
233		Protocol            string `json:"protocol,omitempty"`
234		JMAPEndpoint        string `json:"jmap_endpoint,omitempty"`
235		POP3Server          string `json:"pop3_server,omitempty"`
236		POP3Port            int    `json:"pop3_port,omitempty"`
237	}
238	type diskConfig struct {
239		Accounts             []rawAccount  `json:"accounts"`
240		DisableImages        bool          `json:"disable_images,omitempty"`
241		HideTips             bool          `json:"hide_tips,omitempty"`
242		DisableNotifications bool          `json:"disable_notifications,omitempty"`
243		Theme                string        `json:"theme,omitempty"`
244		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
245	}
246
247	var raw diskConfig
248	if err := json.Unmarshal(data, &raw); err != nil {
249		var legacyConfig legacyConfigFormat
250		if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
251			config = Config{
252				Accounts: []Account{
253					{
254						ID:              uuid.New().String(),
255						Name:            legacyConfig.Name,
256						Email:           legacyConfig.Email,
257						Password:        legacyConfig.Password,
258						ServiceProvider: legacyConfig.ServiceProvider,
259						FetchEmail:      legacyConfig.Email,
260					},
261				},
262			}
263			// SaveConfig automatically pushes the password to the keyring and strips it from JSON
264			if saveErr := SaveConfig(&config); saveErr != nil {
265				return nil, saveErr
266			}
267			return &config, nil
268		}
269		return nil, err
270	}
271
272	config.DisableImages = raw.DisableImages
273	config.HideTips = raw.HideTips
274	config.DisableNotifications = raw.DisableNotifications
275	config.Theme = raw.Theme
276	config.MailingLists = raw.MailingLists
277	for _, rawAcc := range raw.Accounts {
278		acc := Account{
279			ID:                  rawAcc.ID,
280			Name:                rawAcc.Name,
281			Email:               rawAcc.Email,
282			ServiceProvider:     rawAcc.ServiceProvider,
283			FetchEmail:          rawAcc.FetchEmail,
284			IMAPServer:          rawAcc.IMAPServer,
285			IMAPPort:            rawAcc.IMAPPort,
286			SMTPServer:          rawAcc.SMTPServer,
287			SMTPPort:            rawAcc.SMTPPort,
288			Insecure:            rawAcc.Insecure,
289			SMIMECert:           rawAcc.SMIMECert,
290			SMIMEKey:            rawAcc.SMIMEKey,
291			SMIMESignByDefault:  rawAcc.SMIMESignByDefault,
292			PGPPublicKey:        rawAcc.PGPPublicKey,
293			PGPPrivateKey:       rawAcc.PGPPrivateKey,
294			PGPKeySource:        rawAcc.PGPKeySource,
295			PGPSignByDefault:    rawAcc.PGPSignByDefault,
296			PGPEncryptByDefault: rawAcc.PGPEncryptByDefault,
297			AuthMethod:          rawAcc.AuthMethod,
298			Protocol:            rawAcc.Protocol,
299			JMAPEndpoint:        rawAcc.JMAPEndpoint,
300			POP3Server:          rawAcc.POP3Server,
301			POP3Port:            rawAcc.POP3Port,
302		}
303
304		if rawAcc.Password != "" {
305			// Found a plain-text password! Move it to the OS Keyring.
306			_ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password)
307			acc.Password = rawAcc.Password
308			needsMigration = true
309		} else {
310			// No plaintext password in JSON, fetch from Keyring as normal.
311			if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
312				acc.Password = pwd
313			}
314		}
315
316		// Load YubiKey PIN from keyring if using YubiKey
317		if acc.PGPKeySource == "yubikey" {
318			if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
319				acc.PGPPIN = pin
320			}
321		}
322
323		config.Accounts = append(config.Accounts, acc)
324	}
325
326	if needsMigration {
327		if saveErr := SaveConfig(&config); saveErr != nil {
328			return nil, saveErr
329		}
330	}
331
332	return &config, nil
333}
334
335// legacyConfigFormat represents the old single-account configuration format.
336type legacyConfigFormat struct {
337	ServiceProvider string `json:"service_provider"`
338	Email           string `json:"email"`
339	Password        string `json:"password"`
340	Name            string `json:"name"`
341}
342
343// AddAccount adds a new account to the configuration.
344func (c *Config) AddAccount(account Account) {
345	if account.ID == "" {
346		account.ID = uuid.New().String()
347	}
348	// Ensure FetchEmail defaults to the login Email if not explicitly set.
349	if account.FetchEmail == "" && account.Email != "" {
350		account.FetchEmail = account.Email
351	}
352	c.Accounts = append(c.Accounts, account)
353}
354
355// RemoveAccount removes an account by its ID and deletes its password from the keyring.
356func (c *Config) RemoveAccount(id string) bool {
357	for i, acc := range c.Accounts {
358		if acc.ID == id {
359			// Delete password from OS Keyring when account is removed
360			_ = keyring.Delete(keyringServiceName, acc.Email)
361			// Delete PGP PIN from OS Keyring if present
362			_ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
363
364			c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
365			return true
366		}
367	}
368	return false
369}
370
371// GetAccountByID returns an account by its ID.
372func (c *Config) GetAccountByID(id string) *Account {
373	for i := range c.Accounts {
374		if c.Accounts[i].ID == id {
375			return &c.Accounts[i]
376		}
377	}
378	return nil
379}
380
381// GetAccountByEmail returns an account by its email address.
382func (c *Config) GetAccountByEmail(email string) *Account {
383	for i := range c.Accounts {
384		if c.Accounts[i].Email == email {
385			return &c.Accounts[i]
386		}
387	}
388	return nil
389}
390
391// HasAccounts returns true if there are any configured accounts.
392func (c *Config) HasAccounts() bool {
393	return len(c.Accounts) > 0
394}
395
396// GetFirstAccount returns the first account or nil if none exist.
397func (c *Config) GetFirstAccount() *Account {
398	if len(c.Accounts) > 0 {
399		return &c.Accounts[0]
400	}
401	return nil
402}
403
404// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
405func EnsurePGPDir() error {
406	dir, err := configDir()
407	if err != nil {
408		return err
409	}
410	pgpDir := filepath.Join(dir, "pgp")
411	return os.MkdirAll(pgpDir, 0700)
412}