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