1package config
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/google/uuid"
10 "github.com/zalando/go-keyring"
11)
12
13const keyringServiceName = "matcha-email-client"
14
15// Account stores the configuration for a single email account.
16type Account struct {
17 ID string `json:"id"`
18 Name string `json:"name"`
19 Email string `json:"email"`
20 Password string `json:"-"` // "-" prevents the password from being saved to config.json
21 ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom"
22 // FetchEmail is the single email address for which messages should be fetched.
23 // If empty, it will default to `Email` when accounts are added.
24 FetchEmail string `json:"fetch_email,omitempty"`
25 // SendAsEmail controls the visible From header on outgoing mail.
26 // If empty, it defaults to FetchEmail, then Email.
27 SendAsEmail string `json:"send_as_email,omitempty"`
28
29 // Custom server settings (used when ServiceProvider is "custom")
30 IMAPServer string `json:"imap_server,omitempty"`
31 IMAPPort int `json:"imap_port,omitempty"`
32 SMTPServer string `json:"smtp_server,omitempty"`
33 SMTPPort int `json:"smtp_port,omitempty"`
34 Insecure bool `json:"insecure,omitempty"`
35
36 // S/MIME settings
37 SMIMECert string `json:"smime_cert,omitempty"` // Path to the public certificate PEM
38 SMIMEKey string `json:"smime_key,omitempty"` // Path to the private key PEM
39 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default
40
41 // PGP settings
42 PGPPublicKey string `json:"pgp_public_key,omitempty"` // Path to public key (.asc or .gpg)
43 PGPPrivateKey string `json:"pgp_private_key,omitempty"` // Path to private key (.asc or .gpg)
44 PGPKeySource string `json:"pgp_key_source,omitempty"` // "file" (default) or "yubikey" for hardware key
45 PGPPIN string `json:"-"` // YubiKey PIN (stored in keyring, not JSON)
46 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails
47
48 // OAuth2 settings
49 AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2"
50
51 // Multi-protocol settings
52 Protocol string `json:"protocol,omitempty"` // "imap" (default), "jmap", or "pop3"
53 JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
54 POP3Server string `json:"pop3_server,omitempty"` // POP3 server hostname (for protocol=pop3)
55 POP3Port int `json:"pop3_port,omitempty"` // POP3 server port (for protocol=pop3)
56}
57
58// MailingList represents a named group of email addresses.
59type MailingList struct {
60 Name string `json:"name"`
61 Addresses []string `json:"addresses"`
62}
63
64// Config stores the user's email configuration with multiple accounts.
65type Config struct {
66 Accounts []Account `json:"accounts"`
67 DisableImages bool `json:"disable_images,omitempty"`
68 HideTips bool `json:"hide_tips,omitempty"`
69 DisableNotifications bool `json:"disable_notifications,omitempty"`
70 Theme string `json:"theme,omitempty"`
71 MailingLists []MailingList `json:"mailing_lists,omitempty"`
72}
73
74// GetIMAPServer returns the IMAP server address for the account.
75func (a *Account) GetIMAPServer() string {
76 switch a.ServiceProvider {
77 case "gmail":
78 return "imap.gmail.com"
79 case "icloud":
80 return "imap.mail.me.com"
81 case "custom":
82 return a.IMAPServer
83 default:
84 return ""
85 }
86}
87
88// GetIMAPPort returns the IMAP port for the account.
89func (a *Account) GetIMAPPort() int {
90 switch a.ServiceProvider {
91 case "gmail", "icloud":
92 return 993
93 case "custom":
94 if a.IMAPPort != 0 {
95 return a.IMAPPort
96 }
97 return 993 // Default IMAP SSL port
98 default:
99 return 993
100 }
101}
102
103// GetSMTPServer returns the SMTP server address for the account.
104func (a *Account) GetSMTPServer() string {
105 switch a.ServiceProvider {
106 case "gmail":
107 return "smtp.gmail.com"
108 case "icloud":
109 return "smtp.mail.me.com"
110 case "custom":
111 return a.SMTPServer
112 default:
113 return ""
114 }
115}
116
117// GetSMTPPort returns the SMTP port for the account.
118func (a *Account) GetSMTPPort() int {
119 switch a.ServiceProvider {
120 case "gmail", "icloud":
121 return 587
122 case "custom":
123 if a.SMTPPort != 0 {
124 return a.SMTPPort
125 }
126 return 587 // Default SMTP TLS port
127 default:
128 return 587
129 }
130}
131
132// GetFetchEmail returns the configured fetch identity, falling back to Email.
133func (a *Account) GetFetchEmail() string {
134 if a.FetchEmail != "" {
135 return a.FetchEmail
136 }
137 return a.Email
138}
139
140// GetSendAsEmail returns the visible sender address for outgoing mail.
141func (a *Account) GetSendAsEmail() string {
142 if a.SendAsEmail != "" {
143 return a.SendAsEmail
144 }
145 return a.GetFetchEmail()
146}
147
148// FormatFromHeader returns the display-ready From header value.
149func (a *Account) FormatFromHeader() string {
150 sendAs := a.GetSendAsEmail()
151 if a.Name != "" && sendAs != "" {
152 return fmt.Sprintf("%s <%s>", a.Name, sendAs)
153 }
154 return sendAs
155}
156
157// GetPOP3Server returns the POP3 server address for the account.
158func (a *Account) GetPOP3Server() string {
159 if a.POP3Server != "" {
160 return a.POP3Server
161 }
162 return ""
163}
164
165// GetPOP3Port returns the POP3 port for the account.
166func (a *Account) GetPOP3Port() int {
167 if a.POP3Port != 0 {
168 return a.POP3Port
169 }
170 return 995 // Default POP3 SSL port
171}
172
173// GetConfigDir returns the path to the configuration directory (exported).
174func GetConfigDir() (string, error) {
175 return configDir()
176}
177
178// configDir returns the path to the configuration directory (internal).
179func configDir() (string, error) {
180 home, err := os.UserHomeDir()
181 if err != nil {
182 return "", err
183 }
184 return filepath.Join(home, ".config", "matcha"), nil
185}
186
187// GetCacheDir returns the path to the cache directory (exported).
188func GetCacheDir() (string, error) {
189 return cacheDir()
190}
191
192// cacheDir returns the path to the cache directory (internal).
193func cacheDir() (string, error) {
194 home, err := os.UserHomeDir()
195 if err != nil {
196 return "", err
197 }
198 return filepath.Join(home, ".cache", "matcha"), nil
199}
200
201// MigrateCacheFiles moves cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed.
202// This is a one-time migration for existing installations.
203func MigrateCacheFiles() error {
204 src, err := configDir()
205 if err != nil {
206 return err
207 }
208 dst, err := cacheDir()
209 if err != nil {
210 return err
211 }
212 if err := os.MkdirAll(dst, 0700); err != nil {
213 return err
214 }
215
216 // Files to migrate
217 files := []string{"email_cache.json", "contacts.json", "drafts.json", "folder_cache.json"}
218 for _, f := range files {
219 oldPath := filepath.Join(src, f)
220 newPath := filepath.Join(dst, f)
221 if _, err := os.Stat(oldPath); err == nil {
222 // Only migrate if destination doesn't already exist
223 if _, err := os.Stat(newPath); err != nil {
224 if err := os.Rename(oldPath, newPath); err != nil {
225 return err
226 }
227 }
228 }
229 }
230
231 // Migrate folder_emails directory
232 oldDir := filepath.Join(src, "folder_emails")
233 newDir := filepath.Join(dst, "folder_emails")
234 if info, err := os.Stat(oldDir); err == nil && info.IsDir() {
235 if _, err := os.Stat(newDir); err != nil {
236 if err := os.Rename(oldDir, newDir); err != nil {
237 return err
238 }
239 }
240 }
241
242 return nil
243}
244
245// configFile returns the full path to the configuration file.
246func configFile() (string, error) {
247 dir, err := configDir()
248 if err != nil {
249 return "", err
250 }
251 return filepath.Join(dir, "config.json"), nil
252}
253
254// secureDiskAccount includes the Password field in JSON when secure mode is active.
255type secureDiskAccount struct {
256 ID string `json:"id"`
257 Name string `json:"name"`
258 Email string `json:"email"`
259 Password string `json:"password,omitempty"`
260 ServiceProvider string `json:"service_provider"`
261 FetchEmail string `json:"fetch_email,omitempty"`
262 SendAsEmail string `json:"send_as_email,omitempty"`
263 IMAPServer string `json:"imap_server,omitempty"`
264 IMAPPort int `json:"imap_port,omitempty"`
265 SMTPServer string `json:"smtp_server,omitempty"`
266 SMTPPort int `json:"smtp_port,omitempty"`
267 Insecure bool `json:"insecure,omitempty"`
268 SMIMECert string `json:"smime_cert,omitempty"`
269 SMIMEKey string `json:"smime_key,omitempty"`
270 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
271 PGPPublicKey string `json:"pgp_public_key,omitempty"`
272 PGPPrivateKey string `json:"pgp_private_key,omitempty"`
273 PGPKeySource string `json:"pgp_key_source,omitempty"`
274 PGPPIN string `json:"pgp_pin,omitempty"`
275 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
276 AuthMethod string `json:"auth_method,omitempty"`
277 Protocol string `json:"protocol,omitempty"`
278 JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
279 POP3Server string `json:"pop3_server,omitempty"`
280 POP3Port int `json:"pop3_port,omitempty"`
281}
282
283type secureDiskConfig struct {
284 Accounts []secureDiskAccount `json:"accounts"`
285 DisableImages bool `json:"disable_images,omitempty"`
286 HideTips bool `json:"hide_tips,omitempty"`
287 DisableNotifications bool `json:"disable_notifications,omitempty"`
288 Theme string `json:"theme,omitempty"`
289 MailingLists []MailingList `json:"mailing_lists,omitempty"`
290}
291
292// SaveConfig saves the given configuration to the config file and passwords to the keyring.
293func SaveConfig(config *Config) error {
294 secureMode := GetSessionKey() != nil
295
296 if !secureMode {
297 // Save passwords and PGP PINs to the OS keyring before writing the JSON file
298 for _, acc := range config.Accounts {
299 if acc.Password != "" {
300 _ = keyring.Set(keyringServiceName, acc.Email, acc.Password)
301 }
302 if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" {
303 _ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN)
304 }
305 }
306 }
307
308 path, err := configFile()
309 if err != nil {
310 return err
311 }
312 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
313 return err
314 }
315
316 var data []byte
317 if secureMode {
318 // In secure mode, include passwords in the JSON (they'll be encrypted on disk)
319 sdc := secureDiskConfig{
320 DisableImages: config.DisableImages,
321 HideTips: config.HideTips,
322 DisableNotifications: config.DisableNotifications,
323 Theme: config.Theme,
324 MailingLists: config.MailingLists,
325 }
326 for _, acc := range config.Accounts {
327 sdc.Accounts = append(sdc.Accounts, secureDiskAccount{
328 ID: acc.ID,
329 Name: acc.Name,
330 Email: acc.Email,
331 Password: acc.Password,
332 ServiceProvider: acc.ServiceProvider,
333 FetchEmail: acc.FetchEmail,
334 SendAsEmail: acc.SendAsEmail,
335 IMAPServer: acc.IMAPServer,
336 IMAPPort: acc.IMAPPort,
337 SMTPServer: acc.SMTPServer,
338 SMTPPort: acc.SMTPPort,
339 Insecure: acc.Insecure,
340 SMIMECert: acc.SMIMECert,
341 SMIMEKey: acc.SMIMEKey,
342 SMIMESignByDefault: acc.SMIMESignByDefault,
343 PGPPublicKey: acc.PGPPublicKey,
344 PGPPrivateKey: acc.PGPPrivateKey,
345 PGPKeySource: acc.PGPKeySource,
346 PGPPIN: acc.PGPPIN,
347 PGPSignByDefault: acc.PGPSignByDefault,
348 AuthMethod: acc.AuthMethod,
349 Protocol: acc.Protocol,
350 JMAPEndpoint: acc.JMAPEndpoint,
351 POP3Server: acc.POP3Server,
352 POP3Port: acc.POP3Port,
353 })
354 }
355 data, err = json.MarshalIndent(sdc, "", " ")
356 } else {
357 data, err = json.MarshalIndent(config, "", " ")
358 }
359 if err != nil {
360 return err
361 }
362 return SecureWriteFile(path, data, 0600)
363}
364
365// LoadConfig loads the configuration from the config file and passwords from the keyring.
366// It automatically migrates plain-text passwords to the OS keyring if they exist.
367func LoadConfig() (*Config, error) {
368 path, err := configFile()
369 if err != nil {
370 return nil, err
371 }
372 data, err := SecureReadFile(path)
373 if err != nil {
374 return nil, err
375 }
376
377 secureMode := GetSessionKey() != nil
378
379 var config Config
380 var needsMigration bool
381
382 type rawAccount struct {
383 ID string `json:"id"`
384 Name string `json:"name"`
385 Email string `json:"email"`
386 Password string `json:"password,omitempty"`
387 ServiceProvider string `json:"service_provider"`
388 FetchEmail string `json:"fetch_email,omitempty"`
389 SendAsEmail string `json:"send_as_email,omitempty"`
390 IMAPServer string `json:"imap_server,omitempty"`
391 IMAPPort int `json:"imap_port,omitempty"`
392 SMTPServer string `json:"smtp_server,omitempty"`
393 SMTPPort int `json:"smtp_port,omitempty"`
394 Insecure bool `json:"insecure,omitempty"`
395 SMIMECert string `json:"smime_cert,omitempty"`
396 SMIMEKey string `json:"smime_key,omitempty"`
397 SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"`
398 PGPPublicKey string `json:"pgp_public_key,omitempty"`
399 PGPPrivateKey string `json:"pgp_private_key,omitempty"`
400 PGPKeySource string `json:"pgp_key_source,omitempty"`
401 PGPPIN string `json:"pgp_pin,omitempty"`
402 PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"`
403 AuthMethod string `json:"auth_method,omitempty"`
404 Protocol string `json:"protocol,omitempty"`
405 JMAPEndpoint string `json:"jmap_endpoint,omitempty"`
406 POP3Server string `json:"pop3_server,omitempty"`
407 POP3Port int `json:"pop3_port,omitempty"`
408 }
409 type diskConfig struct {
410 Accounts []rawAccount `json:"accounts"`
411 DisableImages bool `json:"disable_images,omitempty"`
412 HideTips bool `json:"hide_tips,omitempty"`
413 DisableNotifications bool `json:"disable_notifications,omitempty"`
414 Theme string `json:"theme,omitempty"`
415 MailingLists []MailingList `json:"mailing_lists,omitempty"`
416 }
417
418 var raw diskConfig
419 if err := json.Unmarshal(data, &raw); err != nil {
420 var legacyConfig legacyConfigFormat
421 if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" {
422 config = Config{
423 Accounts: []Account{
424 {
425 ID: uuid.New().String(),
426 Name: legacyConfig.Name,
427 Email: legacyConfig.Email,
428 Password: legacyConfig.Password,
429 ServiceProvider: legacyConfig.ServiceProvider,
430 FetchEmail: legacyConfig.Email,
431 },
432 },
433 }
434 // SaveConfig automatically pushes the password to the keyring and strips it from JSON
435 if saveErr := SaveConfig(&config); saveErr != nil {
436 return nil, saveErr
437 }
438 return &config, nil
439 }
440 return nil, err
441 }
442
443 config.DisableImages = raw.DisableImages
444 config.HideTips = raw.HideTips
445 config.DisableNotifications = raw.DisableNotifications
446 config.Theme = raw.Theme
447 config.MailingLists = raw.MailingLists
448 for _, rawAcc := range raw.Accounts {
449 acc := Account{
450 ID: rawAcc.ID,
451 Name: rawAcc.Name,
452 Email: rawAcc.Email,
453 ServiceProvider: rawAcc.ServiceProvider,
454 FetchEmail: rawAcc.FetchEmail,
455 SendAsEmail: rawAcc.SendAsEmail,
456 IMAPServer: rawAcc.IMAPServer,
457 IMAPPort: rawAcc.IMAPPort,
458 SMTPServer: rawAcc.SMTPServer,
459 SMTPPort: rawAcc.SMTPPort,
460 Insecure: rawAcc.Insecure,
461 SMIMECert: rawAcc.SMIMECert,
462 SMIMEKey: rawAcc.SMIMEKey,
463 SMIMESignByDefault: rawAcc.SMIMESignByDefault,
464 PGPPublicKey: rawAcc.PGPPublicKey,
465 PGPPrivateKey: rawAcc.PGPPrivateKey,
466 PGPKeySource: rawAcc.PGPKeySource,
467 PGPSignByDefault: rawAcc.PGPSignByDefault,
468 AuthMethod: rawAcc.AuthMethod,
469 Protocol: rawAcc.Protocol,
470 JMAPEndpoint: rawAcc.JMAPEndpoint,
471 POP3Server: rawAcc.POP3Server,
472 POP3Port: rawAcc.POP3Port,
473 }
474
475 if secureMode {
476 // In secure mode, passwords and PINs are stored in the encrypted config JSON
477 acc.Password = rawAcc.Password
478 acc.PGPPIN = rawAcc.PGPPIN
479 } else if rawAcc.Password != "" {
480 // Found a plain-text password! Move it to the OS Keyring.
481 _ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password)
482 acc.Password = rawAcc.Password
483 needsMigration = true
484 } else {
485 // No plaintext password in JSON, fetch from Keyring as normal.
486 if pwd, err := keyring.Get(keyringServiceName, acc.Email); err == nil {
487 acc.Password = pwd
488 }
489 }
490
491 if !secureMode {
492 // Load YubiKey PIN from keyring if using YubiKey
493 if acc.PGPKeySource == "yubikey" {
494 if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil {
495 acc.PGPPIN = pin
496 }
497 }
498 }
499
500 config.Accounts = append(config.Accounts, acc)
501 }
502
503 if needsMigration {
504 if saveErr := SaveConfig(&config); saveErr != nil {
505 return nil, saveErr
506 }
507 }
508
509 return &config, nil
510}
511
512// legacyConfigFormat represents the old single-account configuration format.
513type legacyConfigFormat struct {
514 ServiceProvider string `json:"service_provider"`
515 Email string `json:"email"`
516 Password string `json:"password"`
517 Name string `json:"name"`
518}
519
520// AddAccount adds a new account to the configuration.
521func (c *Config) AddAccount(account Account) {
522 if account.ID == "" {
523 account.ID = uuid.New().String()
524 }
525 // Ensure FetchEmail defaults to the login Email if not explicitly set.
526 if account.FetchEmail == "" && account.Email != "" {
527 account.FetchEmail = account.Email
528 }
529 c.Accounts = append(c.Accounts, account)
530}
531
532// RemoveAccount removes an account by its ID and deletes its password from the keyring.
533func (c *Config) RemoveAccount(id string) bool {
534 for i, acc := range c.Accounts {
535 if acc.ID == id {
536 // Delete password from OS Keyring when account is removed
537 _ = keyring.Delete(keyringServiceName, acc.Email)
538 // Delete PGP PIN from OS Keyring if present
539 _ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin")
540
541 c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
542 return true
543 }
544 }
545 return false
546}
547
548// GetAccountByID returns an account by its ID.
549func (c *Config) GetAccountByID(id string) *Account {
550 for i := range c.Accounts {
551 if c.Accounts[i].ID == id {
552 return &c.Accounts[i]
553 }
554 }
555 return nil
556}
557
558// GetAccountByEmail returns an account by its email address.
559func (c *Config) GetAccountByEmail(email string) *Account {
560 for i := range c.Accounts {
561 if c.Accounts[i].Email == email {
562 return &c.Accounts[i]
563 }
564 }
565 return nil
566}
567
568// HasAccounts returns true if there are any configured accounts.
569func (c *Config) HasAccounts() bool {
570 return len(c.Accounts) > 0
571}
572
573// GetFirstAccount returns the first account or nil if none exist.
574func (c *Config) GetFirstAccount() *Account {
575 if len(c.Accounts) > 0 {
576 return &c.Accounts[0]
577 }
578 return nil
579}
580
581// EnsurePGPDir creates the PGP keys directory if it doesn't exist.
582func EnsurePGPDir() error {
583 dir, err := configDir()
584 if err != nil {
585 return err
586 }
587 pgpDir := filepath.Join(dir, "pgp")
588 return os.MkdirAll(pgpDir, 0700)
589}