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)) //nolint:errcheck,gosec
 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}) //nolint:errcheck,gosec
 70}
 71
 72func (d *Daemon) handleGetStatus(conn *daemonrpc.Conn, req *daemonrpc.Request) {
 73	d.mu.RLock()
 74	accounts := make([]string, 0, len(d.config.Accounts))
 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{ //nolint:errcheck,gosec
 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	infos := make([]daemonrpc.AccountInfo, 0, len(d.config.Accounts))
 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) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
111		return
112	}
113	conn.SendResponse(req.ID, true) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
120		return
121	}
122
123	p, err := d.getProvider(params.AccountID)
124	if err != nil {
125		conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
135		return
136	}
137
138	conn.SendResponse(req.ID, emails) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
145		return
146	}
147
148	p, err := d.getProvider(params.AccountID)
149	if err != nil {
150		conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
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{ //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
185		return
186	}
187
188	p, err := d.getProvider(params.AccountID)
189	if err != nil {
190		conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
199		return
200	}
201	conn.SendResponse(req.ID, true) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
208		return
209	}
210
211	p, err := d.getProvider(params.AccountID)
212	if err != nil {
213		conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
222		return
223	}
224	conn.SendResponse(req.ID, true) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
231		return
232	}
233
234	p, err := d.getProvider(params.AccountID)
235	if err != nil {
236		conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
245		return
246	}
247	conn.SendResponse(req.ID, true) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
254		return
255	}
256
257	p, err := d.getProvider(params.AccountID)
258	if err != nil {
259		conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec
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) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
284		return
285	}
286
287	p, err := d.getProvider(params.AccountID)
288	if err != nil {
289		conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
299		return
300	}
301	conn.SendResponse(req.ID, folders) //nolint:errcheck,gosec
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()) //nolint:errcheck,gosec
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(params))
331
332		ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
333		defer cancel()
334
335		emails, err := p.FetchEmails(ctx, params.Folder, 50, 0)
336		if err != nil {
337			d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncError, daemonrpc.SyncErrorEvent{
338				AccountID: params.AccountID,
339				Folder:    params.Folder,
340				Error:     err.Error(),
341			})
342			return
343		}
344
345		d.broadcastToSubscribers(params.AccountID, params.Folder, daemonrpc.EventSyncComplete, daemonrpc.SyncCompleteEvent{
346			AccountID:  params.AccountID,
347			Folder:     params.Folder,
348			EmailCount: len(emails),
349		})
350	}()
351
352	conn.SendResponse(req.ID, true) //nolint:errcheck,gosec
353}
354
355func (d *Daemon) handleSubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
356	params, err := decodeParams[daemonrpc.SubscribeParams](req)
357	if err != nil {
358		conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec
359		return
360	}
361
362	key := params.AccountID + ":" + params.Folder
363
364	d.subMu.Lock()
365	if d.subscriptions[conn] == nil {
366		d.subscriptions[conn] = make(map[string]struct{})
367	}
368	d.subscriptions[conn][key] = struct{}{}
369	d.subMu.Unlock()
370
371	log.Printf("daemon: client subscribed to %s", key)
372	conn.SendResponse(req.ID, true) //nolint:errcheck,gosec
373}
374
375func (d *Daemon) handleUnsubscribe(conn *daemonrpc.Conn, req *daemonrpc.Request) {
376	params, err := decodeParams[daemonrpc.UnsubscribeParams](req)
377	if err != nil {
378		conn.SendError(req.ID, daemonrpc.ErrCodeParse, err.Error()) //nolint:errcheck,gosec
379		return
380	}
381
382	key := params.AccountID + ":" + params.Folder
383
384	d.subMu.Lock()
385	if subs, ok := d.subscriptions[conn]; ok {
386		delete(subs, key)
387	}
388	d.subMu.Unlock()
389
390	conn.SendResponse(req.ID, true) //nolint:errcheck,gosec
391}