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}