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