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}