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