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