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}