handler.go

  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, &params); 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}