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