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