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