Skip to content

Commit 5198d8f

Browse files
authored
fix(gmail): empty provider, login (#1703)
## What? Fixes several issues with Gmail ## Why? Fixes #1674 Signed-off-by: drew <me@andrinoff.com>
1 parent 87bc332 commit 5198d8f

8 files changed

Lines changed: 268 additions & 12 deletions

File tree

backend/pop3/pop3.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func (p *Provider) connect() (*pop3client.Conn, error) {
7878
return nil, fmt.Errorf("pop3 connect: %w", err)
7979
}
8080

81-
if err := conn.Auth(p.account.Email, p.account.Password); err != nil {
81+
if err := conn.Auth(p.account.Email, p.account.ResolvePassword()); err != nil {
8282
_ = conn.Quit()
8383
return nil, fmt.Errorf("pop3 auth: %w", err)
8484
}

config/config.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,53 @@ func (a *Account) GetPOP3Port() int {
340340
return 995 // Default POP3 SSL port
341341
}
342342

343+
// ResolvePassword returns the account password, re-resolving it on demand when
344+
// the value cached at config-load time is empty.
345+
//
346+
// Resolution at load time can fail for reasons that are temporary or
347+
// process-specific: a pass_cmd whose gpg-agent is not reachable yet (common for
348+
// the auto-started daemon, which has no controlling terminal), or a Secret
349+
// Service keyring that was not up when Matcha launched. Without a retry, a
350+
// single failed lookup leaves Password empty for the whole process lifetime and
351+
// every login fails with a server-side error such as Gmail's
352+
// "NO Empty username or password".
353+
//
354+
// Returns "" for OAuth2 accounts (they authenticate via XOAUTH2) and when no
355+
// source yields a password.
356+
func (a *Account) ResolvePassword() string {
357+
if a == nil {
358+
return ""
359+
}
360+
if a.Password != "" {
361+
return a.Password
362+
}
363+
if a.IsOAuth2() {
364+
return ""
365+
}
366+
367+
if a.PassCmd != "" {
368+
pwd, err := resolvePassCmd(a.PassCmd)
369+
if err != nil {
370+
log.Printf("matcha: pass_cmd for %s failed: %v", a.Email, err)
371+
return ""
372+
}
373+
return pwd
374+
}
375+
376+
// In secure mode the password lives in the encrypted config, never the
377+
// keyring, so there is nothing else to try.
378+
if GetSessionKey() != nil {
379+
return ""
380+
}
381+
382+
pwd, err := keyring.Get(keyringServiceName, a.Email)
383+
if err != nil {
384+
log.Printf("matcha: keyring lookup for %s failed: %v", a.Email, err)
385+
return ""
386+
}
387+
return pwd
388+
}
389+
343390
// GetConfigDir returns the path to the configuration directory (exported).
344391
func GetConfigDir() (string, error) {
345392
return configDir()

config/config_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,3 +678,50 @@ func TestPassCmd(t *testing.T) {
678678
t.Errorf("Password not resolved from pass_cmd: got %q", acc.Password)
679679
}
680680
}
681+
682+
// TestResolvePassword covers the lazy password retry from #1674: a pass_cmd or
683+
// keyring lookup that failed at load time (no gpg-agent, keyring daemon not up)
684+
// left Password empty and every subsequent login failed with the server's
685+
// "Empty username or password". ResolvePassword retries at connect time.
686+
func TestResolvePassword(t *testing.T) {
687+
keyring.MockInit()
688+
t.Setenv("HOME", t.TempDir())
689+
690+
t.Run("cached password wins", func(t *testing.T) {
691+
acc := &Account{Email: "a@example.com", Password: "cached", PassCmd: "echo fromcmd"}
692+
if got := acc.ResolvePassword(); got != "cached" {
693+
t.Errorf("ResolvePassword() = %q, want %q", got, "cached")
694+
}
695+
})
696+
697+
t.Run("re-runs pass_cmd when empty", func(t *testing.T) {
698+
acc := &Account{Email: "b@example.com", PassCmd: "echo fromcmd"}
699+
if got := acc.ResolvePassword(); got != "fromcmd" {
700+
t.Errorf("ResolvePassword() = %q, want %q", got, "fromcmd")
701+
}
702+
})
703+
704+
t.Run("failing pass_cmd yields empty", func(t *testing.T) {
705+
acc := &Account{Email: "c@example.com", PassCmd: "exit 1"}
706+
if got := acc.ResolvePassword(); got != "" {
707+
t.Errorf("ResolvePassword() = %q, want empty", got)
708+
}
709+
})
710+
711+
t.Run("falls back to keyring", func(t *testing.T) {
712+
if err := keyring.Set(keyringServiceName, "d@example.com", "fromkeyring"); err != nil {
713+
t.Fatalf("keyring.Set() failed: %v", err)
714+
}
715+
acc := &Account{Email: "d@example.com"}
716+
if got := acc.ResolvePassword(); got != "fromkeyring" {
717+
t.Errorf("ResolvePassword() = %q, want %q", got, "fromkeyring")
718+
}
719+
})
720+
721+
t.Run("oauth2 accounts resolve to empty", func(t *testing.T) {
722+
acc := &Account{Email: "e@example.com", AuthMethod: "oauth2", PassCmd: "echo fromcmd"}
723+
if got := acc.ResolvePassword(); got != "" {
724+
t.Errorf("ResolvePassword() = %q, want empty", got)
725+
}
726+
})
727+
}

daemon/daemon.go

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,19 @@ func (d *Daemon) ReloadConfig() error {
218218
}
219219
d.mu.Lock()
220220
d.config = cfg
221+
// Providers hold a pointer into the old Accounts slice, so keeping them
222+
// would pin stale credentials (e.g. a re-resolved pass_cmd password).
223+
// Drop them all and rebuild against the freshly loaded accounts.
224+
old := d.providers
225+
d.providers = make(map[string]backend.Provider, len(cfg.Accounts))
221226
d.mu.Unlock()
222227

228+
for id, p := range old {
229+
if err := p.Close(); err != nil {
230+
log.Printf("daemon: error closing provider %s: %v", id, err)
231+
}
232+
}
233+
223234
// Reinitialize providers for new/changed accounts.
224235
d.initProviders()
225236

@@ -275,14 +286,61 @@ func (d *Daemon) broadcastToSubscribers(accountID, folder, eventType string, dat
275286
})
276287
}
277288

