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