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