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