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 for _, uid := range params.UIDs {
267 var err error
268 if params.Read {
269 err = p.MarkAsRead(ctx, params.Folder, uid)
270 } else {
271 err = p.MarkAsUnread(ctx, params.Folder, uid)
272 }
273 if err != nil {
274 log.Printf("daemon: mark read=%v %d failed: %v", params.Read, uid, err)
275 }
276 }
277 conn.SendResponse(req.ID, true)
278}
279
280func (d *Daemon) handleFetchFolders(conn *daemonrpc.Conn, req *daemonrpc.Request) {
281 params, err := decodeParams[daemonrpc.FetchFoldersParams](req)
282 if err != nil {
283 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
284 return
285 }
286
287 p, err := d.getProvider(params.AccountID)
288 if err != nil {
289 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
290 return
291 }
292
293 ctx, cancel := context.WithTimeout(context.Background(), mutateTimeout)
294 defer cancel()
295
296 folders, err := p.FetchFolders(ctx)
297 if err != nil {
298 conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
299 return
300 }
301 conn.SendResponse(req.ID, folders)
302}
303
304func (d *Daemon) handleRefreshFolder(conn *daemonrpc.Conn, req *daemonrpc.Request) {
305 params, err := decodeParams[daemonrpc.RefreshFolderParams](req)
306 if err != nil {
307 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
308 return
309 }
310
311 // Async: fetch in background, push events when done.
312 go func() {
313 defer func() {
314 if r := recover(); r != nil {
315 log.Printf("daemon: refresh panic for account = %s folder = %s: %v", params.AccountID, params.Folder, r)
316 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{
317 AccountID: params.AccountID,
318 Folder: params.Folder,
319 Error: fmt.Sprintf("panic: %v", r),
320 })
321 }
322 }()
323
324 p, err := d.getProvider(params.AccountID)
325 if err != nil {
326 log.Printf("daemon: refresh provider error: %v", err)
327 return
328 }
329
330 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent{
331 AccountID: params.AccountID,
332 Folder: params.Folder,
333 })
334
335 ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
336 defer cancel()
337
338 emails, err := p.FetchEmails(ctx, params.Folder, 50, 0)
339 if err != nil {
340 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{
341 AccountID: params.AccountID,
342 Folder: params.Folder,
343 Error: err.Error(),
344 })
345 return
346 }
347
348 d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncComplete, daemonrpc.SyncCompleteEvent{
349 AccountID: params.AccountID,
350 Folder: params.Folder,
351 EmailCount: len(emails),
352 })
353 }()
354
355 conn.SendResponse(req.ID, true)
356}
357
358func (d *Daemon) handleSubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
359 params, err := decodeParams[daemonrpc.SubscribeParams](req)
360 if err != nil {
361 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
362 return
363 }
364
365 key := params.AccountID + ":" + params.Folder
366
367 d.subMu.Lock()
368 if d.subscriptions[conn] == nil {
369 d.subscriptions[conn] = make(map[string]struct{})
370 }
371 d.subscriptions[conn][key] = struct{}{}
372 d.subMu.Unlock()
373
374 log.Printf("daemon: client subscribed to %s", key)
375 conn.SendResponse(req.ID, true)
376}
377
378func (d *Daemon) handleUnsubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
379 params, err := decodeParams[daemonrpc.UnsubscribeParams](req)
380 if err != nil {
381 conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error())
382 return
383 }
384
385 key := params.AccountID + ":" + params.Folder
386
387 d.subMu.Lock()
388 if subs, ok := d.subscriptions[conn]; ok {
389 delete(subs, key)
390 }
391 d.subMu.Unlock()
392
393 conn.SendResponse(req.ID, true)
394}