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