service.go

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