1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
8 "os"
9 "time"
10
11 "github.com/floatpane/matcha/daemonrpc"
12)
13
14// Per-handler timeouts. fetchTimeout covers reads against the upstream IMAP
15// provider, which can return large bodies and so are given more headroom.
16// mutateTimeout covers state-changing operations and folder listings, which
17// are bounded by IMAP command latency rather than payload size.
18const (
19 fetchTimeout = 60 * time.Second
20 mutateTimeout = 30 * time.Second
21)
22
23func (d *Daemon) handleRequest(conn *daemonrpc.Conn, req *daemonrpc.Request) {
24 switch req.Method {
25 case daemonrpc.MethodPing:
26 d.handlePing(conn, req)
27 case daemonrpc.MethodGetStatus:
28 d.handleGetStatus(conn, req)
29 case daemonrpc.MethodGetAccounts:
30 d.handleGetAccounts(conn, req)
31 case daemonrpc.MethodReloadConfig:
32 d.handleReloadConfig(conn, req)
33 case daemonrpc.MethodFetchEmails:
34 d.handleFetchEmails(conn, req)
35 case daemonrpc.MethodFetchEmailBody:
36 d.handleFetchEmailBody(conn, req)
37 case daemonrpc.MethodDeleteEmails:
38 d.handleDeleteEmails(conn, req)
39 case daemonrpc.MethodArchiveEmails:
40 d.handleArchiveEmails(conn, req)
41 case daemonrpc.MethodMoveEmails:
42 d.handleMoveEmails(conn, req)
43 case daemonrpc.MethodMarkRead:
44 d.handleMarkRead(conn, req)
45 case daemonrpc.MethodFetchFolders:
46 d.handleFetchFolders(conn, req)
47 case daemonrpc.MethodRefreshFolder:
48 d.handleRefreshFolder(conn, req)
49 case daemonrpc.MethodSubscribe:
50 d.handleSubscribe(conn, req)
51 case daemonrpc.MethodUnsubscribe:
52 d.handleUnsubscribe(conn, req)
53 default:
54 conn.SendError(req.ID, daemonrpc.ErrCodeNotFound, fmt.Sprintf("unknown method: %s", req.Method))
55 }
56}
57
58func decodeParams[T any](req *daemonrpc.Request) (T, error) {
59 var params T
60 if req.Params != nil {
61 if err := json.Unmarshal(req.Params, ¶ms); err != nil {
62 return params, err
63 }
64 }
65 return params, nil
66}
67
68func (d *Daemon) handlePing(conn *daemonrpc.Conn, req *daemonrpc.Request) {
69 conn.SendResponse(req.ID, daemonrpc.PingResult{Pong: true})
70}
71
72func (d *Daemon) handleGetStatus(conn *daemonrpc.Conn, req *daemonrpc.Request) {
73 d.mu.RLock()
74 var accounts []string
75 for _, acct := range d.config.Accounts {
76 accounts = append(accounts, acct.Email)
77 }
78 d.mu.RUnlock()
79
80 conn.SendResponse(req.ID, daemonrpc.StatusResult{
81 Running: true,
82 Uptime: int64(time.Since(d.startTime).Seconds()),
83 Accounts: accounts,
84 PID: os.Getpid(),
85 })
86}
87
88func (d *Daemon) handleGetAccounts(conn *daemonrpc.Conn, req *daemonrpc.Request) {
89 d.mu.RLock()
90 defer d.mu.RUnlock()
91
92 var infos []daemonrpc.AccountInfo
93 for _, acct := range d.config.Accounts {
94 protocol := acct.Protocol
95 if protocol == "" {
96 protocol = "imap"
97 }
98 infos = append(infos, daemonrpc.AccountInfo{
99 ID: acct.ID,
100 Name: acct.Name,
101 Email: acct.Email,
102 Protocol: protocol,
103 })
104 }
105 conn.SendResponse(req.ID, infos)
106}
107
108func (d *Daemon) handleReloadConfig(conn *daemonrpc.Conn, req *daemonrpc.Request) {
109 if err := d.ReloadConfig(); err != nil {
110 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
111 return
112 }
113 conn.SendResponse(req.ID, true)
114}
115
116func (d *Daemon) handleFetchEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) {
117 params, err := decodeParams[daemonrpc.FetchEmailsParams](req)
118 if err != nil {
119 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
120 return
121 }
122
123 p, err := d.getProvider(params.AccountID)
124 if err != nil {
125 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
126 return
127 }
128
129 ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
130 defer cancel()
131
132 emails, err := p.FetchEmails(ctx, params.Folder, params.Limit, params.Offset)
133 if err != nil {
134 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
135 return
136 }
137
138 conn.SendResponse(req.ID, emails)
139}
140
141func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Request) {
142 params, err := decodeParams[daemonrpc.FetchEmailBodyParams](req)
143 if err != nil {
144 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
145 return
146 }
147
148 p, err := d.getProvider(params.AccountID)
149 if err != nil {
150 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
151 return
152 }
153
154 ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
155 defer cancel()
156
157 body, attachments, err := p.FetchEmailBody(ctx, params.Folder, params.UID)
158 if err != nil {
159 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
160 return
161 }
162
163 // Convert backend.Attachment to daemonrpc.AttachmentInfo for wire transfer.
164 var attInfos []daemonrpc.AttachmentInfo
165 for _, att := range attachments {
166 attInfos = append(attInfos, daemonrpc.AttachmentInfo{
167 Filename: att.Filename,
168 PartID: att.PartID,
169 Encoding: att.Encoding,
170 MIMEType: att.MIMEType,
171 })
172 }
173
174 conn.SendResponse(req.ID, daemonrpc.FetchEmailBodyResult{
175 Body: body,
176 Attachments: attInfos,
177 })
178}
179
180func (d *Daemon) handleDeleteEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) {
181 params, err := decodeParams[daemonrpc.DeleteEmailsParams](req)
182 if err != nil {
183 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
184 return
185 }
186
187 p, err := d.getProvider(params.AccountID)
188 if err != nil {
189 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
190 return
191 }
192
193 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
194 defer cancel()
195
196 if err := p.DeleteEmails(ctx, params.Folder, params.UIDs); err != nil {
197 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
198 return
199 }
200 conn.SendResponse(req.ID, true)
201}
202
203func (d *Daemon) handleArchiveEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) {
204 params, err := decodeParams[daemonrpc.ArchiveEmailsParams](req)
205 if err != nil {
206 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
207 return
208 }
209
210 p, err := d.getProvider(params.AccountID)
211 if err != nil {
212 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
213 return
214 }
215
216 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
217 defer cancel()
218
219 if err := p.ArchiveEmails(ctx, params.Folder, params.UIDs); err != nil {
220 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
221 return
222 }
223 conn.SendResponse(req.ID, true)
224}
225
226func (d *Daemon) handleMoveEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) {
227 params, err := decodeParams[daemonrpc.MoveEmailsParams](req)
228 if err != nil {
229 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
230 return
231 }
232
233 p, err := d.getProvider(params.AccountID)
234 if err != nil {
235 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
236 return
237 }
238
239 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
240 defer cancel()
241
242 if err := p.MoveEmails(ctx, params.UIDs, params.SourceFolder, params.DestFolder); err != nil {
243 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
244 return
245 }
246 conn.SendResponse(req.ID, true)
247}
248
249func (d *Daemon) handleMarkRead(conn *daemonrpc.Conn, req *daemonrpc.Request) {
250 params, err := decodeParams[daemonrpc.MarkReadParams](req)
251 if err != nil {
252 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
253 return
254 }
255
256 p, err := d.getProvider(params.AccountID)
257 if err != nil {
258 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
259 return
260 }
261
262 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
263 defer cancel()
264
265 // MarkAsRead only supports one UID at a time in the Provider interface.
266 for _, uid := range params.UIDs {
267 if err := p.MarkAsRead(ctx, params.Folder, uid); err != nil {
268 log.Printf("daemon: mark read %d failed: %v", uid, err)
269 }
270 }
271 conn.SendResponse(req.ID, true)
272}
273
274func (d *Daemon) handleFetchFolders(conn *daemonrpc.Conn, req *daemonrpc.Request) {
275 params, err := decodeParams[daemonrpc.FetchFoldersParams](req)
276 if err != nil {
277 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
278 return
279 }
280
281 p, err := d.getProvider(params.AccountID)
282 if err != nil {
283 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
284 return
285 }
286
287 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
288 defer cancel()
289
290 folders, err := p.FetchFolders(ctx)
291 if err != nil {
292 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
293 return
294 }
295 conn.SendResponse(req.ID, folders)
296}
297
298func (d *Daemon) handleRefreshFolder(conn *daemonrpc.Conn, req *daemonrpc.Request) {
299 params, err := decodeParams[daemonrpc.RefreshFolderParams](req)
300 if err != nil {
301 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
302 return
303 }
304
305 // Async: fetch in background, push events when done.
306 go func() {
307 p, err := d.getProvider(params.AccountID)
308 if err != nil {
309 log.Printf("daemon: refresh provider error: %v", err)
310 return
311 }
312
313 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent{
314 AccountID: params.AccountID,
315 Folder: params.Folder,
316 })
317
318 ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
319 defer cancel()
320
321 emails, err := p.FetchEmails(ctx, params.Folder, 50, 0)
322 if err != nil {
323 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{
324 AccountID: params.AccountID,
325 Folder: params.Folder,
326 Error: err.Error(),
327 })
328 return
329 }
330
331 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncComplete, daemonrpc.SyncCompleteEvent{
332 AccountID: params.AccountID,
333 Folder: params.Folder,
334 EmailCount: len(emails),
335 })
336 }()
337
338 conn.SendResponse(req.ID, true)
339}
340
341func (d *Daemon) handleSubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
342 params, err := decodeParams[daemonrpc.SubscribeParams](req)
343 if err != nil {
344 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
345 return
346 }
347
348 key := params.AccountID + ":" + params.Folder
349
350 d.subMu.Lock()
351 if d.subscriptions[conn] == nil {
352 d.subscriptions[conn] = make(map[string]struct{})
353 }
354 d.subscriptions[conn][key] = struct{}{}
355 d.subMu.Unlock()
356
357 log.Printf("daemon: client subscribed to %s", key)
358 conn.SendResponse(req.ID, true)
359}
360
361func (d *Daemon) handleUnsubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
362 params, err := decodeParams[daemonrpc.UnsubscribeParams](req)
363 if err != nil {
364 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
365 return
366 }
367
368 key := params.AccountID + ":" + params.Folder
369
370 d.subMu.Lock()
371 if subs, ok := d.subscriptions[conn]; ok {
372 delete(subs, key)
373 }
374 d.subMu.Unlock()
375
376 conn.SendResponse(req.ID, true)
377}