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, 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}