278-
// getProvider returns the provider for the given account ID.
289+
// getProvider returns the provider for the given account ID, creating it on
290+
// demand when it is missing.
291+
//
292+
// The daemon outlives any client, so its in-memory config can predate accounts
293+
// that were added later; and a provider that failed to build at startup used to
294+
// stay missing forever. Both showed up as "no provider for account <id>" on
295+
// every delete/archive. Reload from disk and retry before giving up.
279296
func (d *Daemon) getProvider(accountID string) (backend.Provider, error) {
280297
d.mu.RLock()
281-
defer d.mu.RUnlock()
282298
p, ok := d.providers[accountID]
283-
if !ok {
284-
return nil, fmt.Errorf("no provider for account %s", accountID)
299+
known := d.config.GetAccountByID(accountID) != nil
300+
d.mu.RUnlock()
301+
if ok {
302+
return p, nil
303+
}
304+
305+
if !known {
306+
if err := d.ReloadConfig(); err != nil {
307+
return nil, fmt.Errorf("no provider for account %s (config reload failed: %w)", accountID, err)
308+
}
309+
// ReloadConfig rebuilds every provider, so the account is now covered
310+
// if it exists on disk at all.
311+
d.mu.RLock()
312+
p, ok = d.providers[accountID]
313+
d.mu.RUnlock()
314+
if ok {
315+
return p, nil
316+
}
317+
return nil, fmt.Errorf("no provider for account %s: account not found in config", accountID)
318+
}
319+
320+
return d.createProvider(accountID)
321+
}
322+
323+
// createProvider builds and stores a provider for a known account.
324+
func (d *Daemon) createProvider(accountID string) (backend.Provider, error) {
325+
d.mu.Lock()
326+
defer d.mu.Unlock()
327+
328+
// Another caller may have won the race while the lock was released.
329+
if p, ok := d.providers[accountID]; ok {
330+
return p, nil
331+
}
332+
333+
acct := d.config.GetAccountByID(accountID)
334+
if acct == nil {
335+
return nil, fmt.Errorf("no provider for account %s: account not found in config", accountID)
336+
}
337+
338+
p, err := backend.New(acct)
339+
if err != nil {
340+
return nil, fmt.Errorf("create provider for %s: %w", acct.Email, err)
285341
}
342+
d.providers[accountID] = p
343+
log.Printf("daemon: provider created on demand for %s (%s)", acct.Email, acct.Protocol)
286344
return p, nil
287345
}
288346

daemon/daemon_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010
"time"
1111

12+
_ "github.com/floatpane/matcha/backend/imap" // register imap backend for provider tests
1213
"github.com/floatpane/matcha/config"
1314
"github.com/floatpane/matcha/daemonrpc"
1415
)
@@ -206,3 +207,40 @@ func TestDaemon_BroadcastEvent(t *testing.T) {
206207
t.Errorf("type = %q, want NewMail", msg.Event.Type)
207208
}
208209
}
210+
211+
// TestDaemon_GetProviderCreatesOnDemand covers the "no provider for account"
212+
// regression (#1674): an account present in the config but missing from the
213+
// provider map must get a provider built on demand instead of failing every
214+
// delete/archive for the rest of the daemon's lifetime.
215+
func TestDaemon_GetProviderCreatesOnDemand(t *testing.T) {
216+
d := New(&config.Config{
217+
Accounts: []config.Account{{
218+
ID: "acc1",
219+
Email: "user@example.com",
220+
ServiceProvider: "gmail",
221+
Protocol: "imap",
222+
}},
223+
})
224+
225+
// Simulates a provider that failed to build when Run() called initProviders.
226+
if len(d.providers) != 0 {
227+
t.Fatalf("expected empty provider map, got %d", len(d.providers))
228+
}
229+
230+
p, err := d.getProvider("acc1")
231+
if err != nil {
232+
t.Fatalf("getProvider: %v", err)
233+
}
234+
if p == nil {
235+
t.Fatal("expected a provider")
236+
}
237+
238+
// Second call must reuse the cached provider.
239+
p2, err := d.getProvider("acc1")
240+
if err != nil {
241+
t.Fatalf("getProvider (cached): %v", err)
242+
}
243+
if p2 != p {
244+
t.Error("expected the cached provider to be reused")
245+
}
246+
}

