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