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