idle.go

  1package fetcher
  2
  3import (
  4	"log"
  5	"sync"
  6	"time"
  7
  8	"github.com/emersion/go-imap/client"
  9	"github.com/floatpane/matcha/config"
 10)
 11
 12// IdleUpdate is sent when IDLE detects a mailbox change.
 13type IdleUpdate struct {
 14	AccountID  string
 15	FolderName string
 16}
 17
 18// IdleWatcher manages IDLE connections for multiple accounts.
 19type IdleWatcher struct {
 20	mu       sync.Mutex
 21	watchers map[string]*accountIdle // key: account ID
 22	notify   chan<- IdleUpdate
 23}
 24
 25// accountIdle manages a single IDLE connection for one account.
 26type accountIdle struct {
 27	account *config.Account
 28	folder  string
 29	notify  chan<- IdleUpdate
 30	stop    chan struct{}
 31	done    chan struct{}
 32}
 33
 34// NewIdleWatcher creates a new IDLE watcher. Updates are sent to the notify channel.
 35func NewIdleWatcher(notify chan<- IdleUpdate) *IdleWatcher {
 36	return &IdleWatcher{
 37		watchers: make(map[string]*accountIdle),
 38		notify:   notify,
 39	}
 40}
 41
 42// Watch starts (or restarts) an IDLE connection for the given account and folder.
 43func (w *IdleWatcher) Watch(account *config.Account, folder string) {
 44	w.mu.Lock()
 45	defer w.mu.Unlock()
 46
 47	// Stop existing watcher for this account if any
 48	if existing, ok := w.watchers[account.ID]; ok {
 49		close(existing.stop)
 50		delete(w.watchers, account.ID)
 51		// Let old connection tear down in the background
 52	}
 53
 54	a := &accountIdle{
 55		account: account,
 56		folder:  folder,
 57		notify:  w.notify,
 58		stop:    make(chan struct{}),
 59		done:    make(chan struct{}),
 60	}
 61	w.watchers[account.ID] = a
 62	go a.run()
 63}
 64
 65// Stop stops the IDLE watcher for a specific account.
 66func (w *IdleWatcher) Stop(accountID string) {
 67	w.mu.Lock()
 68	defer w.mu.Unlock()
 69
 70	if a, ok := w.watchers[accountID]; ok {
 71		close(a.stop)
 72		delete(w.watchers, accountID)
 73		// Let old connection tear down in the background
 74	}
 75}
 76
 77// StopAll stops all IDLE watchers.
 78func (w *IdleWatcher) StopAll() {
 79	w.mu.Lock()
 80	defer w.mu.Unlock()
 81
 82	for id, a := range w.watchers {
 83		close(a.stop)
 84		delete(w.watchers, id)
 85	}
 86}
 87
 88// StopAllAndWait stops all IDLE watchers and waits for them to finish.
 89func (w *IdleWatcher) StopAllAndWait() {
 90	w.mu.Lock()
 91	var pending []chan struct{}
 92	for id, a := range w.watchers {
 93		close(a.stop)
 94		pending = append(pending, a.done)
 95		delete(w.watchers, id)
 96	}
 97	w.mu.Unlock()
 98
 99	for _, done := range pending {
100		<-done
101	}
102}
103
104func (a *accountIdle) run() {
105	defer close(a.done)
106
107	initialBackoff := 5 * time.Second
108	maxBackoff := 2 * time.Minute
109	backoff := initialBackoff
110
111	for {
112		start := time.Now()
113		err := a.idleOnce()
114		if err == nil {
115			// Clean exit (stop was closed)
116			return
117		}
118
119		// Reset backoff if we had a successful IDLE session (ran for
120		// longer than the current backoff period without error).
121		if time.Since(start) > backoff {
122			backoff = initialBackoff
123		}
124
125		// Check if we were told to stop
126		select {
127		case <-a.stop:
128			return
129		default:
130		}
131
132		log.Printf("IDLE error for account %s: %v (reconnecting in %v)", a.account.ID, err, backoff)
133
134		// Wait with backoff before reconnecting
135		select {
136		case <-a.stop:
137			return
138		case <-time.After(backoff):
139		}
140
141		backoff *= 2
142		if backoff > maxBackoff {
143			backoff = maxBackoff
144		}
145	}
146}
147
148// idleOnce connects, selects the mailbox, and runs IDLE until an error or stop.
149// Returns nil if stopped cleanly.
150func (a *accountIdle) idleOnce() error {
151	c, err := connect(a.account)
152	if err != nil {
153		return err
154	}
155	defer func() {
156		_ = c.Logout()
157	}()
158
159	// Select the mailbox in read-only mode
160	mbox, err := c.Select(a.folder, true)
161	if err != nil {
162		return err
163	}
164	prevExists := mbox.Messages
165
166	// Set up update channel
167	updates := make(chan client.Update, 32)
168	c.Updates = updates
169
170	// Run IDLE in a goroutine
171	idleDone := make(chan error, 1)
172	idleStop := make(chan struct{})
173	go func() {
174		idleDone <- c.Idle(idleStop, nil)
175	}()
176
177	for {
178		select {
179		case <-a.stop:
180			close(idleStop)
181			<-idleDone
182			return nil
183
184		case update := <-updates:
185			switch u := update.(type) {
186			case *client.MailboxUpdate:
187				newExists := u.Mailbox.Messages
188				if newExists > prevExists {
189					// New mail arrived
190					select {
191					case a.notify <- IdleUpdate{
192						AccountID:  a.account.ID,
193						FolderName: a.folder,
194					}:
195					case <-a.stop:
196						close(idleStop)
197						<-idleDone
198						return nil
199					}
200				}
201				prevExists = newExists
202			}
203
204		case err := <-idleDone:
205			return err
206		}
207	}
208}