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