service.go

  1package daemonclient
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log"
  7	"os"
  8	"os/exec"
  9	"time"
 10
 11	"github.com/floatpane/matcha/backend"
 12	_ "github.com/floatpane/matcha/backend/jmap"    // register jmap backend for directService
 13	_ "github.com/floatpane/matcha/backend/maildir" // register maildir backend for directService
 14	"github.com/floatpane/matcha/config"
 15	"github.com/floatpane/matcha/daemonrpc"
 16	"github.com/floatpane/matcha/fetcher"
 17	"github.com/floatpane/matcha/internal/loglevel"
 18	"github.com/floatpane/matcha/sender"
 19)
 20
 21// Service abstracts daemon-backed vs direct email operations.
 22// TUI and CLI use this interface — they don't care which mode is active.
 23type Service interface {
 24	FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error)
 25	// FetchEmailBody returns body, MIME type ("text/html"|"text/plain"|""),
 26	// attachments, and any error.
 27	FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error)
 28	DeleteEmails(accountID, folder string, uids []uint32) error
 29	ArchiveEmails(accountID, folder string, uids []uint32) error
 30	MoveEmails(accountID string, uids []uint32, src, dst string) error
 31	MarkRead(accountID, folder string, uids []uint32) error
 32	MarkUnread(accountID, folder string, uids []uint32) error
 33	QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, error)
 34	CancelEmail(jobID string) error
 35	FetchFolders(accountID string) ([]backend.Folder, error)
 36	RefreshFolder(accountID, folder string) error
 37	Subscribe(accountID, folder string) error
 38	Unsubscribe(accountID, folder string) error
 39	ReloadConfig() error
 40	Events() <-chan *daemonrpc.Event
 41	IsDaemon() bool
 42	Close() error
 43}
 44
 45// NewService connects to the daemon, auto-starting it if needed.
 46// Falls back to direct mode only if daemon cannot be started, or if DisableDaemon is set.
 47func NewService(cfg *config.Config) Service {
 48	if cfg.DisableDaemon {
 49		log.Println("service: daemon disabled by config, using direct mode")
 50		return newDirectService(cfg)
 51	}
 52
 53	// Try connecting to existing daemon.
 54	if svc := tryConnect(); svc != nil {
 55		return svc
 56	}
 57
 58	// Daemon not running — auto-start it.
 59	loglevel.Debugf("service: daemon not running, auto-starting")
 60	if err := autoStartDaemon(); err != nil {
 61		loglevel.Debugf("service: auto-start failed: %v, using direct mode", err)
 62		return newDirectService(cfg)
 63	}
 64
 65	// Wait briefly for daemon to become ready, then connect.
 66	for i := 0; i < 20; i++ {
 67		time.Sleep(100 * time.Millisecond)
 68		if svc := tryConnect(); svc != nil {
 69			loglevel.Debugf("service: connected to auto-started daemon")
 70			return svc
 71		}
 72	}
 73
 74	log.Println("service: daemon started but not responding, using direct mode")
 75	return newDirectService(cfg)
 76}
 77
 78func tryConnect() *daemonService {
 79	client, err := Dial()
 80	if err != nil {
 81		return nil
 82	}
 83	if err := client.Ping(); err != nil {
 84		client.Close() //nolint:errcheck,gosec
 85		return nil
 86	}
 87	return &daemonService{client: client}
 88}
 89
 90func autoStartDaemon() error {
 91	exe, err := os.Executable()
 92	if err != nil {
 93		return err
 94	}
 95
 96	cmd := exec.Command(exe, "daemon", "run") //nolint:noctx
 97	cmd.Stdout = nil
 98	cmd.Stderr = nil
 99	cmd.Stdin = nil
100	cmd.SysProcAttr = DaemonProcAttr()
101
102	return cmd.Start()
103}
104
105// daemonService routes all operations through the daemon socket.
106type daemonService struct {
107	client *Client
108}
109
110func (s *daemonService) FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error) {
111	var emails []backend.Email
112	err := s.client.Call(daemonrpc.MethodFetchEmails, daemonrpc.FetchEmailsParams{
113		AccountID: accountID,
114		Folder:    folder,
115		Limit:     limit,
116		Offset:    offset,
117	}, &emails)
118	return emails, err
119}
120
121func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error) {
122	var result daemonrpc.FetchEmailBodyResult
123	err := s.client.Call(daemonrpc.MethodFetchEmailBody, daemonrpc.FetchEmailBodyParams{
124		AccountID: accountID,
125		Folder:    folder,
126		UID:       uid,
127	}, &result)
128	if err != nil {
129		return "", "", nil, err
130	}
131
132	var attachments []backend.Attachment
133	for _, a := range result.Attachments {
134		attachments = append(attachments, backend.Attachment{
135			Filename: a.Filename,
136			PartID:   a.PartID,
137			Encoding: a.Encoding,
138			MIMEType: a.MIMEType,
139		})
140	}
141	return result.Body, result.BodyMIMEType, attachments, nil
142}
143
144func (s *daemonService) DeleteEmails(accountID, folder string, uids []uint32) error {
145	return s.client.Call(daemonrpc.MethodDeleteEmails, daemonrpc.DeleteEmailsParams{
146		AccountID: accountID,
147		Folder:    folder,
148		UIDs:      uids,
149	}, nil)
150}
151
152func (s *daemonService) ArchiveEmails(accountID, folder string, uids []uint32) error {
153	return s.client.Call(daemonrpc.MethodArchiveEmails, daemonrpc.ArchiveEmailsParams{
154		AccountID: accountID,
155		Folder:    folder,
156		UIDs:      uids,
157	}, nil)
158}
159
160func (s *daemonService) MoveEmails(accountID string, uids []uint32, src, dst string) error {
161	return s.client.Call(daemonrpc.MethodMoveEmails, daemonrpc.MoveEmailsParams{
162		AccountID:    accountID,
163		UIDs:         uids,
164		SourceFolder: src,
165		DestFolder:   dst,
166	}, nil)
167}
168
169func (s *daemonService) MarkRead(accountID, folder string, uids []uint32) error {
170	return s.client.Call(daemonrpc.MethodMarkRead, daemonrpc.MarkReadParams{
171		AccountID: accountID,
172		Folder:    folder,
173		UIDs:      uids,
174		Read:      true,
175	}, nil)
176}
177
178func (s *daemonService) MarkUnread(accountID, folder string, uids []uint32) error {
179	return s.client.Call(daemonrpc.MethodMarkRead, daemonrpc.MarkReadParams{
180		AccountID: accountID,
181		Folder:    folder,
182		UIDs:      uids,
183		Read:      false,
184	}, nil)
185}
186
187func (s *daemonService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, delaySeconds int) (string, error) {
188	var result daemonrpc.QueueEmailResult
189	err := s.client.Call(daemonrpc.MethodQueueEmail, daemonrpc.QueueEmailParams{
190		Email: daemonrpc.SendEmailParams{
191			AccountID:    accountID,
192			To:           to,
193			Cc:           cc,
194			Bcc:          bcc,
195			Subject:      subject,
196			Body:         body,
197			HTMLBody:     htmlBody,
198			Images:       images,
199			Attachments:  attachments,
200			InReplyTo:    inReplyTo,
201			References:   references,
202			SignSMIME:    signSMIME,
203			EncryptSMIME: encryptSMIME,
204			SignPGP:      signPGP,
205			EncryptPGP:   encryptPGP,
206		},
207		DelaySeconds: delaySeconds,
208	}, &result)
209	return result.JobID, err
210}
211
212func (s *daemonService) CancelEmail(jobID string) error {
213	return s.client.Call(daemonrpc.MethodCancelEmail, daemonrpc.CancelEmailParams{
214		JobID: jobID,
215	}, nil)
216}
217
218func (s *daemonService) FetchFolders(accountID string) ([]backend.Folder, error) {
219	var folders []backend.Folder
220	err := s.client.Call(daemonrpc.MethodFetchFolders, daemonrpc.FetchFoldersParams{
221		AccountID: accountID,
222	}, &folders)
223	return folders, err
224}
225
226func (s *daemonService) RefreshFolder(accountID, folder string) error {
227	return s.client.Call(daemonrpc.MethodRefreshFolder, daemonrpc.RefreshFolderParams{
228		AccountID: accountID,
229		Folder:    folder,
230	}, nil)
231}
232
233func (s *daemonService) Subscribe(accountID, folder string) error {
234	return s.client.Call(daemonrpc.MethodSubscribe, daemonrpc.SubscribeParams{
235		AccountID: accountID,
236		Folder:    folder,
237	}, nil)
238}
239
240func (s *daemonService) Unsubscribe(accountID, folder string) error {
241	return s.client.Call(daemonrpc.MethodUnsubscribe, daemonrpc.UnsubscribeParams{
242		AccountID: accountID,
243		Folder:    folder,
244	}, nil)
245}
246
247func (s *daemonService) ReloadConfig() error {
248	return s.client.Call(daemonrpc.MethodReloadConfig, nil, nil)
249}
250
251func (s *daemonService) Events() <-chan *daemonrpc.Event {
252	return s.client.Events()
253}
254
255func (s *daemonService) IsDaemon() bool { return true }
256
257func (s *daemonService) Close() error {
258	return s.client.Close()
259}
260
261// directService runs operations in-process (no daemon).
262// This is the fallback when daemon is not running.
263type directService struct {
264	cfg       *config.Config
265	providers map[string]backend.Provider
266	events    chan *daemonrpc.Event
267}
268
269func newDirectService(cfg *config.Config) *directService {
270	s := &directService{
271		cfg:       cfg,
272		providers: make(map[string]backend.Provider),
273		events:    make(chan *daemonrpc.Event, 64),
274	}
275	s.initProviders()
276	return s
277}
278
279func (s *directService) initProviders() {
280	for i := range s.cfg.Accounts {
281		acct := &s.cfg.Accounts[i]
282		if _, ok := s.providers[acct.ID]; ok {
283			continue
284		}
285		p, err := backend.New(acct)
286		if err != nil {
287			log.Printf("direct service: provider for %s failed: %v", acct.Email, err)
288			continue
289		}
290		s.providers[acct.ID] = p
291	}
292}
293
294func (s *directService) getProvider(accountID string) (backend.Provider, error) {
295	p, ok := s.providers[accountID]
296	if !ok {
297		return nil, &daemonrpc.Error{Code: daemonrpc.ErrCodeInternal, Message: "no provider for account " + accountID}
298	}
299	return p, nil
300}
301
302func (s *directService) FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error) {
303	p, err := s.getProvider(accountID)
304	if err != nil {
305		return nil, err
306	}
307	return p.FetchEmails(context.Background(), folder, limit, offset)
308}
309
310func (s *directService) FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error) {
311	p, err := s.getProvider(accountID)
312	if err != nil {
313		return "", "", nil, err
314	}
315	return p.FetchEmailBody(context.Background(), folder, uid)
316}
317
318func (s *directService) DeleteEmails(accountID, folder string, uids []uint32) error {
319	p, err := s.getProvider(accountID)
320	if err != nil {
321		return err
322	}
323	return p.DeleteEmails(context.Background(), folder, uids)
324}
325
326func (s *directService) ArchiveEmails(accountID, folder string, uids []uint32) error {
327	p, err := s.getProvider(accountID)
328	if err != nil {
329		return err
330	}
331	return p.ArchiveEmails(context.Background(), folder, uids)
332}
333
334func (s *directService) MoveEmails(accountID string, uids []uint32, src, dst string) error {
335	p, err := s.getProvider(accountID)
336	if err != nil {
337		return err
338	}
339	return p.MoveEmails(context.Background(), uids, src, dst)
340}
341
342func (s *directService) MarkRead(accountID, folder string, uids []uint32) error {
343	p, err := s.getProvider(accountID)
344	if err != nil {
345		return err
346	}
347	for _, uid := range uids {
348		if err := p.MarkAsRead(context.Background(), folder, uid); err != nil {
349			return err
350		}
351	}
352	return nil
353}
354
355func (s *directService) MarkUnread(accountID, folder string, uids []uint32) error {
356	p, err := s.getProvider(accountID)
357	if err != nil {
358		return err
359	}
360	for _, uid := range uids {
361		if err := p.MarkAsUnread(context.Background(), folder, uid); err != nil {
362			return err
363		}
364	}
365	return nil
366}
367
368func (s *directService) FetchFolders(accountID string) ([]backend.Folder, error) {
369	p, err := s.getProvider(accountID)
370	if err != nil {
371		return nil, err
372	}
373	return p.FetchFolders(context.Background())
374}
375
376func (s *directService) RefreshFolder(_, _ string) error {
377	// In direct mode, caller handles refresh via their own fetcher calls.
378	return nil
379}
380
381func (s *directService) Subscribe(_, _ string) error {
382	// No-op in direct mode — TUI manages its own IDLE.
383	return nil
384}
385
386func (s *directService) Unsubscribe(_, _ string) error {
387	return nil
388}
389
390func (s *directService) ReloadConfig() error {
391	cfg, err := config.LoadConfig()
392	if err != nil {
393		return err
394	}
395	s.cfg = cfg
396	s.initProviders()
397	return nil
398}
399
400func (s *directService) Events() <-chan *daemonrpc.Event {
401	return s.events
402}
403
404func (s *directService) IsDaemon() bool { return false }
405
406func (s *directService) Close() error {
407	for _, p := range s.providers {
408		p.Close() //nolint:errcheck,gosec
409	}
410	close(s.events)
411	return nil
412}
413
414func (s *directService) QueueEmail(accountID string, to, cc, bcc []string, subject, body, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME, encryptSMIME, signPGP, encryptPGP bool, _ int) (string, error) {
415	acct := s.cfg.GetAccountByID(accountID)
416	if acct == nil {
417		return "", fmt.Errorf("no account for %s", accountID)
418	}
419
420	rawMsg, err := sender.SendEmail(
421		acct,
422		to,
423		cc,
424		bcc,
425		subject,
426		body,
427		htmlBody,
428		images,
429		attachments,
430		inReplyTo,
431		references,
432		signSMIME,
433		encryptSMIME,
434		signPGP,
435		encryptPGP,
436	)
437	if err != nil {
438		return "", err
439	}
440
441	if acct.ServiceProvider != "gmail" {
442		if err := fetcher.AppendToSentMailbox(acct, rawMsg); err != nil {
443			log.Printf("direct: append to sent failed: %v", err)
444		}
445	}
446
447	return "", nil
448}
449
450func (s *directService) CancelEmail(_ string) error {
451	return nil
452}