@@ -6,14 +6,17 @@ import (
66 "path/filepath"
77
88 "github.com/google/uuid"
9+ "github.com/zalando/go-keyring"
910)
1011
12+ const keyringServiceName = "matcha-email-client"
13+
1114// Account stores the configuration for a single email account.
1215type Account struct {
1316 ID string `json:"id"`
1417 Name string `json:"name"`
1518 Email string `json:"email"`
16- Password string `json:"password"`
19+ Password string `json:"-"` // "-" prevents the password from being saved to config.json
1720 ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom"
1821 // FetchEmail is the single email address for which messages should be fetched.
1922 // If empty, it will default to `Email` when accounts are added.
@@ -108,8 +111,17 @@ func configFile() (string, error) {
108111 return filepath .Join (dir , "config.json" ), nil
109112}
110113
111- // SaveConfig saves the given configuration to the config file.
114+ // SaveConfig saves the given configuration to the config file and passwords to the keyring .
112115func 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+
113125 path , err := configFile ()
114126 if err != nil {
115127 return err
@@ -124,7 +136,8 @@ func SaveConfig(config *Config) error {
124136 return os .WriteFile (path , data , 0600 )
125137}
126138
127- // LoadConfig loads the configuration from the config file.
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.
128141func LoadConfig () (* Config , error ) {
129142 path , err := configFile ()
130143 if err != nil {
@@ -134,12 +147,31 @@ func LoadConfig() (*Config, error) {
134147 if err != nil {
135148 return nil , err
136149 }
150+
137151 var config Config
138- if err := json .Unmarshal (data , & config ); err != nil {
139- // Try to load legacy single-account 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 {
140173 var legacyConfig legacyConfigFormat
141174 if legacyErr := json .Unmarshal (data , & legacyConfig ); legacyErr == nil && legacyConfig .Email != "" {
142- // Convert legacy config to new format
143175 config = Config {
144176 Accounts : []Account {
145177 {
@@ -148,19 +180,54 @@ func LoadConfig() (*Config, error) {
148180 Email : legacyConfig .Email ,
149181 Password : legacyConfig .Password ,
150182 ServiceProvider : legacyConfig .ServiceProvider ,
151- // Default FetchEmail to the legacy Email value
152- FetchEmail : legacyConfig .Email ,
183+ FetchEmail : legacyConfig .Email ,
153184 },
154185 },
155186 }
156- // Save the migrated config
187+ // SaveConfig automatically pushes the password to the keyring and strips it from JSON
157188 if saveErr := SaveConfig (& config ); saveErr != nil {
158189 return nil , saveErr
159190 }
160191 return & config , nil
161192 }
162193 return nil , err
163194 }
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+
164231 return & config , nil
165232}
166233
@@ -184,10 +251,13 @@ func (c *Config) AddAccount(account Account) {
184251 c .Accounts = append (c .Accounts , account )
185252}
186253
187- // RemoveAccount removes an account by its ID.
254+ // RemoveAccount removes an account by its ID and deletes its password from the keyring .
188255func (c * Config ) RemoveAccount (id string ) bool {
189256 for i , acc := range c .Accounts {
190257 if acc .ID == id {
258+ // Delete password from OS Keyring when account is removed
259+ _ = keyring .Delete (keyringServiceName , acc .Email )
260+
191261 c .Accounts = append (c .Accounts [:i ], c .Accounts [i + 1 :]... )
192262 return true
193263 }
0 commit comments