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