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