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