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