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