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}