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// configFile returns the full path to the configuration file.
159func configFile() (string, error) {
160	dir, err := configDir()
161	if err != nil {
162		return "", err
163	}
164	return filepath.Join(dir, "config.json"), nil
165}
166
167// SaveConfig saves the given configuration to the config file and passwords to the keyring.
168func SaveConfig(config *Config) error {
169	// Save passwords and PGP PINs to the OS keyring before writing the JSON file
170	for _, acc := range config.Accounts {
171		if acc.Password != "" {
172			// We ignore the error here because some environments (like headless CI)
173			// might not have a keyring service, but we still want to save the rest of the config.
174			_ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
175		}
176		// Save YubiKey PIN if present
177		if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
178			_ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
179		}
180	}
181
182	path, err := configFile()
183	if err != nil {
184		return err
185	}
186	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
187		return err
188	}
189	data, err := json.MarshalIndent(config, "", "  ")
190	if err != nil {
191		return err
192	}
193	return os.WriteFile(path, data, 0600)
194}
195
196// LoadConfig loads the configuration from the config file and passwords from the keyring.
197// It automatically migrates plain-text passwords to the OS keyring if they exist.
198func LoadConfig() (*Config, error) {
199	path, err := configFile()
200	if err != nil {
201		return nil, err
202	}
203	data, err := os.ReadFile(path)
204	if err != nil {
205		return nil, err
206	}
207
208	var config Config
209	var needsMigration bool
210
211	type rawAccount struct {
212		ID                 string `json:"id"`
213		Name               string `json:"name"`
214		Email              string `json:"email"`
215		Password           string `json:"password,omitempty"`
216		ServiceProvider    string `json:"service_provider"`
217		FetchEmail         string `json:"fetch_email,omitempty"`
218		IMAPServer         string `json:"imap_server,omitempty"`
219		IMAPPort           int    `json:"imap_port,omitempty"`
220		SMTPServer         string `json:"smtp_server,omitempty"`
221		SMTPPort           int    `json:"smtp_port,omitempty"`
222		Insecure           bool   `json:"insecure,omitempty"`
223		SMIMECert          string `json:"smime_cert,omitempty"`
224		SMIMEKey           string `json:"smime_key,omitempty"`
225		SMIMESignByDefault bool   `json:"smime_sign_by_default,omitempty"`
226		PGPPublicKey       string `json:"pgp_public_key,omitempty"`
227		PGPPrivateKey      string `json:"pgp_private_key,omitempty"`
228		PGPKeySource       string `json:"pgp_key_source,omitempty"`
229		PGPSignByDefault   bool   `json:"pgp_sign_by_default,omitempty"`
230		AuthMethod         string `json:"auth_method,omitempty"`
231		Protocol           string `json:"protocol,omitempty"`
232		JMAPEndpoint       string `json:"jmap_endpoint,omitempty"`
233		POP3Server         string `json:"pop3_server,omitempty"`
234		POP3Port           int    `json:"pop3_port,omitempty"`
235	}
236	type diskConfig struct {
237		Accounts             []rawAccount  `json:"accounts"`
238		DisableImages        bool          `json:"disable_images,omitempty"`
239		HideTips             bool          `json:"hide_tips,omitempty"`
240		DisableNotifications bool          `json:"disable_notifications,omitempty"`
241		Theme                string        `json:"theme,omitempty"`
242		MailingLists         []MailingList `json:"mailing_lists,omitempty"`
243	}
244
245	var raw diskConfig
246	if err := json.Unmarshal(data, &raw); err != nil {
247		var legacyConfig legacyConfigFormat
248		if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
249			config = Config{
250				Accounts: []Account{
251					{
252						ID:              uuid.New().String(),
253						Name:            legacyConfig.Name,
254						Email:           legacyConfig.Email,
255						Password:        legacyConfig.Password,
256						ServiceProvider: legacyConfig.ServiceProvider,
257						FetchEmail:      legacyConfig.Email,
258					},
259				},
260			}
261			// SaveConfig automatically pushes the password to the keyring and strips it from JSON
262			if saveErr := SaveConfig(&config); saveErr != nil {
263				return nil, saveErr
264			}
265			return &config, nil
266		}
267		return nil, err
268	}
269
270	config.DisableImages = raw.DisableImages
271	config.HideTips = raw.HideTips
272	config.DisableNotifications = raw.DisableNotifications
273	config.Theme = raw.Theme
274	config.MailingLists = raw.MailingLists
275	for _, rawAcc := range raw.Accounts {
276		acc := Account{
277			ID:                 rawAcc.ID,
278			Name:               rawAcc.Name,
279			Email:              rawAcc.Email,
280			ServiceProvider:    rawAcc.ServiceProvider,
281			FetchEmail:         rawAcc.FetchEmail,
282			IMAPServer:         rawAcc.IMAPServer,
283			IMAPPort:           rawAcc.IMAPPort,
284			SMTPServer:         rawAcc.SMTPServer,
285			SMTPPort:           rawAcc.SMTPPort,
286			Insecure:           rawAcc.Insecure,
287			SMIMECert:          rawAcc.SMIMECert,
288			SMIMEKey:           rawAcc.SMIMEKey,
289			SMIMESignByDefault: rawAcc.SMIMESignByDefault,
290			PGPPublicKey:       rawAcc.PGPPublicKey,
291			PGPPrivateKey:      rawAcc.PGPPrivateKey,
292			PGPKeySource:       rawAcc.PGPKeySource,
293			PGPSignByDefault:   rawAcc.PGPSignByDefault,
294			AuthMethod:         rawAcc.AuthMethod,
295			Protocol:           rawAcc.Protocol,
296			JMAPEndpoint:       rawAcc.JMAPEndpoint,
297			POP3Server:         rawAcc.POP3Server,
298			POP3Port:           rawAcc.POP3Port,
299		}
300
301		if rawAcc.Password != "" {
302			// Found a plain-text password! Move it to the OS Keyring.
303			_ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password)
304			acc.Password = rawAcc.Password
305			needsMigration = true
306		} else {
307			// No plaintext password in JSON, fetch from Keyring as normal.
308			if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
309				acc.Password = pwd
310			}
311		}
312
313		// Load YubiKey PIN from keyring if using YubiKey
314		if acc.PGPKeySource == "yubikey" {
315			if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
316				acc.PGPPIN = pin
317			}
318		}
319
320		config.Accounts = append(config.Accounts, acc)
321	}
322
323	if needsMigration {
324		if saveErr := SaveConfig(&config); saveErr != nil {
325			return nil, saveErr
326		}
327	}
328
329	return &config, nil
330}
331
332// legacyConfigFormat represents the old single-account configuration format.
333type legacyConfigFormat struct {
334	ServiceProvider string `json:"service_provider"`
335	Email           string `json:"email"`
336	Password        string `json:"password"`
337	Name            string `json:"name"`
338}
339
340// AddAccount adds a new account to the configuration.
341func (c *Config) AddAccount(account Account) {
342	if account.ID == "" {
343		account.ID = uuid.New().String()
344	}
345	// Ensure FetchEmail defaults to the login Email if not explicitly set.
346	if account.FetchEmail == "" && account.Email != "" {
347		account.FetchEmail = account.Email
348	}
349	c.Accounts = append(c.Accounts, account)
350}
351
352// RemoveAccount removes an account by its ID and deletes its password from the keyring.
353func (c *Config) RemoveAccount(id string) bool {
354	for i, acc := range c.Accounts {
355		if acc.ID == id {
356			// Delete password from OS Keyring when account is removed
357			_ = keyring.Delete(keyringServiceName, acc.Email)
358			// Delete PGP PIN from OS Keyring if present
359			_ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
360
361			c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
362			return true
363		}
364	}
365	return false
366}
367
368// GetAccountByID returns an account by its ID.
369func (c *Config) GetAccountByID(id string) *Account {
370	for i := range c.Accounts {
371		if c.Accounts[i].ID == id {
372			return &c.Accounts[i]
373		}
374	}
375	return nil
376}
377
378// GetAccountByEmail returns an account by its email address.
379func (c *Config) GetAccountByEmail(email string) *Account {
380	for i := range c.Accounts {
381		if c.Accounts[i].Email == email {
382			return &c.Accounts[i]
383		}
384	}
385	return nil
386}
387
388// HasAccounts returns true if there are any configured accounts.
389func (c *Config) HasAccounts() bool {
390	return len(c.Accounts) > 0
391}
392
393// GetFirstAccount returns the first account or nil if none exist.
394func (c *Config) GetFirstAccount() *Account {
395	if len(c.Accounts) > 0 {
396		return &c.Accounts[0]
397	}
398	return nil
399}
400
401// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
402func EnsurePGPDir() error {
403	dir, err := configDir()
404	if err != nil {
405		return err
406	}
407	pgpDir := filepath.Join(dir, "pgp")
408	return os.MkdirAll(pgpDir, 0700)
409}