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, mimeType, 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 BodyMIMEType: mimeType,
177 Attachments: attInfos,
178 })
179}
180
181func (d *Daemon) handleDeleteEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) {
182 params, err := decodeParams[daemonrpc.DeleteEmailsParams](req)
183 if err != nil {
184 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
185 return
186 }
187
188 p, err := d.getProvider(params.AccountID)
189 if err != nil {
190 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
191 return
192 }
193
194 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
195 defer cancel()
196
197 if err := p.DeleteEmails(ctx, params.Folder, params.UIDs); err != nil {
198 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
199 return
200 }
201 conn.SendResponse(req.ID, true)
202}
203
204func (d *Daemon) handleArchiveEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) {
205 params, err := decodeParams[daemonrpc.ArchiveEmailsParams](req)
206 if err != nil {
207 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
208 return
209 }
210
211 p, err := d.getProvider(params.AccountID)
212 if err != nil {
213 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
214 return
215 }
216
217 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
218 defer cancel()
219
220 if err := p.ArchiveEmails(ctx, params.Folder, params.UIDs); err != nil {
221 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
222 return
223 }
224 conn.SendResponse(req.ID, true)
225}
226
227func (d *Daemon) handleMoveEmails(conn *daemonrpc.Conn, req *daemonrpc.Request) {
228 params, err := decodeParams[daemonrpc.MoveEmailsParams](req)
229 if err != nil {
230 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
231 return
232 }
233
234 p, err := d.getProvider(params.AccountID)
235 if err != nil {
236 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
237 return
238 }
239
240 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
241 defer cancel()
242
243 if err := p.MoveEmails(ctx, params.UIDs, params.SourceFolder, params.DestFolder); err != nil {
244 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
245 return
246 }
247 conn.SendResponse(req.ID, true)
248}
249
250func (d *Daemon) handleMarkRead(conn *daemonrpc.Conn, req *daemonrpc.Request) {
251 params, err := decodeParams[daemonrpc.MarkReadParams](req)
252 if err != nil {
253 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
254 return
255 }
256
257 p, err := d.getProvider(params.AccountID)
258 if err != nil {
259 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
260 return
261 }
262
263 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
264 defer cancel()
265
266 // MarkAsRead only supports one UID at a time in the Provider interface.
267 for _, uid := range params.UIDs {
268 if err := p.MarkAsRead(ctx, params.Folder, uid); err != nil {
269 log.Printf("daemon: mark read %d failed: %v", uid, err)
270 }
271 }
272 conn.SendResponse(req.ID, true)
273}
274
275func (d *Daemon) handleFetchFolders(conn *daemonrpc.Conn, req *daemonrpc.Request) {
276 params, err := decodeParams[daemonrpc.FetchFoldersParams](req)
277 if err != nil {
278 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
279 return
280 }
281
282 p, err := d.getProvider(params.AccountID)
283 if err != nil {
284 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
285 return
286 }
287
288 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
289 defer cancel()
290
291 folders, err := p.FetchFolders(ctx)
292 if err != nil {
293 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
294 return
295 }
296 conn.SendResponse(req.ID, folders)
297}
298
299func (d *Daemon) handleRefreshFolder(conn *daemonrpc.Conn, req *daemonrpc.Request) {
300 params, err := decodeParams[daemonrpc.RefreshFolderParams](req)
301 if err != nil {
302 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
303 return
304 }
305
306 // Async: fetch in background, push events when done.
307 go func() {
308 p, err := d.getProvider(params.AccountID)
309 if err != nil {
310 log.Printf("daemon: refresh provider error: %v", err)
311 return
312 }
313
314 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent{
315 AccountID: params.AccountID,
316 Folder: params.Folder,
317 })
318
319 ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
320 defer cancel()
321
322 emails, err := p.FetchEmails(ctx, params.Folder, 50, 0)
323 if err != nil {
324 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{
325 AccountID: params.AccountID,
326 Folder: params.Folder,
327 Error: err.Error(),
328 })
329 return
330 }
331
332 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncComplete, daemonrpc.SyncCompleteEvent{
333 AccountID: params.AccountID,
334 Folder: params.Folder,
335 EmailCount: len(emails),
336 })
337 }()
338
339 conn.SendResponse(req.ID, true)
340}
341
342func (d *Daemon) handleSubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
343 params, err := decodeParams[daemonrpc.SubscribeParams](req)
344 if err != nil {
345 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
346 return
347 }
348
349 key := params.AccountID + ":" + params.Folder
350
351 d.subMu.Lock()
352 if d.subscriptions[conn] == nil {
353 d.subscriptions[conn] = make(map[string]struct{})
354 }
355 d.subscriptions[conn][key] = struct{}{}
356 d.subMu.Unlock()
357
358 log.Printf("daemon: client subscribed to %s", key)
359 conn.SendResponse(req.ID, true)
360}
361
362func (d *Daemon) handleUnsubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
363 params, err := decodeParams[daemonrpc.UnsubscribeParams](req)
364 if err != nil {
365 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
366 return
367 }
368
369 key := params.AccountID + ":" + params.Folder
370
371 d.subMu.Lock()
372 if subs, ok := d.subscriptions[conn]; ok {
373 delete(subs, key)
374 }
375 d.subMu.Unlock()
376
377 conn.SendResponse(req.ID, true)
378}