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		<-existing.done
 51		delete(w.watchers, account.ID)
 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// StopAll stops all IDLE watchers.
 66func (w *IdleWatcher) StopAll() {
 67	w.mu.Lock()
 68	defer w.mu.Unlock()
 69
 70	for id, a := range w.watchers {
 71		close(a.stop)
 72		<-a.done
 73		delete(w.watchers, id)
 74	}
 75}
 76
 77func (a *accountIdle) run() {
 78	defer close(a.done)
 79
 80	initialBackoff := 5 * time.Second
 81	maxBackoff := 2 * time.Minute
 82	backoff := initialBackoff
 83
 84	for {
 85		start := time.Now()
 86		err := a.idleOnce()
 87		if err == nil {
 88			// Clean exit (stop was closed)
 89			return
 90		}
 91
 92		// Reset backoff if we had a successful IDLE session (ran for
 93		// longer than the current backoff period without error).
 94		if time.Since(start) > backoff {
 95			backoff = initialBackoff
 96		}
 97
 98		// Check if we were told to stop
 99		select {
100		case <-a.stop:
101			return
102		default:
103		}
104
105		log.Printf("IDLE error for account %s: %v (reconnecting in %v)", a.account.ID, err, backoff)
106
107		// Wait with backoff before reconnecting
108		select {
109		case <-a.stop:
110			return
111		case <-time.After(backoff):
112		}
113
114		backoff *= 2
115		if backoff > maxBackoff {
116			backoff = maxBackoff
117		}
118	}
119}
120
121// idleOnce connects, selects the mailbox, and runs IDLE until an error or stop.
122// Returns nil if stopped cleanly.
123func (a *accountIdle) idleOnce() error {
124	c, err := connect(a.account)
125	if err != nil {
126		return err
127	}
128	defer func() {
129		_ = c.Logout()
130	}()
131
132	// Select the mailbox in read-only mode
133	mbox, err := c.Select(a.folder, true)
134	if err != nil {
135		return err
136	}
137	prevExists := mbox.Messages
138
139	// Set up update channel
140	updates := make(chan client.Update, 32)
141	c.Updates = updates
142
143	// Run IDLE in a goroutine
144	idleDone := make(chan error, 1)
145	idleStop := make(chan struct{})
146	go func() {
147		idleDone <- c.Idle(idleStop, nil)
148	}()
149
150	for {
151		select {
152		case <-a.stop:
153			close(idleStop)
154			<-idleDone
155			return nil
156
157		case update := <-updates:
158			switch u := update.(type) {
159			case *client.MailboxUpdate:
160				newExists := u.Mailbox.Messages
161				if newExists > prevExists {
162					// New mail arrived
163					select {
164					case a.notify <- IdleUpdate{
165						AccountID:  a.account.ID,
166						FolderName: a.folder,
167					}:
168					case <-a.stop:
169						close(idleStop)
170						<-idleDone
171						return nil
172					}
173				}
174				prevExists = newExists
175			}
176
177		case err := <-idleDone:
178			return err
179		}
180	}
181}