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