daemonclient/service.go

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import (
66
"log"
77
"os"
88
"os/exec"
9+
"sync"
910
"time"
1011

1112
"github.com/floatpane/matcha/backend"
13+
_ "github.com/floatpane/matcha/backend/imap" // register imap backend for directService
1214
_ "github.com/floatpane/matcha/backend/jmap" // register jmap backend for directService
1315
_ "github.com/floatpane/matcha/backend/maildir" // register maildir backend for directService
16+
_ "github.com/floatpane/matcha/backend/pop3" // register pop3 backend for directService
1417
"github.com/floatpane/matcha/config"
1518
"github.com/floatpane/matcha/daemonrpc"
1619
"github.com/floatpane/matcha/fetcher"
@@ -264,6 +267,7 @@ type directService struct {
264267
cfg *config.Config
265268
providers map[string]backend.Provider
266269
events chan *daemonrpc.Event
270+
mu sync.RWMutex
267271
}
268272

269273
func newDirectService(cfg *config.Config) *directService {
@@ -277,6 +281,9 @@ func newDirectService(cfg *config.Config) *directService {
277281
}
278282

279283
func (s *directService) initProviders() {
284+
s.mu.Lock()
285+
defer s.mu.Unlock()
286+
280287
for i := range s.cfg.Accounts {
281288
acct := &s.cfg.Accounts[i]
282289
if _, ok := s.providers[acct.ID]; ok {
@@ -291,11 +298,47 @@ func (s *directService) initProviders() {
291298
}
292299
}
293300

301+
// getProvider returns the provider for an account, creating it on demand.
302+
// A provider missing from the map (account added after the service was built,
303+
// or a constructor that failed once at startup) used to fail every later
304+
// operation with "no provider for account <id>".
294305
func (s *directService) getProvider(accountID string) (backend.Provider, error) {
306+
s.mu.RLock()
295307
p, ok := s.providers[accountID]
296-
if !ok {
297-
return nil, &daemonrpc.Error{Code: daemonrpc.ErrCodeInternal, Message: "no provider for account " + accountID}
308+
acct := s.cfg.GetAccountByID(accountID)
309+
s.mu.RUnlock()
310+
if ok {
311+
return p, nil
312+
}
313+
314+
if acct == nil {
315+
// Config in memory may be stale — reload from disk before giving up.
316+
if err := s.ReloadConfig(); err != nil {
317+
return nil, &daemonrpc.Error{Code: daemonrpc.ErrCodeInternal, Message: "no provider for account " + accountID + ": " + err.Error()}
318+
}
319+
s.mu.RLock()
320+
p, ok = s.providers[accountID]
321+
s.mu.RUnlock()
322+
if ok {
323+
return p, nil
324+
}
325+
return nil, &daemonrpc.Error{Code: daemonrpc.ErrCodeInternal, Message: "no provider for account " + accountID + ": account not found in config"}
326+
}
327+
328+
s.mu.Lock()
329+
defer s.mu.Unlock()
330+
if p, ok := s.providers[accountID]; ok {
331+
return p, nil
298332
}
333+
acct = s.cfg.GetAccountByID(accountID)
334+
if acct == nil {
335+
return nil, &daemonrpc.Error{Code: daemonrpc.ErrCodeInternal, Message: "no provider for account " + accountID + ": account not found in config"}
336+
}
337+
p, err := backend.New(acct)
338+
if err != nil {
339+
return nil, &daemonrpc.Error{Code: daemonrpc.ErrCodeInternal, Message: "create provider for " + acct.Email + ": " + err.Error()}
340+
}
341+
s.providers[accountID] = p
299342
return p, nil
300343
}
301344

@@ -392,7 +435,19 @@ func (s *directService) ReloadConfig() error {
392435
if err != nil {
393436
return err
394437
}
438+
439+
// Providers point into the old Accounts slice; drop them so the reloaded
440+
// credentials (keyring / pass_cmd) actually take effect.
441+
s.mu.Lock()
395442
s.cfg = cfg
443+
old := s.providers
444+
s.providers = make(map[string]backend.Provider, len(cfg.Accounts))
445+
s.mu.Unlock()
446+
447+
for _, p := range old {
448+
p.Close() //nolint:errcheck,gosec
449+
}
450+
396451
s.initProviders()
397452
return nil
398453
}
@@ -404,6 +459,8 @@ func (s *directService) Events() <-chan *daemonrpc.Event {
404459
func (s *directService) IsDaemon() bool { return false }
405460

406461
func (s *directService) Close() error {
462+
s.mu.Lock()
463+
defer s.mu.Unlock()
407464
for _, p := range s.providers {
408465
p.Close() //nolint:errcheck,gosec
409466
}
@@ -412,7 +469,9 @@ func (s *directService) Close() error {
412469
}
413470

414471
func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, _ int) (string, error) {
472+
s.mu.RLock()
415473
acct := s.cfg.GetAccountByID(accountID)
474+
s.mu.RUnlock()
416475
if acct == nil {
417476
return "", fmt.Errorf("no account for %s", accountID)
418477
}

fetcher/fetcher.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,12 @@ func connectWithOptions(account *config.Account, extraOpts *imapclient.Options)
445445
return nil, fmt.Errorf("XOAUTH2 authentication failed: %w", err)
446446
}
447447
} else {
448-
if err := c.Login(account.Email, account.Password).Wait(); err != nil {
448+
password := account.ResolvePassword()
449+
if password == "" {
450+
c.Close() //nolint:errcheck,gosec
451+
return nil, fmt.Errorf("no password available for %s: keyring or pass_cmd returned nothing (see https://docs.matcha.email/Features/PassCmd)", account.Email)
452+
}
453+
if err := c.Login(account.Email, password).Wait(); err != nil {
449454
return nil, fmt.Errorf("authentication error: %w", err)
450455
}
451456
}

0 commit comments

Comments
 (0)