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