service.go

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