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