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