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