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