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
23// decodeParams unmarshals raw JSON params into T. A nil/empty payload yields
24// the zero value.
25func decodeParams[T any](params json.RawMessage) (T, error) {
26 var p T
27 if params != nil {
28 if err := json.Unmarshal(params, &p); err != nil {
29 return p, err
30 }
31 }
32 return p, nil
33}
34
35// parseError wraps a params-decoding failure with the parse error code so the
36// server forwards it verbatim instead of mapping to ErrCodeInternal.
37func parseError(err error) error {
38 return &daemonrpc.Error{Code: daemonrpc.ErrCodeParse, Message: err.Error()}
39}
40
41func (d *Daemon) handlePing(_ context.Context, _ *daemonrpc.Conn, _ json.RawMessage) (any, error) {
42 return daemonrpc.PingResult{Pong: true}, nil
43}
44
45func (d *Daemon) handleGetStatus(_ context.Context, _ *daemonrpc.Conn, _ json.RawMessage) (any, error) {
46 d.mu.RLock()
47 accounts := make([]string, 0, len(d.config.Accounts))
48 for _, acct := range d.config.Accounts {
49 accounts = append(accounts, acct.Email)
50 }
51 d.mu.RUnlock()
52
53 return daemonrpc.StatusResult{
54 Running: true,
55 Uptime: int64(time.Since(d.startTime).Seconds()),
56 Accounts: accounts,
57 PID: os.Getpid(),
58 }, nil
59}
60
61func (d *Daemon) handleGetAccounts(_ context.Context, _ *daemonrpc.Conn, _ json.RawMessage) (any, error) {
62 d.mu.RLock()
63 defer d.mu.RUnlock()
64
65 infos := make([]daemonrpc.AccountInfo, 0, len(d.config.Accounts))
66 for _, acct := range d.config.Accounts {
67 protocol := acct.Protocol
68 if protocol == "" {
69 protocol = "imap"
70 }
71 infos = append(infos, daemonrpc.AccountInfo{
72 ID: acct.ID,
73 Name: acct.Name,
74 Email: acct.Email,
75 Protocol: protocol,
76 })
77 }
78 return infos, nil
79}
80
81func (d *Daemon) handleReloadConfig(_ context.Context, _ *daemonrpc.Conn, _ json.RawMessage) (any, error) {
82 if err := d.ReloadConfig(); err != nil {
83 return nil, err
84 }
85 return true, nil
86}
87
88func (d *Daemon) handleFetchEmails(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
89 args, err := decodeParams[daemonrpc.FetchEmailsParams](params)
90 if err != nil {
91 return nil, parseError(err)
92 }
93
94 p, err := d.getProvider(args.AccountID)
95 if err != nil {
96 return nil, err
97 }
98
99 ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
100 defer cancel()
101
102 emails, err := p.FetchEmails(ctx, args.Folder, args.Limit, args.Offset)
103 if err != nil {
104 return nil, err
105 }
106 return emails, nil
107}
108
109func (d *Daemon) handleFetchEmailBody(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
110 args, err := decodeParams[daemonrpc.FetchEmailBodyParams](params)
111 if err != nil {
112 return nil, parseError(err)
113 }
114
115 p, err := d.getProvider(args.AccountID)
116 if err != nil {
117 return nil, err
118 }
119
120 ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
121 defer cancel()
122
123 body, mimeType, attachments, err := p.FetchEmailBody(ctx, args.Folder, args.UID)
124 if err != nil {
125 return nil, err
126 }
127
128 // Convert backend.Attachment to daemonrpc.AttachmentInfo for wire transfer.
129 var attInfos []daemonrpc.AttachmentInfo
130 for _, att := range attachments {
131 attInfos = append(attInfos, daemonrpc.AttachmentInfo{
132 Filename: att.Filename,
133 PartID: att.PartID,
134 Encoding: att.Encoding,
135 MIMEType: att.MIMEType,
136 })
137 }
138
139 return daemonrpc.FetchEmailBodyResult{
140 Body: body,
141 BodyMIMEType: mimeType,
142 Attachments: attInfos,
143 }, nil
144}
145
146func (d *Daemon) handleDeleteEmails(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
147 args, err := decodeParams[daemonrpc.DeleteEmailsParams](params)
148 if err != nil {
149 return nil, parseError(err)
150 }
151
152 p, err := d.getProvider(args.AccountID)
153 if err != nil {
154 return nil, err
155 }
156
157 ctx, cancel := context.WithTimeout(ctx, mutateTimeout)
158 defer cancel()
159
160 if err := p.DeleteEmails(ctx, args.Folder, args.UIDs); err != nil {
161 return nil, err
162 }
163 return true, nil
164}
165
166func (d *Daemon) handleArchiveEmails(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
167 args, err := decodeParams[daemonrpc.ArchiveEmailsParams](params)
168 if err != nil {
169 return nil, parseError(err)
170 }
171
172 p, err := d.getProvider(args.AccountID)
173 if err != nil {
174 return nil, err
175 }
176
177 ctx, cancel := context.WithTimeout(ctx, mutateTimeout)
178 defer cancel()
179
180 if err := p.ArchiveEmails(ctx, args.Folder, args.UIDs); err != nil {
181 return nil, err
182 }
183 return true, nil
184}
185
186func (d *Daemon) handleMoveEmails(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
187 args, err := decodeParams[daemonrpc.MoveEmailsParams](params)
188 if err != nil {
189 return nil, parseError(err)
190 }
191
192 p, err := d.getProvider(args.AccountID)
193 if err != nil {
194 return nil, err
195 }
196
197 ctx, cancel := context.WithTimeout(ctx, mutateTimeout)
198 defer cancel()
199
200 if err := p.MoveEmails(ctx, args.UIDs, args.SourceFolder, args.DestFolder); err != nil {
201 return nil, err
202 }
203 return true, nil
204}
205
206func (d *Daemon) handleMarkRead(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
207 args, err := decodeParams[daemonrpc.MarkReadParams](params)
208 if err != nil {
209 return nil, parseError(err)
210 }
211
212 p, err := d.getProvider(args.AccountID)
213 if err != nil {
214 return nil, err
215 }
216
217 ctx, cancel := context.WithTimeout(ctx, mutateTimeout)
218 defer cancel()
219
220 for _, uid := range args.UIDs {
221 var err error
222 if args.Read {
223 err = p.MarkAsRead(ctx, args.Folder, uid)
224 } else {
225 err = p.MarkAsUnread(ctx, args.Folder, uid)
226 }
227 if err != nil {
228 log.Printf("daemon: mark read=%v %d failed: %v", args.Read, uid, err)
229 }
230 }
231 return true, nil
232}
233
234func (d *Daemon) handleFetchFolders(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
235 args, err := decodeParams[daemonrpc.FetchFoldersParams](params)
236 if err != nil {
237 return nil, parseError(err)
238 }
239
240 p, err := d.getProvider(args.AccountID)
241 if err != nil {
242 return nil, err
243 }
244
245 ctx, cancel := context.WithTimeout(ctx, mutateTimeout)
246 defer cancel()
247
248 folders, err := p.FetchFolders(ctx)
249 if err != nil {
250 return nil, err
251 }
252 return folders, nil
253}
254
255func (d *Daemon) handleRefreshFolder(ctx context.Context, _ *daemonrpc.Conn, params json.RawMessage) (any, error) {
256 args, err := decodeParams[daemonrpc.RefreshFolderParams](params)
257 if err != nil {
258 return nil, parseError(err)
259 }
260
261 // Async: fetch in background, push events when done. The server-scoped ctx
262 // outlives the request and is canceled on daemon shutdown.
263 go func() {
264 defer func() {
265 if r := recover(); r != nil {
266 log.Printf("daemon: refresh panic for account = %s folder = %s: %v", args.AccountID, args.Folder, r)
267 d.broadcastToSubscribers(args.AccountID, args.Folder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{
268 AccountID: args.AccountID,
269 Folder: args.Folder,
270 Error: fmt.Sprintf("panic: %v", r),
271 })
272 }
273 }()
274
275 p, err := d.getProvider(args.AccountID)
276 if err != nil {
277 log.Printf("daemon: refresh provider error: %v", err)
278 return
279 }
280
281 d.broadcastToSubscribers(args.AccountID, args.Folder, daemonrpc.EventSyncStarted, daemonrpc.SyncStartedEvent(args))
282
283 fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
284 defer cancel()
285
286 emails, err := p.FetchEmails(fetchCtx, args.Folder, 50, 0)
287 if err != nil {
288 d.broadcastToSubscribers(args.AccountID, args.Folder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{
289 AccountID: args.AccountID,
290 Folder: args.Folder,
291 Error: err.Error(),
292 })
293 return
294 }
295
296 d.broadcastToSubscribers(args.AccountID, args.Folder, daemonrpc.EventSyncComplete, daemonrpc.SyncCompleteEvent{
297 AccountID: args.AccountID,
298 Folder: args.Folder,
299 EmailCount: len(emails),
300 })
301 }()
302
303 return true, nil
304}
305
306func (d *Daemon) handleSubscribe(_ context.Context, conn *daemonrpc.Conn, params json.RawMessage) (any, error) {
307 args, err := decodeParams[daemonrpc.SubscribeParams](params)
308 if err != nil {
309 return nil, parseError(err)
310 }
311
312 key := args.AccountID + ":" + args.Folder
313
314 d.subMu.Lock()
315 if d.subscriptions[conn] == nil {
316 d.subscriptions[conn] = make(map[string]struct{})
317 }
318 d.subscriptions[conn][key] = struct{}{}
319 d.subMu.Unlock()
320
321 log.Printf("daemon: client subscribed to %s", key)
322 return true, nil
323}
324
325func (d *Daemon) handleUnsubscribe(_ context.Context, conn *daemonrpc.Conn, params json.RawMessage) (any, error) {
326 args, err := decodeParams[daemonrpc.UnsubscribeParams](params)
327 if err != nil {
328 return nil, parseError(err)
329 }
330
331 key := args.AccountID + ":" + args.Folder
332
333 d.subMu.Lock()
334 if subs, ok := d.subscriptions[conn]; ok {
335 delete(subs, key)
336 }
337 d.subMu.Unlock()
338
339 return true, nil
340}