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