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