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