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