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