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}