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