1package main
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "compress/gzip"
7 "context"
8 "encoding/base64"
9 "encoding/json"
10 "flag"
11 "fmt"
12 "io"
13 "log"
14 "net/url"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "regexp"
19 "runtime"
20 "slices"
21 "strings"
22 "sync"
23 "time"
24
25 tea "charm.land/bubbletea/v2"
26 "github.com/floatpane/matcha/backend"
27 _ "github.com/floatpane/matcha/backend/imap"
28 _ "github.com/floatpane/matcha/backend/jmap"
29 _ "github.com/floatpane/matcha/backend/pop3"
30 "github.com/floatpane/matcha/calendar"
31 matchaCli "github.com/floatpane/matcha/cli"
32 "github.com/floatpane/matcha/clib"
33 "github.com/floatpane/matcha/clib/macos"
34 "github.com/floatpane/matcha/config"
35 matchaDaemon "github.com/floatpane/matcha/daemon"
36 "github.com/floatpane/matcha/daemonclient"
37 "github.com/floatpane/matcha/daemonrpc"
38 "github.com/floatpane/matcha/fetcher"
39 "github.com/floatpane/matcha/i18n"
40 _ "github.com/floatpane/matcha/i18n/languages"
41 "github.com/floatpane/matcha/internal/httpclient"
42 "github.com/floatpane/matcha/notify"
43 "github.com/floatpane/matcha/plugin"
44 "github.com/floatpane/matcha/sender"
45 "github.com/floatpane/matcha/theme"
46 "github.com/floatpane/matcha/tui"
47 "github.com/google/uuid"
48 lua "github.com/yuin/gopher-lua"
49)
50
51const (
52 initialEmailLimit = 50
53 paginationLimit = 50
54 maxCacheEmails = 100
55)
56
57// Version variables are injected by the build (GoReleaser ldflags).
58// They default to "dev" when not set by the build system.
59var (
60 version = "dev"
61 commit = ""
62 date = ""
63
64 // httpClient is used for all outbound HTTP requests (update checks, asset downloads).
65 httpClient = httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
66)
67
68// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
69type UpdateAvailableMsg struct {
70 Latest string
71 Current string
72}
73
74// internal struct for parsing GitHub release JSON.
75type githubRelease struct {
76 TagName string `json:"tag_name"`
77 Assets []struct {
78 Name string `json:"name"`
79 BrowserDownloadURL string `json:"browser_download_url"`
80 } `json:"assets"`
81}
82
83type mainModel struct {
84 current tea.Model
85 previousModel tea.Model
86 config *config.Config
87 plugins *plugin.Manager
88 // Folder-based email storage
89 folderEmails map[string][]fetcher.Email // key: folderName
90 folderInbox *tui.FolderInbox
91 // Legacy fields kept for email actions
92 emails []fetcher.Email
93 emailsByAcct map[string][]fetcher.Email
94 width int
95 height int
96 err error
97 // IMAP IDLE
98 idleWatcher *fetcher.IdleWatcher
99 idleUpdates chan fetcher.IdleUpdate
100 // Multi-protocol backend providers (keyed by account ID)
101 providers map[string]backend.Provider
102 // Daemon client service (daemon or direct fallback)
103 service daemonclient.Service
104 // Plugin prompt waiting for user input
105 pendingPrompt *plugin.PendingPrompt
106 // mailto: URL parsed from os.Args
107 mailtoURL *url.URL
108}
109
110func newInitialModel(cfg *config.Config, mailtoURL *url.URL) *mainModel {
111 idleUpdates := make(chan fetcher.IdleUpdate, 16)
112 initialModel := &mainModel{
113 emailsByAcct: make(map[string][]fetcher.Email),
114 folderEmails: make(map[string][]fetcher.Email),
115 idleUpdates: idleUpdates,
116 idleWatcher: fetcher.NewIdleWatcher(idleUpdates),
117 providers: make(map[string]backend.Provider),
118 mailtoURL: mailtoURL,
119 }
120
121 if cfg == nil || !cfg.HasAccounts() {
122 hideTips := false
123 if cfg != nil {
124 hideTips = cfg.HideTips
125 }
126 initialModel.current = tui.NewLogin(hideTips)
127 } else {
128 if mailtoURL != nil {
129 // mailto:addr@example.com?subject=test
130 to := mailtoURL.Opaque
131 if to == "" {
132 to = mailtoURL.Path
133 }
134 if to == "" {
135 to = mailtoURL.Query().Get("to")
136 }
137 subject := mailtoURL.Query().Get("subject")
138 body := mailtoURL.Query().Get("body")
139 initialModel.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
140 } else {
141
142 initialModel.current = tui.NewChoice()
143 }
144 initialModel.config = cfg
145 }
146 return initialModel
147}
148
149// ensureProviders creates backend providers for all configured accounts.
150func (m *mainModel) ensureProviders() {
151 if m.config == nil {
152 return
153 }
154 for _, acct := range m.config.Accounts {
155 if _, ok := m.providers[acct.ID]; ok {
156 continue
157 }
158 p, err := backend.New(&acct)
159 if err != nil {
160 log.Printf("backend: failed to create provider for %s: %v", acct.Email, err)
161 continue
162 }
163 m.providers[acct.ID] = p
164 }
165}
166
167// getProvider returns the backend provider for the given account.
168func (m *mainModel) getProvider(acct *config.Account) backend.Provider {
169 if acct == nil {
170 return nil
171 }
172 return m.providers[acct.ID]
173}
174
175func (m *mainModel) Init() tea.Cmd {
176 return tea.Batch(m.current.Init(), checkForUpdatesCmd())
177}
178
179func (m *mainModel) syncUnreadBadge() {
180 if runtime.GOOS != "darwin" {
181 return
182 }
183 count := 0
184 // Count unread across all accounts (cached/loaded emails)
185 for _, emails := range m.emailsByAcct {
186 for _, e := range emails {
187 if !e.IsRead {
188 count++
189 }
190 }
191 }
192 // Also check folderEmails for unread status
193 for _, emails := range m.folderEmails {
194 for _, e := range emails {
195 if !e.IsRead {
196 count++
197 }
198 }
199 }
200 _ = macos.SetBadge(count)
201}
202
203func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
204 var cmd tea.Cmd
205 var cmds []tea.Cmd
206
207 m.current, cmd = m.current.Update(msg)
208 cmds = append(cmds, cmd)
209
210 // Fire composer_updated hook on key presses when the composer is active
211 if keyMsg, isKey := msg.(tea.KeyPressMsg); isKey {
212 if composer, ok := m.current.(*tui.Composer); ok && m.plugins != nil {
213 m.plugins.CallComposerHook(plugin.HookComposerUpdated, composer.GetBody(), composer.GetSubject(), composer.GetTo(), composer.GetCc(), composer.GetBcc())
214 m.syncPluginStatus()
215 m.applyPluginFields(composer)
216 }
217
218 // Check plugin key bindings for the current view
219 if m.plugins != nil {
220 m.handlePluginKeyBinding(keyMsg)
221 }
222 }
223
224 switch msg := msg.(type) {
225 case tea.WindowSizeMsg:
226 m.width = msg.Width
227 m.height = msg.Height
228 return m, nil
229
230 case tea.KeyPressMsg:
231 if msg.String() == "ctrl+c" {
232 m.idleWatcher.StopAll()
233 if m.service != nil {
234 m.service.Close()
235 }
236 return m, tea.Quit
237 }
238 if msg.String() == "esc" {
239 switch m.current.(type) {
240 case *tui.FilePicker:
241 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
242 case *tui.FolderInbox, *tui.Inbox, *tui.Login:
243 m.idleWatcher.StopAll()
244 m.current = tui.NewChoice()
245 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
246 return m, m.current.Init()
247 }
248 }
249
250 case tui.BackToInboxMsg:
251 if m.folderInbox != nil {
252 m.current = m.folderInbox
253 } else {
254 m.current = tui.NewChoice()
255 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
256 }
257 return m, nil
258
259 case tui.BackToMailboxMsg:
260 // Ensure kitty graphics are cleared when leaving email view
261 tui.ClearKittyGraphics()
262 if m.folderInbox != nil {
263 m.current = m.folderInbox
264 return m, nil
265 }
266 m.current = tui.NewChoice()
267 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
268 return m, nil
269
270 case tui.DiscardDraftMsg:
271 // Save draft to disk
272 if msg.ComposerState != nil {
273 draft := msg.ComposerState.ToDraft()
274
275 if err := config.SaveDraft(draft); err != nil {
276 log.Printf("Error saving draft: %v", err)
277 }
278
279 }
280 m.current = tui.NewChoice()
281 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
282 return m, m.current.Init()
283
284 case tui.OAuth2CompleteMsg:
285 if msg.Err != nil {
286 log.Printf("OAuth2 authorization failed: %v", msg.Err)
287 }
288 // After OAuth2 flow, go to the choice menu so user can proceed
289 m.current = tui.NewChoice()
290 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
291 return m, m.current.Init()
292
293 case tui.Credentials:
294 // Split FetchEmail by commas to support multiple fetch addresses.
295 // Each address creates a separate account sharing the same login credentials.
296 fetchEmails := []string{""}
297 if msg.FetchEmail != "" {
298 fetchEmails = fetchEmails[:0]
299 for _, fe := range strings.Split(msg.FetchEmail, ",") {
300 if trimmed := strings.TrimSpace(fe); trimmed != "" {
301 fetchEmails = append(fetchEmails, trimmed)
302 }
303 }
304 if len(fetchEmails) == 0 {
305 fetchEmails = []string{""}
306 }
307 }
308
309 if m.config == nil {
310 m.config = &config.Config{}
311 }
312
313 // Check if we're editing an existing account
314 isEdit := false
315 var lastAccount config.Account
316 if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() {
317 isEdit = true
318 existingID := login.GetAccountID()
319
320 account := config.Account{
321 ID: existingID,
322 Name: msg.Name,
323 Email: msg.Host,
324 Password: msg.Password,
325 ServiceProvider: msg.Provider,
326 FetchEmail: fetchEmails[0],
327 SendAsEmail: msg.SendAsEmail,
328 AuthMethod: msg.AuthMethod,
329 Protocol: msg.Protocol,
330 Insecure: msg.Insecure,
331 JMAPEndpoint: msg.JMAPEndpoint,
332 POP3Server: msg.POP3Server,
333 POP3Port: msg.POP3Port,
334 }
335
336 if msg.Provider == "custom" || msg.Protocol == "pop3" {
337 account.IMAPServer = msg.IMAPServer
338 account.IMAPPort = msg.IMAPPort
339 account.SMTPServer = msg.SMTPServer
340 account.SMTPPort = msg.SMTPPort
341 }
342
343 if account.FetchEmail == "" && account.Email != "" {
344 account.FetchEmail = account.Email
345 }
346
347 // Find and update the existing account, preserving S/MIME settings
348 for i, acc := range m.config.Accounts {
349 if acc.ID == existingID {
350 account.SMIMECert = acc.SMIMECert
351 account.SMIMEKey = acc.SMIMEKey
352 account.SMIMESignByDefault = acc.SMIMESignByDefault
353 if account.Password == "" {
354 account.Password = acc.Password
355 }
356 m.config.Accounts[i] = account
357 break
358 }
359 }
360 lastAccount = account
361 } else {
362 // New account: create one account per fetch email address
363 for _, fe := range fetchEmails {
364 account := config.Account{
365 ID: uuid.New().String(),
366 Name: msg.Name,
367 Email: msg.Host,
368 Password: msg.Password,
369 ServiceProvider: msg.Provider,
370 FetchEmail: fe,
371 SendAsEmail: msg.SendAsEmail,
372 AuthMethod: msg.AuthMethod,
373 Protocol: msg.Protocol,
374 JMAPEndpoint: msg.JMAPEndpoint,
375 POP3Server: msg.POP3Server,
376 POP3Port: msg.POP3Port,
377 }
378
379 if msg.Provider == "custom" || msg.Protocol == "pop3" {
380 account.IMAPServer = msg.IMAPServer
381 account.IMAPPort = msg.IMAPPort
382 account.SMTPServer = msg.SMTPServer
383 account.SMTPPort = msg.SMTPPort
384 }
385
386 if account.FetchEmail == "" && account.Email != "" {
387 account.FetchEmail = account.Email
388 }
389
390 m.config.AddAccount(account)
391 lastAccount = account
392 }
393 }
394
395 if err := config.SaveConfig(m.config); err != nil {
396 log.Printf("could not save config: %v", err)
397 return m, tea.Quit
398 }
399
400 // If OAuth2, launch the authorization flow after saving the account
401 if lastAccount.IsOAuth2() {
402 email := lastAccount.Email
403 provider := lastAccount.ServiceProvider
404 return m, func() tea.Msg {
405 err := config.RunOAuth2Flow(email, provider, "", "")
406 return tui.OAuth2CompleteMsg{Email: email, Err: err}
407 }
408 }
409
410 if isEdit {
411 m.current = tui.NewSettings(m.config)
412 } else {
413 m.current = tui.NewChoice()
414 }
415 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
416 return m, m.current.Init()
417
418 case tui.GoToInboxMsg:
419 if m.config == nil || !m.config.HasAccounts() {
420 hideTips := false
421 if m.config != nil {
422 hideTips = m.config.HideTips
423 }
424 m.current = tui.NewLogin(hideTips)
425 return m, m.current.Init()
426 }
427 m.ensureProviders()
428 // Load cached folders from all accounts, merge unique names
429 seen := make(map[string]bool)
430 var cachedFolders []string
431 for _, acc := range m.config.Accounts {
432 for _, f := range config.GetCachedFolders(acc.ID) {
433 if !seen[f] {
434 seen[f] = true
435 cachedFolders = append(cachedFolders, f)
436 }
437 }
438 }
439 // Always ensure INBOX is present, even if cache is empty or stale
440 if !seen["INBOX"] {
441 cachedFolders = append([]string{"INBOX"}, cachedFolders...)
442 }
443 m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts)
444 m.folderInbox.SetDateFormat(m.config.GetDateFormat())
445 // Use cached INBOX emails for instant display (memory first, then disk)
446 if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 {
447 m.folderInbox.SetEmails(cached, m.config.Accounts)
448 } else if diskCached := loadFolderEmailsFromCache("INBOX"); len(diskCached) > 0 {
449 m.folderEmails["INBOX"] = diskCached
450 m.emails = diskCached
451 m.emailsByAcct = make(map[string][]fetcher.Email)
452 for _, email := range diskCached {
453 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
454 }
455 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
456 }
457 m.current = m.folderInbox
458 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
459 // Initialize daemon service if not already set.
460 if m.service == nil {
461 m.service = daemonclient.NewService(m.config)
462 }
463 if m.service.IsDaemon() {
464 // Subscribe to INBOX updates if using daemon.
465 for _, acct := range m.config.Accounts {
466 m.service.Subscribe(acct.ID, "INBOX")
467 }
468 } else {
469 // Start IDLE watchers for all accounts on INBOX
470 for i := range m.config.Accounts {
471 m.idleWatcher.Watch(&m.config.Accounts[i], "INBOX")
472 }
473 }
474 // Fetch folders and INBOX emails in parallel (background refresh)
475 batchCmds := []tea.Cmd{
476 m.current.Init(),
477 fetchFoldersCmd(m.config),
478 fetchFolderEmailsCmd(m.config, "INBOX"),
479 listenForIdleUpdates(m.idleUpdates),
480 }
481 if m.service.IsDaemon() {
482 batchCmds = append(batchCmds, listenForDaemonEvents(m.service.Events()))
483 }
484 return m, tea.Batch(batchCmds...)
485
486 case tui.FoldersFetchedMsg:
487 if m.folderInbox == nil {
488 return m, nil
489 }
490 var folderNames []string
491 for _, f := range msg.MergedFolders {
492 folderNames = append(folderNames, f.Name)
493 }
494 m.folderInbox.SetFolders(folderNames)
495 // Cache folder lists per account
496 for accID, folders := range msg.FoldersByAccount {
497 var names []string
498 for _, f := range folders {
499 names = append(names, f.Name)
500 }
501 go config.SaveAccountFolders(accID, names)
502 }
503 return m, nil
504
505 case tui.SwitchFolderMsg:
506 if m.config == nil {
507 return m, nil
508 }
509 // Update IDLE watchers to monitor the new folder
510 for i := range m.config.Accounts {
511 // Only start IDLE for accounts that actually have this folder
512 folders := config.GetCachedFolders(m.config.Accounts[i].ID)
513 if !slices.Contains(folders, msg.FolderName) {
514 if m.service != nil && m.service.IsDaemon() {
515 m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
516 } else {
517 m.idleWatcher.Stop(m.config.Accounts[i].ID)
518 }
519 continue
520 }
521 if m.service != nil && m.service.IsDaemon() {
522 // Unsubscribe from old, subscribe to new.
523 if msg.PreviousFolder != "" {
524 m.service.Unsubscribe(m.config.Accounts[i].ID, msg.PreviousFolder)
525 }
526 m.service.Subscribe(m.config.Accounts[i].ID, msg.FolderName)
527 } else {
528 m.idleWatcher.Watch(&m.config.Accounts[i], msg.FolderName)
529 }
530 }
531 if m.plugins != nil {
532 m.plugins.CallFolderHook(plugin.HookFolderChanged, msg.FolderName)
533 m.syncPluginStatus()
534 m.syncPluginKeyBindings()
535 }
536 // Use in-memory cache if available
537 if cached, ok := m.folderEmails[msg.FolderName]; ok {
538 m.emails = cached
539 m.emailsByAcct = make(map[string][]fetcher.Email)
540 for _, email := range cached {
541 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
542 }
543 if m.folderInbox != nil {
544 m.folderInbox.SetEmails(cached, m.config.Accounts)
545 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
546 m.folderInbox.SetLoadingEmails(false)
547 }
548 return m, m.pluginNotifyCmd()
549 }
550 // Fall back to disk cache for instant display, then fetch fresh in background
551 if diskCached := loadFolderEmailsFromCache(msg.FolderName); len(diskCached) > 0 {
552 m.folderEmails[msg.FolderName] = diskCached
553 m.emails = diskCached
554 m.emailsByAcct = make(map[string][]fetcher.Email)
555 for _, email := range diskCached {
556 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
557 }
558 if m.folderInbox != nil {
559 m.folderInbox.SetEmails(diskCached, m.config.Accounts)
560 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
561 m.folderInbox.SetLoadingEmails(false)
562 }
563 // Still fetch fresh emails in background
564 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
565 }
566 if m.folderInbox != nil {
567 m.folderInbox.SetLoadingEmails(true)
568 }
569 return m, tea.Batch(fetchFolderEmailsCmd(m.config, msg.FolderName), m.pluginNotifyCmd())
570
571 case tui.PluginNotifyMsg:
572 m.previousModel = m.current
573 m.current = tui.NewStatus(msg.Message)
574 dur := time.Duration(msg.Duration * float64(time.Second))
575 if dur <= 0 {
576 dur = 2 * time.Second
577 }
578 return m, tea.Tick(dur, func(t time.Time) tea.Msg {
579 return tui.RestoreViewMsg{}
580 })
581
582 case tui.PluginPromptSubmitMsg:
583 if m.pendingPrompt != nil {
584 if composer, ok := m.current.(*tui.Composer); ok {
585 composer.HidePluginPrompt()
586 m.plugins.ResolvePrompt(m.pendingPrompt, msg.Value)
587 m.applyPluginFields(composer)
588 m.syncPluginStatus()
589 }
590 m.pendingPrompt = nil
591 }
592 return m, nil
593
594 case tui.PluginPromptCancelMsg:
595 if composer, ok := m.current.(*tui.Composer); ok {
596 composer.HidePluginPrompt()
597 }
598 m.pendingPrompt = nil
599 return m, nil
600
601 case tui.FolderEmailsFetchedMsg:
602 if m.folderInbox == nil {
603 return m, nil
604 }
605 // Call plugin hooks for received emails
606 if m.plugins != nil {
607 for _, email := range msg.Emails {
608 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, msg.FolderName)
609 m.plugins.CallHook(plugin.HookEmailReceived, t)
610 }
611 }
612 // Always cache in memory and to disk
613 m.folderEmails[msg.FolderName] = msg.Emails
614 go saveFolderEmailsToCache(msg.FolderName, msg.Emails)
615 // Prune stale body cache entries
616 go func() {
617 validUIDs := make(map[uint32]string, len(msg.Emails))
618 for _, e := range msg.Emails {
619 validUIDs[e.UID] = e.AccountID
620 }
621 _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs)
622 }()
623 // Only update the view if the user is still on this folder
624 if m.folderInbox.GetCurrentFolder() != msg.FolderName {
625 return m, nil
626 }
627 m.emails = msg.Emails
628 m.emailsByAcct = make(map[string][]fetcher.Email)
629 for _, email := range msg.Emails {
630 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
631 }
632 m.folderInbox.SetEmails(msg.Emails, m.config.Accounts)
633 m.folderInbox.GetInbox().SetFolderName(msg.FolderName)
634 m.folderInbox.SetLoadingEmails(false)
635 m.syncPluginStatus()
636 m.syncPluginKeyBindings()
637 return m, m.pluginNotifyCmd()
638
639 case tui.FetchFolderMoreEmailsMsg:
640 if msg.AccountID == "" || m.config == nil {
641 return m, nil
642 }
643 account := m.config.GetAccountByID(msg.AccountID)
644 if account == nil {
645 return m, nil
646 }
647 limit := uint32(paginationLimit)
648 if msg.Limit > 0 {
649 limit = msg.Limit
650 }
651 return m, tea.Batch(
652 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
653 fetchFolderEmailsPaginatedCmd(account, msg.FolderName, limit, msg.Offset),
654 )
655
656 case tui.FolderEmailsAppendedMsg:
657 // Ignore stale appends for a folder the user has moved away from
658 if m.folderInbox == nil || m.folderInbox.GetCurrentFolder() != msg.FolderName {
659 return m, nil
660 }
661 m.folderInbox.Update(msg)
662 // Update local stores and per-folder cache
663 for _, email := range msg.Emails {
664 m.emails = append(m.emails, email)
665 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
666 }
667 m.folderEmails[msg.FolderName] = append(m.folderEmails[msg.FolderName], msg.Emails...)
668 go saveFolderEmailsToCache(msg.FolderName, m.folderEmails[msg.FolderName])
669 return m, nil
670
671 case tui.MoveEmailToFolderMsg:
672 if m.config == nil {
673 return m, nil
674 }
675 account := m.config.GetAccountByID(msg.AccountID)
676 if account == nil {
677 return m, nil
678 }
679 m.previousModel = m.current
680 m.current = tui.NewStatus("Moving email...")
681 return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder))
682
683 case tui.UpdatePreviewMsg:
684 // Trigger preview body fetch
685 if m.folderInbox == nil {
686 return m, nil
687 }
688 folderName := m.folderInbox.GetCurrentFolder()
689 // Check cache first
690 if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
691 var attachments []fetcher.Attachment
692 for _, ca := range cached.Attachments {
693 att := fetcher.Attachment{
694 Filename: ca.Filename,
695 PartID: ca.PartID,
696 Encoding: ca.Encoding,
697 MIMEType: ca.MIMEType,
698 ContentID: ca.ContentID,
699 Inline: ca.Inline,
700 IsSMIMESignature: ca.IsSMIMESignature,
701 SMIMEVerified: ca.SMIMEVerified,
702 IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
703 IsCalendarInvite: ca.IsCalendarInvite,
704 }
705 if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
706 att.Data = ca.CalendarData
707 }
708 attachments = append(attachments, att)
709 }
710 return m, func() tea.Msg {
711 return tui.PreviewBodyFetchedMsg{
712 UID: msg.UID,
713 Body: cached.Body,
714 Attachments: attachments,
715 AccountID: msg.AccountID,
716 }
717 }
718 }
719 return m, fetchPreviewBodyCmd(m.config, msg.UID, msg.AccountID, folderName)
720
721 case tui.PreviewBodyFetchedMsg:
722 // Cache body and forward to FolderInbox
723 if msg.Err == nil && m.folderInbox != nil {
724 folderName := m.folderInbox.GetCurrentFolder()
725 var cachedAttachments []config.CachedAttachment
726 for _, a := range msg.Attachments {
727 cachedAttachments = append(cachedAttachments, config.CachedAttachment{
728 Filename: a.Filename,
729 PartID: a.PartID,
730 Encoding: a.Encoding,
731 MIMEType: a.MIMEType,
732 ContentID: a.ContentID,
733 Inline: a.Inline,
734 })
735 }
736 go config.SaveEmailBody(folderName, config.CachedEmailBody{
737 UID: msg.UID,
738 AccountID: msg.AccountID,
739 Body: msg.Body,
740 Attachments: cachedAttachments,
741 })
742 }
743 // Forward to FolderInbox for rendering
744 if m.folderInbox != nil {
745 m.current, cmd = m.current.Update(msg)
746 return m, cmd
747 }
748 return m, nil
749
750 case tui.EmailMovedMsg:
751 if msg.Err != nil {
752 log.Printf("Move failed: %v", msg.Err)
753 if m.folderInbox != nil {
754 m.previousModel = m.folderInbox
755 }
756 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
757 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
758 return tui.RestoreViewMsg{}
759 })
760 }
761 // Remove email from current view
762 if m.folderInbox != nil {
763 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
764 m.current = m.folderInbox
765 }
766 return m, nil
767
768 case tui.CachedEmailsLoadedMsg:
769 // Cache is no longer used for the folder-based inbox flow
770 // This handler is kept for backwards compatibility but simply fetches normally
771 if m.folderInbox == nil {
772 return m, nil
773 }
774 return m, fetchFolderEmailsCmd(m.config, m.folderInbox.GetCurrentFolder())
775
776 case tui.IdleNewMailMsg:
777 // Send desktop notification for new mail (if enabled)
778 if m.config == nil || !m.config.DisableNotifications {
779 accountName := msg.AccountID
780 if m.config != nil {
781 if acc := m.config.GetAccountByID(msg.AccountID); acc != nil {
782 accountName = acc.Email
783 }
784 }
785 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", msg.FolderName, accountName))
786 }
787
788 // IDLE detected new mail — refetch the folder if we're viewing it
789 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == msg.FolderName {
790 return m, tea.Batch(
791 fetchFolderEmailsCmd(m.config, msg.FolderName),
792 listenForIdleUpdates(m.idleUpdates),
793 )
794 }
795 // Re-subscribe even if not viewing the affected folder
796 return m, listenForIdleUpdates(m.idleUpdates)
797
798 case tui.DaemonEventMsg:
799 if msg.Event == nil {
800 return m, nil
801 }
802 var cmds []tea.Cmd
803 // Re-subscribe for next event.
804 if m.service != nil && m.service.IsDaemon() {
805 cmds = append(cmds, listenForDaemonEvents(m.service.Events()))
806 }
807 switch msg.Event.Type {
808 case daemonrpc.EventNewMail:
809 var ev daemonrpc.NewMailEvent
810 if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
811 if m.config == nil || !m.config.DisableNotifications {
812 accountName := ev.AccountID
813 if m.config != nil {
814 if acc := m.config.GetAccountByID(ev.AccountID); acc != nil {
815 accountName = acc.Email
816 }
817 }
818 go notify.Send("Matcha", fmt.Sprintf("New mail in %s (%s)", ev.Folder, accountName))
819 }
820
821 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
822 cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
823 }
824 }
825 case daemonrpc.EventSyncComplete:
826 var ev daemonrpc.SyncCompleteEvent
827 if err := json.Unmarshal(msg.Event.Data, &ev); err == nil {
828 if m.folderInbox != nil && m.folderInbox.GetCurrentFolder() == ev.Folder {
829 cmds = append(cmds, fetchFolderEmailsCmd(m.config, ev.Folder))
830 }
831 }
832 }
833 return m, tea.Batch(cmds...)
834
835 case tui.RequestRefreshMsg:
836 // Folder-based refresh: clear folder cache and refetch
837 if msg.FolderName != "" && m.config != nil {
838 delete(m.folderEmails, msg.FolderName)
839 if m.folderInbox != nil {
840 m.folderInbox.SetRefreshing(true)
841 }
842 return m, fetchFolderEmailsCmd(m.config, msg.FolderName)
843 }
844 return m, tea.Batch(
845 func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
846 refreshEmails(m.config, msg.Mailbox, msg.Counts),
847 )
848
849 case tui.EmailsRefreshedMsg:
850 // Merge refreshed emails with any paginated emails already loaded.
851 for accID, refreshed := range msg.EmailsByAccount {
852 refreshedUIDs := make(map[uint32]struct{}, len(refreshed))
853 for _, e := range refreshed {
854 refreshedUIDs[e.UID] = struct{}{}
855 }
856 if existing, ok := m.emailsByAcct[accID]; ok {
857 for _, e := range existing {
858 if _, found := refreshedUIDs[e.UID]; !found {
859 refreshed = append(refreshed, e)
860 }
861 }
862 }
863 m.emailsByAcct[accID] = refreshed
864 }
865 m.emails = flattenAndSort(m.emailsByAcct)
866 m.syncUnreadBadge()
867
868 // Update folder inbox if it exists
869 if m.folderInbox != nil {
870 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
871 m.folderInbox.GetInbox().Update(msg)
872 }
873 return m, nil
874
875 case tui.AllEmailsFetchedMsg:
876 m.emailsByAcct = msg.EmailsByAccount
877 m.emails = flattenAndSort(msg.EmailsByAccount)
878 m.syncUnreadBadge()
879
880 if m.folderInbox != nil {
881 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
882 m.folderInbox.SetLoadingEmails(false)
883 }
884 return m, nil
885
886 case tui.EmailsFetchedMsg:
887 if m.emailsByAcct == nil {
888 m.emailsByAcct = make(map[string][]fetcher.Email)
889 }
890 m.emailsByAcct[msg.AccountID] = msg.Emails
891 m.emails = flattenAndSort(m.emailsByAcct)
892 m.syncUnreadBadge()
893
894 if m.folderInbox != nil {
895 m.folderInbox.SetEmails(m.emails, m.config.Accounts)
896 }
897 return m, nil
898
899 case tui.FetchMoreEmailsMsg:
900 if msg.AccountID == "" {
901 return m, nil
902 }
903 account := m.config.GetAccountByID(msg.AccountID)
904 if account == nil {
905 return m, nil
906 }
907 limit := uint32(paginationLimit)
908 if msg.Limit > 0 {
909 limit = msg.Limit
910 }
911 folderName := "INBOX"
912 if m.folderInbox != nil {
913 folderName = m.folderInbox.GetCurrentFolder()
914 }
915 return m, tea.Batch(
916 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
917 fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset),
918 )
919
920 case tui.EmailsAppendedMsg:
921 if m.emailsByAcct == nil {
922 m.emailsByAcct = make(map[string][]fetcher.Email)
923 }
924 unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
925 m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
926 m.emails = append(m.emails, unique...)
927 m.syncUnreadBadge()
928 return m, nil
929
930 case tui.GoToSendMsg:
931 hideTips := false
932 if m.config != nil {
933 hideTips = m.config.HideTips
934 }
935 if m.config != nil && len(m.config.Accounts) > 0 {
936 firstAccount := m.config.GetFirstAccount()
937 composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body, hideTips)
938 m.current = composer
939 } else {
940 m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body, hideTips)
941 }
942 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
943 m.syncPluginKeyBindings()
944 return m, m.current.Init()
945
946 case tui.GoToDraftsMsg:
947 drafts := config.GetAllDrafts()
948 m.current = tui.NewDrafts(drafts)
949 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
950 return m, m.current.Init()
951
952 case tui.OpenDraftMsg:
953 var accounts []config.Account
954 hideTips := false
955 if m.config != nil {
956 accounts = m.config.Accounts
957 hideTips = m.config.HideTips
958 }
959 composer := tui.NewComposerFromDraft(msg.Draft, accounts, hideTips)
960 m.current = composer
961 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
962 m.syncPluginKeyBindings()
963 return m, m.current.Init()
964
965 case tui.DeleteSavedDraftMsg:
966 go func() {
967 if err := config.DeleteDraft(msg.DraftID); err != nil {
968 log.Printf("Error deleting draft: %v", err)
969 }
970 }()
971 // Send message back to drafts view
972 m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
973 return m, cmd
974
975 case tui.GoToMarketplaceMsg:
976 m.current = tui.NewMarketplace(false)
977 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
978 return m, m.current.Init()
979
980 case tui.LanguageChangedMsg:
981 // Rebuild all models with new translations
982 // Keep current view type but recreate with fresh i18n
983 switch curr := m.current.(type) {
984 case *tui.Settings:
985 // Preserve settings state when rebuilding
986 newSettings := tui.NewSettings(m.config)
987 newSettings.RestoreState(curr.GetState())
988 m.current = newSettings
989 case *tui.Composer:
990 // Preserve composer state if possible, for now just refresh
991 m.current = tui.NewChoice()
992 case *tui.Inbox:
993 m.current = tui.NewChoice()
994 case *tui.FolderInbox:
995 // Just rebuild settings view, folder inbox will be recreated on next navigation
996 m.current = tui.NewSettings(m.config)
997 default:
998 // For other views, return to choice menu
999 m.current = tui.NewChoice()
1000 }
1001 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1002 return m, m.current.Init()
1003
1004 case tui.GoToSettingsMsg:
1005 m.current = tui.NewSettings(m.config)
1006 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1007 return m, m.current.Init()
1008
1009 case tui.GoToAddAccountMsg:
1010 hideTips := false
1011 if m.config != nil {
1012 hideTips = m.config.HideTips
1013 }
1014 m.current = tui.NewLogin(hideTips)
1015 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1016 return m, m.current.Init()
1017
1018 case tui.GoToAddMailingListMsg:
1019 m.current = tui.NewMailingListEditor()
1020 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1021 return m, m.current.Init()
1022
1023 case tui.GoToEditAccountMsg:
1024 hideTips := false
1025 if m.config != nil {
1026 hideTips = m.config.HideTips
1027 }
1028 login := tui.NewLogin(hideTips)
1029 login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
1030 m.current = login
1031 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1032 return m, m.current.Init()
1033
1034 case tui.GoToEditMailingListMsg:
1035 editor := tui.NewMailingListEditor()
1036 editor.SetEditMode(msg.Index, msg.Name, msg.Addresses)
1037 m.current = editor
1038 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1039 return m, m.current.Init()
1040
1041 case tui.SaveMailingListMsg:
1042 if m.config != nil {
1043 var addrs []string
1044 for _, part := range strings.Split(msg.Addresses, ",") {
1045 if trimmed := strings.TrimSpace(part); trimmed != "" {
1046 addrs = append(addrs, trimmed)
1047 }
1048 }
1049 if msg.EditIndex >= 0 && msg.EditIndex < len(m.config.MailingLists) {
1050 m.config.MailingLists[msg.EditIndex] = config.MailingList{
1051 Name: msg.Name,
1052 Addresses: addrs,
1053 }
1054 } else {
1055 m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
1056 Name: msg.Name,
1057 Addresses: addrs,
1058 })
1059 }
1060 if err := config.SaveConfig(m.config); err != nil {
1061 log.Printf("could not save config: %v", err)
1062 }
1063 }
1064 // Return to settings
1065 m.current = tui.NewSettings(m.config)
1066 // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
1067 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1068 return m, m.current.Init()
1069
1070 case tui.GoToSignatureEditorMsg:
1071 m.current = tui.NewSignatureEditor(msg.AccountID)
1072 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1073 return m, m.current.Init()
1074
1075 case tui.PasswordVerifiedMsg:
1076 if msg.Err != nil {
1077 // Error is handled inside PasswordPrompt itself
1078 return m, nil
1079 }
1080 // Password verified — set session key and load config
1081 config.SetSessionKey(msg.Key)
1082 cfg, err := config.LoadConfig()
1083 if err == nil {
1084 if cfg.Theme != "" {
1085 theme.SetTheme(cfg.Theme)
1086 tui.RebuildStyles()
1087 }
1088 // Set language from config
1089 lang := i18n.DetectLanguage(cfg)
1090 log.Printf("Detected language: %s", lang)
1091 if err := i18n.GetManager().SetLanguage(lang); err != nil {
1092 log.Printf("Failed to set language %s: %v", lang, err)
1093 } else {
1094 log.Printf("Language set to: %s", i18n.GetManager().GetLanguage())
1095 log.Printf("Test translation: %s", i18n.GetManager().T("composer.title"))
1096 }
1097 }
1098 _ = config.EnsurePGPDir()
1099 if err != nil {
1100 m.config = nil
1101 hideTips := false
1102 m.current = tui.NewLogin(hideTips)
1103 } else {
1104 m.config = cfg
1105 if m.mailtoURL != nil {
1106 to := m.mailtoURL.Opaque
1107 if to == "" {
1108 to = m.mailtoURL.Path
1109 }
1110 if to == "" {
1111 to = m.mailtoURL.Query().Get("to")
1112 }
1113 subject := m.mailtoURL.Query().Get("subject")
1114 body := m.mailtoURL.Query().Get("body")
1115 m.current = tui.NewComposerWithAccounts(cfg.Accounts, cfg.Accounts[0].ID, to, subject, body, cfg.HideTips)
1116 } else {
1117 m.current = tui.NewChoice()
1118 }
1119 }
1120 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1121 return m, m.current.Init()
1122
1123 case tui.SecureModeEnabledMsg:
1124 if msg.Err != nil {
1125 log.Printf("Failed to enable encryption: %v", msg.Err)
1126 }
1127 return m, nil
1128
1129 case tui.SecureModeDisabledMsg:
1130 if msg.Err != nil {
1131 log.Printf("Failed to disable encryption: %v", msg.Err)
1132 }
1133 return m, nil
1134
1135 case tui.GoToChoiceMenuMsg:
1136 m.current = tui.NewChoice()
1137 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1138 return m, m.current.Init()
1139
1140 case tui.DeleteAccountMsg:
1141 if m.config != nil {
1142 m.config.RemoveAccount(msg.AccountID)
1143 if err := config.SaveConfig(m.config); err != nil {
1144 log.Printf("could not save config: %v", err)
1145 }
1146 // Remove emails for this account
1147 delete(m.emailsByAcct, msg.AccountID)
1148
1149 // Rebuild all emails
1150 var allEmails []fetcher.Email
1151 for _, emails := range m.emailsByAcct {
1152 allEmails = append(allEmails, emails...)
1153 }
1154 m.emails = allEmails
1155
1156 // Go back to settings
1157 m.current = tui.NewSettings(m.config)
1158 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1159 }
1160 return m, m.current.Init()
1161
1162 case tui.ViewEmailMsg:
1163 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1164 if email == nil {
1165 return m, nil
1166 }
1167 folderName := "INBOX"
1168 if m.folderInbox != nil {
1169 folderName = m.folderInbox.GetCurrentFolder()
1170 }
1171 if m.plugins != nil {
1172 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName)
1173 m.plugins.CallHook(plugin.HookEmailViewed, t)
1174 }
1175 // Split pane mode: open in split view instead of full screen
1176 if m.config.EnableSplitPane && m.folderInbox != nil {
1177 m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID)
1178 m.current = m.folderInbox
1179 // Mark as read
1180 if !email.IsRead {
1181 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1182 account := m.config.GetAccountByID(msg.AccountID)
1183 if account != nil {
1184 cmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1185 }
1186 }
1187 // Fetch body
1188 return m, tea.Batch(cmd, func() tea.Msg {
1189 return tui.UpdatePreviewMsg{UID: msg.UID, AccountID: msg.AccountID}
1190 })
1191 }
1192 // Check body cache first
1193 if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil {
1194 // Convert cached attachments back to fetcher.Attachment
1195 var attachments []fetcher.Attachment
1196 for _, ca := range cached.Attachments {
1197 att := fetcher.Attachment{
1198 Filename: ca.Filename,
1199 PartID: ca.PartID,
1200 Encoding: ca.Encoding,
1201 MIMEType: ca.MIMEType,
1202 ContentID: ca.ContentID,
1203 Inline: ca.Inline,
1204 IsSMIMESignature: ca.IsSMIMESignature,
1205 SMIMEVerified: ca.SMIMEVerified,
1206 IsSMIMEEncrypted: ca.IsSMIMEEncrypted,
1207 IsCalendarInvite: ca.IsCalendarInvite,
1208 }
1209 if ca.IsCalendarInvite && len(ca.CalendarData) > 0 {
1210 att.Data = ca.CalendarData
1211 }
1212 attachments = append(attachments, att)
1213 }
1214 return m, func() tea.Msg {
1215 return tui.EmailBodyFetchedMsg{
1216 UID: msg.UID,
1217 Body: cached.Body,
1218 Attachments: attachments,
1219 AccountID: msg.AccountID,
1220 Mailbox: msg.Mailbox,
1221 }
1222 }
1223 }
1224 m.current = tui.NewStatus("Fetching email content...")
1225 return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd())
1226
1227 case tui.EmailBodyFetchedMsg:
1228 if msg.Err != nil {
1229 log.Printf("could not fetch email body: %v", msg.Err)
1230 if m.folderInbox != nil {
1231 m.current = m.folderInbox
1232 }
1233 return m, nil
1234 }
1235
1236 // Update the email in our stores
1237 m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments)
1238
1239 // Cache the body to disk
1240 folderForCache := "INBOX"
1241 if m.folderInbox != nil {
1242 folderForCache = m.folderInbox.GetCurrentFolder()
1243 }
1244 var cachedAttachments []config.CachedAttachment
1245 for _, a := range msg.Attachments {
1246 ca := config.CachedAttachment{
1247 Filename: a.Filename,
1248 PartID: a.PartID,
1249 Encoding: a.Encoding,
1250 MIMEType: a.MIMEType,
1251 ContentID: a.ContentID,
1252 Inline: a.Inline,
1253 IsSMIMESignature: a.IsSMIMESignature,
1254 SMIMEVerified: a.SMIMEVerified,
1255 IsSMIMEEncrypted: a.IsSMIMEEncrypted,
1256 IsCalendarInvite: a.IsCalendarInvite,
1257 }
1258 if a.IsCalendarInvite && len(a.Data) > 0 {
1259 ca.CalendarData = a.Data
1260 }
1261 cachedAttachments = append(cachedAttachments, ca)
1262 }
1263 _ = config.SaveEmailBody(folderForCache, config.CachedEmailBody{
1264 UID: msg.UID,
1265 AccountID: msg.AccountID,
1266 Body: msg.Body,
1267 Attachments: cachedAttachments,
1268 })
1269
1270 email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox)
1271 if email == nil {
1272 if m.folderInbox != nil {
1273 m.current = m.folderInbox
1274 }
1275 return m, nil
1276 }
1277
1278 // Mark as read in UI immediately and on the server
1279 var markReadCmd tea.Cmd
1280 if !email.IsRead {
1281 m.markEmailAsReadInStores(msg.UID, msg.AccountID)
1282
1283 folderName := "INBOX"
1284 if m.folderInbox != nil {
1285 folderName = m.folderInbox.GetCurrentFolder()
1286 }
1287 account := m.config.GetAccountByID(msg.AccountID)
1288 if account != nil {
1289 markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
1290 }
1291 }
1292
1293 // Find the index for the email view (used for display purposes)
1294 emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
1295 emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
1296 m.current = emailView
1297 m.syncPluginStatus()
1298 m.syncPluginKeyBindings()
1299 cmds := []tea.Cmd{m.current.Init()}
1300 if markReadCmd != nil {
1301 cmds = append(cmds, markReadCmd)
1302 }
1303 return m, tea.Batch(cmds...)
1304
1305 case tui.ReplyToEmailMsg:
1306 var to string
1307 if len(msg.Email.ReplyTo) > 0 {
1308 to = strings.Join(msg.Email.ReplyTo, ", ")
1309 } else {
1310 to = msg.Email.From
1311 }
1312 subject := msg.Email.Subject
1313 normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
1314 if !strings.HasPrefix(normalizedSubject, "re:") {
1315 subject = "Re: " + subject
1316 }
1317 quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Local().Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
1318
1319 var composer *tui.Composer
1320 hideTips := false
1321 if m.config != nil {
1322 hideTips = m.config.HideTips
1323 }
1324 if m.config != nil && len(m.config.Accounts) > 0 {
1325 // Use the account that received the email
1326 accountID := msg.Email.AccountID
1327 if accountID == "" {
1328 accountID = m.config.GetFirstAccount().ID
1329 }
1330 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, "", hideTips)
1331 } else {
1332 composer = tui.NewComposer("", to, subject, "", hideTips)
1333 }
1334 composer.SetQuotedText(quotedText)
1335
1336 // Set reply headers
1337 inReplyTo := msg.Email.MessageID
1338 references := append(msg.Email.References, msg.Email.MessageID)
1339 composer.SetReplyContext(inReplyTo, references)
1340
1341 m.current = composer
1342 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1343 m.syncPluginKeyBindings()
1344 return m, m.current.Init()
1345
1346 case tui.ForwardEmailMsg:
1347 subject := msg.Email.Subject
1348 if !strings.HasPrefix(strings.ToLower(subject), "fwd:") {
1349 subject = "Fwd: " + subject
1350 }
1351
1352 forwardHeader := fmt.Sprintf("\n\n---------- Forwarded message ----------\nFrom: %s\nDate: %s\nSubject: %s\nTo: %s\n\n",
1353 msg.Email.From,
1354 msg.Email.Date.Local().Format("Mon, Jan 2, 2006 at 3:04 PM"),
1355 msg.Email.Subject,
1356 msg.Email.To,
1357 )
1358
1359 body := forwardHeader + msg.Email.Body
1360
1361 var composer *tui.Composer
1362 hideTips := false
1363 if m.config != nil {
1364 hideTips = m.config.HideTips
1365 }
1366 if m.config != nil && len(m.config.Accounts) > 0 {
1367 // Use the account that received the email
1368 accountID := msg.Email.AccountID
1369 if accountID == "" {
1370 accountID = m.config.GetFirstAccount().ID
1371 }
1372 composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, "", subject, body, hideTips)
1373 } else {
1374 composer = tui.NewComposer("", "", subject, body, hideTips)
1375 }
1376
1377 m.current = composer
1378 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1379 m.syncPluginKeyBindings()
1380 return m, m.current.Init()
1381
1382 case tui.OpenEditorMsg:
1383 composer, ok := m.current.(*tui.Composer)
1384 if !ok {
1385 return m, nil
1386 }
1387 return m, openExternalEditor(composer.GetBody())
1388
1389 case tui.EditorFinishedMsg:
1390 if msg.Err != nil {
1391 log.Printf("Editor error: %v", msg.Err)
1392 return m, nil
1393 }
1394 if composer, ok := m.current.(*tui.Composer); ok {
1395 composer.SetBody(msg.Body)
1396 }
1397 return m, nil
1398
1399 case tui.GoToFilePickerMsg:
1400 if runtime.GOOS == "darwin" {
1401 return m, func() tea.Msg {
1402 wd, _ := os.Getwd()
1403 paths, err := macos.OpenFilePicker(wd)
1404 if err != nil || len(paths) == 0 {
1405 return tui.CancelFilePickerMsg{}
1406 }
1407 return tui.FileSelectedMsg{Paths: paths}
1408 }
1409 }
1410 m.previousModel = m.current
1411 wd, _ := os.Getwd()
1412 m.current = tui.NewFilePicker(wd)
1413 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1414 return m, m.current.Init()
1415
1416 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
1417 if m.previousModel != nil {
1418 m.current = m.previousModel
1419 m.previousModel = nil
1420 }
1421 m.current, cmd = m.current.Update(msg)
1422 cmds = append(cmds, cmd)
1423
1424 case tui.SendEmailMsg:
1425 if m.plugins != nil {
1426 m.plugins.CallSendHook(plugin.HookEmailSendBefore, msg.To, msg.Cc, msg.Subject, msg.AccountID)
1427 }
1428 // Get draft ID before clearing composer (if it's a composer)
1429 var draftID string
1430 if composer, ok := m.current.(*tui.Composer); ok {
1431 draftID = composer.GetDraftID()
1432 }
1433 // Get the account to send from
1434 var account *config.Account
1435 if msg.AccountID != "" && m.config != nil {
1436 account = m.config.GetAccountByID(msg.AccountID)
1437 }
1438 if account == nil && m.config != nil {
1439 account = m.config.GetFirstAccount()
1440 }
1441
1442 statusText := "Sending email..."
1443 if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" {
1444 statusText = "Touch your YubiKey to sign..."
1445 }
1446 m.current = tui.NewStatus(statusText)
1447
1448 // Save contact and delete draft in background
1449 go func() {
1450 // Save the recipient as a contact
1451 if msg.To != "" {
1452 recipients := strings.Split(msg.To, ",")
1453 for _, r := range recipients {
1454 r = strings.TrimSpace(r)
1455 if r == "" {
1456 continue
1457 }
1458 name, email := parseEmailAddress(r)
1459 if err := config.AddContact(name, email); err != nil {
1460 log.Printf("Error saving contact: %v", err)
1461 }
1462 }
1463 }
1464 // Delete the draft since email is being sent
1465 if draftID != "" {
1466 if err := config.DeleteDraft(draftID); err != nil {
1467 log.Printf("Error deleting draft after send: %v", err)
1468 }
1469 }
1470 }()
1471
1472 return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
1473
1474 case tui.SendRSVPMsg:
1475 account := m.config.GetAccountByID(msg.AccountID)
1476 if account == nil {
1477 m.current = tui.NewStatus("Error: account not found")
1478 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1479 return tui.RestoreViewMsg{}
1480 })
1481 }
1482
1483 m.current = tui.NewStatus("Sending RSVP...")
1484 return m, tea.Batch(m.current.Init(), sendRSVP(account, msg))
1485
1486 case tui.RSVPResultMsg:
1487 if msg.Err != nil {
1488 log.Printf("Failed to send RSVP: %v", msg.Err)
1489 m.previousModel = tui.NewChoice()
1490 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1491 m.current = tui.NewStatus(fmt.Sprintf("RSVP error: %v", msg.Err))
1492 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1493 return tui.RestoreViewMsg{}
1494 })
1495 }
1496 status := fmt.Sprintf("RSVP sent: %s", msg.Response)
1497 if strings.HasSuffix(strings.ToLower(msg.Organizer), "@gmail.com") || strings.HasSuffix(strings.ToLower(msg.Organizer), "@googlemail.com") {
1498 status += " (Google Calendar may not auto-update — use Gmail buttons for Google events)"
1499 }
1500 m.current = tui.NewStatus(status)
1501 return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
1502 return tui.RestoreViewMsg{}
1503 })
1504
1505 case tui.EmailResultMsg:
1506 if msg.Err != nil {
1507 log.Printf("Failed to send email: %v", msg.Err)
1508 m.previousModel = tui.NewChoice()
1509 m.previousModel, _ = m.previousModel.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1510 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1511 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1512 return tui.RestoreViewMsg{}
1513 })
1514 }
1515 if m.plugins != nil {
1516 m.plugins.CallHook(plugin.HookEmailSendAfter)
1517 }
1518 m.current = tui.NewChoice()
1519 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1520 return m, m.current.Init()
1521
1522 case tui.DeleteEmailMsg:
1523 tui.ClearKittyGraphics()
1524 m.previousModel = m.current
1525 m.current = tui.NewStatus("Deleting email...")
1526
1527 account := m.config.GetAccountByID(msg.AccountID)
1528 if account == nil {
1529 if m.folderInbox != nil {
1530 m.current = m.folderInbox
1531 }
1532 return m, nil
1533 }
1534
1535 folderName := "INBOX"
1536 if m.folderInbox != nil {
1537 folderName = m.folderInbox.GetCurrentFolder()
1538 }
1539 return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1540
1541 case tui.ArchiveEmailMsg:
1542 tui.ClearKittyGraphics()
1543 m.previousModel = m.current
1544 m.current = tui.NewStatus("Archiving email...")
1545
1546 account := m.config.GetAccountByID(msg.AccountID)
1547 if account == nil {
1548 if m.folderInbox != nil {
1549 m.current = m.folderInbox
1550 }
1551 return m, nil
1552 }
1553
1554 folderName := "INBOX"
1555 if m.folderInbox != nil {
1556 folderName = m.folderInbox.GetCurrentFolder()
1557 }
1558 return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
1559
1560 case tui.EmailMarkedReadMsg:
1561 if msg.Err != nil {
1562 log.Printf("Error marking email as read: %v", msg.Err)
1563 }
1564 m.syncUnreadBadge()
1565 return m, nil
1566
1567 case tui.EmailActionDoneMsg:
1568 if msg.Err != nil {
1569 log.Printf("Action failed: %v", msg.Err)
1570 if m.folderInbox != nil {
1571 m.previousModel = m.folderInbox
1572 }
1573 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1574 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1575 return tui.RestoreViewMsg{}
1576 })
1577 }
1578
1579 // Remove email from stores
1580 m.removeEmailFromStores(msg.UID, msg.AccountID)
1581
1582 if m.folderInbox != nil {
1583 m.folderInbox.RemoveEmail(msg.UID, msg.AccountID)
1584 m.current = m.folderInbox
1585 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1586 return m, m.current.Init()
1587 }
1588 m.current = tui.NewChoice()
1589 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
1590 return m, m.current.Init()
1591
1592 case tui.BatchDeleteEmailsMsg:
1593 tui.ClearKittyGraphics()
1594 m.previousModel = m.current
1595 count := len(msg.UIDs)
1596 m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count))
1597
1598 account := m.config.GetAccountByID(msg.AccountID)
1599 if account == nil {
1600 if m.folderInbox != nil {
1601 m.current = m.folderInbox
1602 }
1603 return m, nil
1604 }
1605
1606 folderName := "INBOX"
1607 if m.folderInbox != nil {
1608 folderName = m.folderInbox.GetCurrentFolder()
1609 }
1610
1611 return m, tea.Batch(
1612 m.current.Init(),
1613 m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1614 )
1615
1616 case tui.BatchArchiveEmailsMsg:
1617 tui.ClearKittyGraphics()
1618 m.previousModel = m.current
1619 count := len(msg.UIDs)
1620 m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count))
1621
1622 account := m.config.GetAccountByID(msg.AccountID)
1623 if account == nil {
1624 if m.folderInbox != nil {
1625 m.current = m.folderInbox
1626 }
1627 return m, nil
1628 }
1629
1630 folderName := "INBOX"
1631 if m.folderInbox != nil {
1632 folderName = m.folderInbox.GetCurrentFolder()
1633 }
1634
1635 return m, tea.Batch(
1636 m.current.Init(),
1637 m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
1638 )
1639
1640 case tui.BatchMoveEmailsMsg:
1641 if m.config == nil {
1642 return m, nil
1643 }
1644 account := m.config.GetAccountByID(msg.AccountID)
1645 if account == nil {
1646 return m, nil
1647 }
1648
1649 count := len(msg.UIDs)
1650 m.previousModel = m.current
1651 m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count))
1652
1653 return m, tea.Batch(
1654 m.current.Init(),
1655 m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count),
1656 )
1657
1658 case tui.BatchEmailActionDoneMsg:
1659 if msg.Err != nil {
1660 log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
1661 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
1662 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1663 return tui.RestoreViewMsg{}
1664 })
1665 }
1666
1667 // Success - show brief confirmation
1668 successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action)
1669 if msg.FailureCount > 0 {
1670 successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)",
1671 msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount)
1672 }
1673
1674 m.current = tui.NewStatus(successMsg)
1675
1676 return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg {
1677 return tui.RestoreViewMsg{}
1678 })
1679
1680 case tui.DownloadAttachmentMsg:
1681 m.previousModel = m.current
1682 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
1683
1684 account := m.config.GetAccountByID(msg.AccountID)
1685 if account == nil {
1686 m.current = m.previousModel
1687 return m, nil
1688 }
1689
1690 email := m.getEmailByIndex(msg.Index, msg.Mailbox)
1691 if email == nil {
1692 m.current = m.previousModel
1693 return m, nil
1694 }
1695
1696 // Find the correct attachment to get encoding
1697 var encoding string
1698 for _, att := range email.Attachments {
1699 if att.PartID == msg.PartID {
1700 encoding = att.Encoding
1701 break
1702 }
1703 }
1704 newMsg := tui.DownloadAttachmentMsg{
1705 Index: msg.Index,
1706 Filename: msg.Filename,
1707 PartID: msg.PartID,
1708 Data: msg.Data,
1709 AccountID: msg.AccountID,
1710 Encoding: encoding,
1711 Mailbox: msg.Mailbox,
1712 }
1713 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
1714
1715 case tui.AttachmentDownloadedMsg:
1716 var statusMsg string
1717 if msg.Err != nil {
1718 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
1719 } else {
1720 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
1721 }
1722 m.current = tui.NewStatus(statusMsg)
1723 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
1724 return tui.RestoreViewMsg{}
1725 })
1726
1727 case tui.RestoreViewMsg:
1728 if m.previousModel != nil {
1729 m.current = m.previousModel
1730 m.previousModel = nil
1731 }
1732 return m, nil
1733 }
1734
1735 if cmd := m.pluginNotifyCmd(); cmd != nil {
1736 cmds = append(cmds, cmd)
1737 }
1738
1739 return m, tea.Batch(cmds...)
1740}
1741
1742func (m *mainModel) View() tea.View {
1743 v := m.current.View()
1744 v.AltScreen = true
1745 return v
1746}
1747
1748func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email {
1749 if index >= 0 && index < len(m.emails) {
1750 return &m.emails[index]
1751 }
1752 return nil
1753}
1754
1755func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email {
1756 for i := range m.emails {
1757 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1758 return &m.emails[i]
1759 }
1760 }
1761 return nil
1762}
1763
1764func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int {
1765 for i := range m.emails {
1766 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1767 return i
1768 }
1769 }
1770 return -1
1771}
1772
1773func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) {
1774 for i := range m.emails {
1775 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1776 m.emails[i].Body = body
1777 m.emails[i].Attachments = attachments
1778 break
1779 }
1780 }
1781 if emails, ok := m.emailsByAcct[accountID]; ok {
1782 for i := range emails {
1783 if emails[i].UID == uid {
1784 emails[i].Body = body
1785 emails[i].Attachments = attachments
1786 break
1787 }
1788 }
1789 }
1790}
1791
1792func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
1793 for i := range m.emails {
1794 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
1795 m.emails[i].IsRead = true
1796 break
1797 }
1798 }
1799 if emails, ok := m.emailsByAcct[accountID]; ok {
1800 for i := range emails {
1801 if emails[i].UID == uid {
1802 emails[i].IsRead = true
1803 break
1804 }
1805 }
1806 }
1807 // Update folder email cache
1808 for folderName, folderEmails := range m.folderEmails {
1809 for i := range folderEmails {
1810 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
1811 folderEmails[i].IsRead = true
1812 m.folderEmails[folderName] = folderEmails
1813 go saveFolderEmailsToCache(folderName, folderEmails)
1814 break
1815 }
1816 }
1817 }
1818 // Update the inbox UI
1819 if m.folderInbox != nil {
1820 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
1821 }
1822}
1823
1824func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
1825 var filtered []fetcher.Email
1826 for _, e := range m.emails {
1827 if !(e.UID == uid && e.AccountID == accountID) {
1828 filtered = append(filtered, e)
1829 }
1830 }
1831 m.emails = filtered
1832 if emails, ok := m.emailsByAcct[accountID]; ok {
1833 var filteredAcct []fetcher.Email
1834 for _, e := range emails {
1835 if e.UID != uid {
1836 filteredAcct = append(filteredAcct, e)
1837 }
1838 }
1839 m.emailsByAcct[accountID] = filteredAcct
1840 }
1841}
1842
1843// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
1844func (m *mainModel) pluginNotifyCmd() tea.Cmd {
1845 if m.plugins == nil {
1846 return nil
1847 }
1848 if n, ok := m.plugins.TakePendingNotification(); ok {
1849 return func() tea.Msg {
1850 return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
1851 }
1852 }
1853 return nil
1854}
1855
1856func (m *mainModel) syncPluginStatus() {
1857 if m.plugins == nil {
1858 return
1859 }
1860 if m.folderInbox != nil {
1861 m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
1862 }
1863 switch v := m.current.(type) {
1864 case *tui.Composer:
1865 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
1866 case *tui.EmailView:
1867 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
1868 }
1869}
1870
1871func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) {
1872 keyStr := msg.String()
1873
1874 var area string
1875 switch m.current.(type) {
1876 case *tui.Inbox:
1877 area = plugin.StatusInbox
1878 case *tui.FolderInbox:
1879 area = plugin.StatusInbox
1880 case *tui.EmailView:
1881 area = plugin.StatusEmailView
1882 case *tui.Composer:
1883 area = plugin.StatusComposer
1884 default:
1885 return
1886 }
1887
1888 bindings := m.plugins.Bindings(area)
1889 for _, binding := range bindings {
1890 if binding.Key != keyStr {
1891 continue
1892 }
1893
1894 // Build context table based on the current view
1895 switch v := m.current.(type) {
1896 case *tui.Inbox:
1897 if email := v.GetSelectedEmail(); email != nil {
1898 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1899 m.plugins.CallKeyBinding(binding, t)
1900 } else {
1901 m.plugins.CallKeyBinding(binding)
1902 }
1903 case *tui.FolderInbox:
1904 if email := v.GetInbox().GetSelectedEmail(); email != nil {
1905 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
1906 m.plugins.CallKeyBinding(binding, t)
1907 } else {
1908 m.plugins.CallKeyBinding(binding)
1909 }
1910 case *tui.EmailView:
1911 email := v.GetEmail()
1912 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
1913 m.plugins.CallKeyBinding(binding, t)
1914 case *tui.Composer:
1915 L := m.plugins.LuaState()
1916 t := L.NewTable()
1917 t.RawSetString("body", lua.LString(v.GetBody()))
1918 t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
1919 t.RawSetString("subject", lua.LString(v.GetSubject()))
1920 t.RawSetString("to", lua.LString(v.GetTo()))
1921 t.RawSetString("cc", lua.LString(v.GetCc()))
1922 t.RawSetString("bcc", lua.LString(v.GetBcc()))
1923 m.plugins.CallKeyBinding(binding, t)
1924 m.applyPluginFields(v)
1925
1926 // Check if the plugin requested a prompt overlay
1927 if p, ok := m.plugins.TakePendingPrompt(); ok {
1928 m.pendingPrompt = p
1929 v.ShowPluginPrompt(p.Placeholder)
1930 }
1931 }
1932
1933 m.syncPluginStatus()
1934 return
1935 }
1936}
1937
1938func (m *mainModel) syncPluginKeyBindings() {
1939 if m.plugins == nil {
1940 return
1941 }
1942
1943 toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
1944 result := make([]tui.PluginKeyBinding, len(bindings))
1945 for i, b := range bindings {
1946 result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
1947 }
1948 return result
1949 }
1950
1951 if m.folderInbox != nil {
1952 m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
1953 }
1954 switch v := m.current.(type) {
1955 case *tui.Composer:
1956 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
1957 case *tui.EmailView:
1958 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
1959 }
1960}
1961
1962func (m *mainModel) applyPluginFields(composer *tui.Composer) {
1963 fields := m.plugins.TakePendingFields()
1964 if fields == nil {
1965 return
1966 }
1967 for field, value := range fields {
1968 switch field {
1969 case "to":
1970 composer.SetTo(value)
1971 case "cc":
1972 composer.SetCc(value)
1973 case "bcc":
1974 composer.SetBcc(value)
1975 case "subject":
1976 composer.SetSubject(value)
1977 case "body":
1978 composer.SetBody(value)
1979 }
1980 }
1981}
1982
1983func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
1984 var allEmails []fetcher.Email
1985 for _, emails := range emailsByAccount {
1986 allEmails = append(allEmails, emails...)
1987 }
1988 for i := 0; i < len(allEmails); i++ {
1989 for j := i + 1; j < len(allEmails); j++ {
1990 if allEmails[j].Date.After(allEmails[i].Date) {
1991 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
1992 }
1993 }
1994 }
1995 return allEmails
1996}
1997
1998func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
1999 return func() tea.Msg {
2000 emailsByAccount := make(map[string][]fetcher.Email)
2001 var mu sync.Mutex
2002 var wg sync.WaitGroup
2003
2004 for _, account := range cfg.Accounts {
2005 wg.Add(1)
2006 go func(acc config.Account) {
2007 defer wg.Done()
2008 var emails []fetcher.Email
2009 var err error
2010 switch mailbox {
2011 case tui.MailboxSent:
2012 emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
2013 case tui.MailboxTrash:
2014 emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0)
2015 case tui.MailboxArchive:
2016 emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0)
2017 default:
2018 emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
2019 }
2020 if err != nil {
2021 log.Printf("Error fetching from %s: %v", acc.Email, err)
2022 return
2023 }
2024 mu.Lock()
2025 emailsByAccount[acc.ID] = emails
2026 mu.Unlock()
2027 }(account)
2028 }
2029
2030 wg.Wait()
2031 return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2032 }
2033}
2034
2035func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
2036 return func() tea.Msg {
2037 var emails []fetcher.Email
2038 var err error
2039 if mailbox == tui.MailboxSent {
2040 emails, err = fetcher.FetchSentEmails(account, limit, offset)
2041 } else {
2042 emails, err = fetcher.FetchEmails(account, limit, offset)
2043 }
2044 if err != nil {
2045 return tui.FetchErr(err)
2046 }
2047 if offset == 0 {
2048 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2049 }
2050 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2051 }
2052}
2053
2054func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd {
2055 return func() tea.Msg {
2056 var emails []fetcher.Email
2057 var err error
2058 switch mailbox {
2059 case tui.MailboxSent:
2060 emails, err = fetcher.FetchSentEmails(account, limit, offset)
2061 case tui.MailboxTrash:
2062 emails, err = fetcher.FetchTrashEmails(account, limit, offset)
2063 case tui.MailboxArchive:
2064 emails, err = fetcher.FetchArchiveEmails(account, limit, offset)
2065 default:
2066 emails, err = fetcher.FetchEmails(account, limit, offset)
2067 }
2068 if err != nil {
2069 return tui.FetchErr(err)
2070 }
2071 if offset == 0 {
2072 return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2073 }
2074 return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox}
2075 }
2076}
2077
2078func loadCachedEmails() tea.Cmd {
2079 return func() tea.Msg {
2080 cache, err := config.LoadEmailCache()
2081 if err != nil {
2082 return tui.CachedEmailsLoadedMsg{Cache: nil}
2083 }
2084 return tui.CachedEmailsLoadedMsg{Cache: cache}
2085 }
2086}
2087
2088func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
2089 return func() tea.Msg {
2090 emailsByAccount := make(map[string][]fetcher.Email)
2091 var mu sync.Mutex
2092 var wg sync.WaitGroup
2093
2094 for _, account := range cfg.Accounts {
2095 wg.Add(1)
2096 go func(acc config.Account) {
2097 defer wg.Done()
2098 var emails []fetcher.Email
2099 var err error
2100
2101 limit := uint32(initialEmailLimit)
2102 if counts != nil {
2103 if c, ok := counts[acc.ID]; ok && c > 0 {
2104 limit = uint32(c)
2105 }
2106 }
2107
2108 if mailbox == tui.MailboxSent {
2109 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
2110 } else {
2111 emails, err = fetcher.FetchEmails(&acc, limit, 0)
2112 }
2113 if err != nil {
2114 log.Printf("Error fetching from %s: %v", acc.Email, err)
2115 return
2116 }
2117 mu.Lock()
2118 emailsByAccount[acc.ID] = emails
2119 mu.Unlock()
2120 }(account)
2121 }
2122
2123 wg.Wait()
2124 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2125 }
2126}
2127
2128func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
2129 var cached []config.CachedEmail
2130 for _, email := range emails {
2131 cached = append(cached, config.CachedEmail{
2132 UID: email.UID,
2133 From: email.From,
2134 To: email.To,
2135 Subject: email.Subject,
2136 Date: email.Date,
2137 MessageID: email.MessageID,
2138 AccountID: email.AccountID,
2139 IsRead: email.IsRead,
2140 })
2141 }
2142 return cached
2143}
2144
2145func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
2146 var emails []fetcher.Email
2147 for _, c := range cached {
2148 emails = append(emails, fetcher.Email{
2149 UID: c.UID,
2150 From: c.From,
2151 To: c.To,
2152 Subject: c.Subject,
2153 Date: c.Date,
2154 MessageID: c.MessageID,
2155 AccountID: c.AccountID,
2156 IsRead: c.IsRead,
2157 })
2158 }
2159 return emails
2160}
2161
2162func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
2163 cached := emailsToCache(emails)
2164 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
2165 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
2166 }
2167}
2168
2169func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
2170 cached, err := config.LoadFolderEmailCache(folderName)
2171 if err != nil {
2172 return nil
2173 }
2174 return cacheToEmails(cached)
2175}
2176
2177func saveEmailsToCache(emails []fetcher.Email) {
2178 if len(emails) > maxCacheEmails {
2179 emails = emails[:maxCacheEmails]
2180 }
2181 var cachedEmails []config.CachedEmail
2182 for _, email := range emails {
2183 cachedEmails = append(cachedEmails, config.CachedEmail{
2184 UID: email.UID,
2185 From: email.From,
2186 To: email.To,
2187 Subject: email.Subject,
2188 Date: email.Date,
2189 MessageID: email.MessageID,
2190 AccountID: email.AccountID,
2191 IsRead: email.IsRead,
2192 })
2193
2194 // Save sender as a contact
2195 if email.From != "" {
2196 name, emailAddr := parseEmailAddress(email.From)
2197 if err := config.AddContact(name, emailAddr); err != nil {
2198 log.Printf("Error saving contact from email: %v", err)
2199 }
2200 }
2201 }
2202 cache := &config.EmailCache{Emails: cachedEmails}
2203 if err := config.SaveEmailCache(cache); err != nil {
2204 log.Printf("Error saving email cache: %v", err)
2205 }
2206}
2207
2208// parseEmailAddress parses "Name <email>" or just "email" format
2209func parseEmailAddress(addr string) (name, email string) {
2210 addr = strings.TrimSpace(addr)
2211 if idx := strings.Index(addr, "<"); idx != -1 {
2212 name = strings.TrimSpace(addr[:idx])
2213 endIdx := strings.Index(addr, ">")
2214 if endIdx > idx {
2215 email = strings.TrimSpace(addr[idx+1 : endIdx])
2216 } else {
2217 email = strings.TrimSpace(addr[idx+1:])
2218 }
2219 } else {
2220 email = addr
2221 }
2222 return name, email
2223}
2224
2225func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2226 return func() tea.Msg {
2227 account := cfg.GetAccountByID(accountID)
2228 if account == nil {
2229 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2230 }
2231
2232 var (
2233 body string
2234 attachments []fetcher.Attachment
2235 err error
2236 )
2237 switch mailbox {
2238 case tui.MailboxSent:
2239 body, attachments, err = fetcher.FetchSentEmailBody(account, uid)
2240 case tui.MailboxTrash:
2241 body, attachments, err = fetcher.FetchTrashEmailBody(account, uid)
2242 case tui.MailboxArchive:
2243 body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid)
2244 default:
2245 body, attachments, err = fetcher.FetchEmailBody(account, uid)
2246 }
2247 if err != nil {
2248 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2249 }
2250
2251 return tui.EmailBodyFetchedMsg{
2252 UID: uid,
2253 Body: body,
2254 Attachments: attachments,
2255 AccountID: accountID,
2256 Mailbox: mailbox,
2257 }
2258 }
2259}
2260
2261func markdownToHTML(md []byte) []byte {
2262 return clib.MarkdownToHTML(md)
2263}
2264
2265func splitEmails(s string) []string {
2266 if s == "" {
2267 return nil
2268 }
2269 parts := strings.Split(s, ",")
2270 var res []string
2271 for _, p := range parts {
2272 if trimmed := strings.TrimSpace(p); trimmed != "" {
2273 res = append(res, trimmed)
2274 }
2275 }
2276 return res
2277}
2278
2279func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
2280 return func() tea.Msg {
2281 if account == nil {
2282 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2283 }
2284
2285 recipients := splitEmails(msg.To)
2286 cc := splitEmails(msg.Cc)
2287 bcc := splitEmails(msg.Bcc)
2288 body := msg.Body
2289 // Append signature if present
2290 if msg.Signature != "" {
2291 body = body + "\n\n" + msg.Signature
2292 }
2293 // Append quoted text if present (for replies)
2294 if msg.QuotedText != "" {
2295 body = body + msg.QuotedText
2296 }
2297 images := make(map[string][]byte)
2298 attachments := make(map[string][]byte)
2299
2300 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
2301 matches := re.FindAllStringSubmatch(body, -1)
2302
2303 for _, match := range matches {
2304 imgPath := match[1]
2305 imgData, err := os.ReadFile(imgPath)
2306 if err != nil {
2307 log.Printf("Could not read image file %s: %v", imgPath, err)
2308 continue
2309 }
2310 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
2311 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
2312 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
2313 }
2314
2315 htmlBody := markdownToHTML([]byte(body))
2316
2317 for _, attachPath := range msg.AttachmentPaths {
2318 fileData, err := os.ReadFile(attachPath)
2319 if err != nil {
2320 log.Printf("Could not read attachment file %s: %v", attachPath, err)
2321 continue
2322 }
2323 _, filename := filepath.Split(attachPath)
2324 attachments[filename] = fileData
2325 }
2326
2327 rawMsg, err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, false)
2328 if err != nil {
2329 log.Printf("Failed to send email: %v", err)
2330 return tui.EmailResultMsg{Err: err}
2331 }
2332
2333 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
2334 if account.ServiceProvider != "gmail" {
2335 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2336 log.Printf("Failed to append sent message to Sent folder: %v", err)
2337 }
2338 }
2339
2340 return tui.EmailResultMsg{}
2341 }
2342}
2343
2344func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
2345 return func() tea.Msg {
2346 if account == nil {
2347 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2348 }
2349
2350 // Generate RSVP .ics
2351 rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
2352 if err != nil {
2353 return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
2354 }
2355
2356 // Compose reply email
2357 subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
2358 bodyText := fmt.Sprintf("%s: %s\n\n%s",
2359 msg.Response,
2360 msg.Event.Summary,
2361 msg.Event.Start.Local().Format("Mon Jan 2, 2006 3:04 PM"))
2362 if msg.Event.Location != "" {
2363 bodyText += " at " + msg.Event.Location
2364 }
2365
2366 // Send as multipart/alternative with text/calendar; method=REPLY
2367 // This iMIP format is required for Google Calendar to recognize the RSVP
2368 references := append(msg.References, msg.InReplyTo)
2369 rawMsg, err := sender.SendCalendarReply(
2370 account,
2371 []string{msg.Event.Organizer},
2372 subject,
2373 bodyText,
2374 rsvpICS,
2375 msg.InReplyTo,
2376 references,
2377 )
2378
2379 if err != nil {
2380 return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
2381 }
2382
2383 // Append to Sent folder
2384 if account.ServiceProvider != "gmail" {
2385 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
2386 log.Printf("Failed to append RSVP to Sent folder: %v", err)
2387 }
2388 }
2389
2390 return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
2391 }
2392}
2393
2394func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2395 return func() tea.Msg {
2396 var err error
2397 switch mailbox {
2398 case tui.MailboxSent:
2399 err = fetcher.DeleteSentEmail(account, uid)
2400 case tui.MailboxTrash:
2401 err = fetcher.DeleteTrashEmail(account, uid)
2402 case tui.MailboxArchive:
2403 err = fetcher.DeleteArchiveEmail(account, uid)
2404 default:
2405 err = fetcher.DeleteEmail(account, uid)
2406 }
2407 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2408 }
2409}
2410
2411func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd {
2412 return func() tea.Msg {
2413 var err error
2414 if mailbox == tui.MailboxSent {
2415 err = fetcher.ArchiveSentEmail(account, uid)
2416 } else {
2417 err = fetcher.ArchiveEmail(account, uid)
2418 }
2419 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2420 }
2421}
2422
2423// --- External editor command ---
2424
2425// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
2426func openExternalEditor(body string) tea.Cmd {
2427 editor := os.Getenv("EDITOR")
2428 if editor == "" {
2429 editor = os.Getenv("VISUAL")
2430 }
2431 if editor == "" {
2432 editor = "vi"
2433 }
2434
2435 tmpFile, err := os.CreateTemp("", "matcha-*.md")
2436 if err != nil {
2437 return func() tea.Msg {
2438 return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
2439 }
2440 }
2441 tmpPath := tmpFile.Name()
2442
2443 if _, err := tmpFile.WriteString(body); err != nil {
2444 tmpFile.Close()
2445 os.Remove(tmpPath)
2446 return func() tea.Msg {
2447 return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
2448 }
2449 }
2450 tmpFile.Close()
2451
2452 parts := strings.Fields(editor)
2453 args := append(parts[1:], tmpPath)
2454 c := exec.Command(parts[0], args...)
2455 return tea.ExecProcess(c, func(err error) tea.Msg {
2456 defer os.Remove(tmpPath)
2457 if err != nil {
2458 return tui.EditorFinishedMsg{Err: err}
2459 }
2460 content, readErr := os.ReadFile(tmpPath)
2461 if readErr != nil {
2462 return tui.EditorFinishedMsg{Err: readErr}
2463 }
2464 return tui.EditorFinishedMsg{Body: string(content)}
2465 })
2466}
2467
2468// --- IDLE command ---
2469
2470// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
2471func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
2472 return func() tea.Msg {
2473 update, ok := <-ch
2474 if !ok {
2475 return nil
2476 }
2477 return tui.IdleNewMailMsg{
2478 AccountID: update.AccountID,
2479 FolderName: update.FolderName,
2480 }
2481 }
2482}
2483
2484// --- Daemon event listener ---
2485
2486// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
2487func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
2488 return func() tea.Msg {
2489 ev, ok := <-ch
2490 if !ok {
2491 return nil
2492 }
2493 return tui.DaemonEventMsg{Event: ev}
2494 }
2495}
2496
2497// --- Folder-based command functions ---
2498
2499func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
2500 return func() tea.Msg {
2501 if !cfg.HasAccounts() {
2502 return nil
2503 }
2504 foldersByAccount := make(map[string][]fetcher.Folder)
2505 seen := make(map[string]fetcher.Folder)
2506 var mu sync.Mutex
2507 var wg sync.WaitGroup
2508
2509 for _, account := range cfg.Accounts {
2510 wg.Add(1)
2511 go func(acc config.Account) {
2512 defer wg.Done()
2513 folders, err := fetcher.FetchFolders(&acc)
2514 if err != nil {
2515 return
2516 }
2517 mu.Lock()
2518 foldersByAccount[acc.ID] = folders
2519 for _, f := range folders {
2520 if _, ok := seen[f.Name]; !ok {
2521 seen[f.Name] = f
2522 }
2523 }
2524 mu.Unlock()
2525 }(account)
2526 }
2527 wg.Wait()
2528
2529 var merged []fetcher.Folder
2530 for _, f := range seen {
2531 merged = append(merged, f)
2532 }
2533
2534 return tui.FoldersFetchedMsg{
2535 FoldersByAccount: foldersByAccount,
2536 MergedFolders: merged,
2537 }
2538 }
2539}
2540
2541func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
2542 return func() tea.Msg {
2543 emailsByAccount := make(map[string][]fetcher.Email)
2544 var mu sync.Mutex
2545 var wg sync.WaitGroup
2546
2547 for _, account := range cfg.Accounts {
2548 wg.Add(1)
2549 go func(acc config.Account) {
2550 defer wg.Done()
2551 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
2552 if err != nil {
2553 // Folder may not exist for this account — silently skip
2554 return
2555 }
2556 mu.Lock()
2557 emailsByAccount[acc.ID] = emails
2558 mu.Unlock()
2559 }(account)
2560 }
2561
2562 wg.Wait()
2563
2564 // Flatten all account emails
2565 var allEmails []fetcher.Email
2566 for _, emails := range emailsByAccount {
2567 allEmails = append(allEmails, emails...)
2568 }
2569 // Sort newest first
2570 for i := 0; i < len(allEmails); i++ {
2571 for j := i + 1; j < len(allEmails); j++ {
2572 if allEmails[j].Date.After(allEmails[i].Date) {
2573 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2574 }
2575 }
2576 }
2577
2578 return tui.FolderEmailsFetchedMsg{
2579 Emails: allEmails,
2580 FolderName: folderName,
2581 }
2582 }
2583}
2584
2585func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
2586 return func() tea.Msg {
2587 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
2588 if err != nil {
2589 return tui.FetchErr(err)
2590 }
2591 return tui.FolderEmailsAppendedMsg{
2592 Emails: emails,
2593 AccountID: account.ID,
2594 FolderName: folderName,
2595 }
2596 }
2597}
2598
2599func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2600 return func() tea.Msg {
2601 account := cfg.GetAccountByID(accountID)
2602 if account == nil {
2603 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
2604 }
2605
2606 body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2607 if err != nil {
2608 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2609 }
2610
2611 return tui.EmailBodyFetchedMsg{
2612 UID: uid,
2613 Body: body,
2614 Attachments: attachments,
2615 AccountID: accountID,
2616 Mailbox: mailbox,
2617 }
2618 }
2619}
2620
2621func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
2622 return func() tea.Msg {
2623 account := cfg.GetAccountByID(accountID)
2624 if account == nil {
2625 return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
2626 }
2627
2628 body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
2629 if err != nil {
2630 return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
2631 }
2632
2633 return tui.PreviewBodyFetchedMsg{
2634 UID: uid,
2635 Body: body,
2636 Attachments: attachments,
2637 AccountID: accountID,
2638 }
2639 }
2640}
2641
2642func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
2643 return func() tea.Msg {
2644 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
2645 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
2646 }
2647}
2648
2649func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2650 return func() tea.Msg {
2651 err := fetcher.DeleteFolderEmail(account, folderName, uid)
2652 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2653 }
2654}
2655
2656func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
2657 return func() tea.Msg {
2658 err := fetcher.ArchiveFolderEmail(account, folderName, uid)
2659 return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
2660 }
2661}
2662
2663func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2664 return func() tea.Msg {
2665 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2666 defer cancel()
2667
2668 p := m.getProvider(account)
2669 if p == nil {
2670 return tui.BatchEmailActionDoneMsg{
2671 Count: count,
2672 Action: "delete",
2673 Err: fmt.Errorf("provider not found"),
2674 }
2675 }
2676
2677 err := p.DeleteEmails(ctx, folderName, uids)
2678
2679 // Remove emails from local state on success
2680 if err == nil && m.folderInbox != nil {
2681 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2682 }
2683
2684 successCount := count
2685 failureCount := 0
2686 if err != nil {
2687 failureCount = count
2688 successCount = 0
2689 }
2690
2691 return tui.BatchEmailActionDoneMsg{
2692 Count: count,
2693 SuccessCount: successCount,
2694 FailureCount: failureCount,
2695 Action: "delete",
2696 Mailbox: mailbox,
2697 Err: err,
2698 }
2699 }
2700}
2701
2702func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
2703 return func() tea.Msg {
2704 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2705 defer cancel()
2706
2707 p := m.getProvider(account)
2708 if p == nil {
2709 return tui.BatchEmailActionDoneMsg{
2710 Count: count,
2711 Action: "archive",
2712 Err: fmt.Errorf("provider not found"),
2713 }
2714 }
2715
2716 err := p.ArchiveEmails(ctx, folderName, uids)
2717
2718 if err == nil && m.folderInbox != nil {
2719 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2720 }
2721
2722 successCount := count
2723 failureCount := 0
2724 if err != nil {
2725 failureCount = count
2726 successCount = 0
2727 }
2728
2729 return tui.BatchEmailActionDoneMsg{
2730 Count: count,
2731 SuccessCount: successCount,
2732 FailureCount: failureCount,
2733 Action: "archive",
2734 Mailbox: mailbox,
2735 Err: err,
2736 }
2737 }
2738}
2739
2740func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
2741 return func() tea.Msg {
2742 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
2743 defer cancel()
2744
2745 p := m.getProvider(account)
2746 if p == nil {
2747 return tui.BatchEmailActionDoneMsg{
2748 Count: count,
2749 Action: "move",
2750 Err: fmt.Errorf("provider not found"),
2751 }
2752 }
2753
2754 err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
2755
2756 if err == nil && m.folderInbox != nil {
2757 m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
2758 }
2759
2760 successCount := count
2761 failureCount := 0
2762 if err != nil {
2763 failureCount = count
2764 successCount = 0
2765 }
2766
2767 return tui.BatchEmailActionDoneMsg{
2768 Count: count,
2769 SuccessCount: successCount,
2770 FailureCount: failureCount,
2771 Action: "move",
2772 Err: err,
2773 }
2774 }
2775}
2776
2777func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
2778 return func() tea.Msg {
2779 err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)
2780 return tui.EmailMovedMsg{
2781 UID: uid,
2782 AccountID: accountID,
2783 SourceFolder: sourceFolder,
2784 DestFolder: destFolder,
2785 Err: err,
2786 }
2787 }
2788}
2789
2790// sanitizeFilename prevents path traversal attacks on attachment downloads.
2791// Email attachment filenames come from untrusted email headers and could
2792// contain path separators or ".." sequences to escape the Downloads directory.
2793func sanitizeFilename(name string) string {
2794 // Normalize backslashes to forward slashes so filepath.Base works
2795 // correctly on all platforms (Linux doesn't treat \ as a separator)
2796 name = strings.ReplaceAll(name, "\\", "/")
2797 // Strip any path components, keep only the base filename
2798 name = filepath.Base(name)
2799 // Replace any remaining path separators (defensive)
2800 name = strings.ReplaceAll(name, "/", "_")
2801 name = strings.ReplaceAll(name, "..", "_")
2802 // Reject hidden files and empty names
2803 if name == "" || name == "." || strings.HasPrefix(name, ".") {
2804 name = "attachment"
2805 }
2806 // Sanitize filename: enforce length limit to prevent filesystem errors
2807 // with extremely long names from untrusted email headers.
2808 const maxFilenameLen = 255
2809 if len(name) > maxFilenameLen {
2810 ext := filepath.Ext(name)
2811 if len(ext) > maxFilenameLen {
2812 ext = ext[:maxFilenameLen]
2813 }
2814 name = name[:maxFilenameLen-len(ext)] + ext
2815 }
2816 return name
2817}
2818
2819func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
2820 return func() tea.Msg {
2821 // Download and decode the attachment using encoding provided in msg.Encoding.
2822 var data []byte
2823 var err error
2824 switch msg.Mailbox {
2825 case tui.MailboxSent:
2826 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
2827 case tui.MailboxTrash:
2828 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
2829 case tui.MailboxArchive:
2830 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
2831 default:
2832 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
2833 }
2834
2835 if err != nil {
2836 return tui.AttachmentDownloadedMsg{Err: err}
2837 }
2838
2839 homeDir, err := os.UserHomeDir()
2840 if err != nil {
2841 return tui.AttachmentDownloadedMsg{Err: err}
2842 }
2843 downloadsPath := filepath.Join(homeDir, "Downloads")
2844 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
2845 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
2846 return tui.AttachmentDownloadedMsg{Err: mkErr}
2847 }
2848 }
2849
2850 // Save the attachment using an exclusive create so we never overwrite an existing file.
2851 // If the filename already exists, append \" (n)\" before the extension.
2852 origName := sanitizeFilename(msg.Filename)
2853 ext := filepath.Ext(origName)
2854 base := strings.TrimSuffix(origName, ext)
2855 candidate := origName
2856 i := 1
2857 var filePath string
2858
2859 for {
2860 filePath = filepath.Join(downloadsPath, candidate)
2861
2862 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
2863 // that satisfies os.IsExist(err), so we can increment the candidate.
2864 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
2865 if err != nil {
2866 if os.IsExist(err) {
2867 // file exists, try next candidate
2868 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
2869 i++
2870 continue
2871 }
2872 // Some other error while attempting to create file
2873 log.Printf("error creating file %s: %v", filePath, err)
2874 return tui.AttachmentDownloadedMsg{Err: err}
2875 }
2876
2877 // Successfully created the file descriptor; write and close.
2878 if _, writeErr := f.Write(data); writeErr != nil {
2879 _ = f.Close()
2880 log.Printf("error writing to file %s: %v", filePath, writeErr)
2881 return tui.AttachmentDownloadedMsg{Err: writeErr}
2882 }
2883 if closeErr := f.Close(); closeErr != nil {
2884 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
2885 }
2886
2887 // file saved successfully
2888 break
2889 }
2890
2891 log.Printf("attachment saved to %s", filePath)
2892
2893 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
2894 go func(p string) {
2895 var cmd *exec.Cmd
2896 switch runtime.GOOS {
2897 case "darwin":
2898 cmd = exec.Command("open", p)
2899 case "linux":
2900 cmd = exec.Command("xdg-open", p)
2901 case "windows":
2902 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
2903 cmd = exec.Command("cmd", "/c", "start", "", p)
2904 default:
2905 // Unsupported OS: nothing to do.
2906 return
2907 }
2908 if err := cmd.Start(); err != nil {
2909 log.Printf("failed to open file %s: %v", p, err)
2910 }
2911 }(filePath)
2912
2913 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
2914 }
2915}
2916
2917/*
2918detectInstalledVersion returns a best-effort installed version string.
2919Priority:
2920 1. If the build-in `version` variable is set to something other than "dev", return it.
2921 2. If Homebrew is present and reports a version for `matcha`, return that.
2922 3. If snap is present and lists `matcha`, return that.
2923 4. Fallback to the build `version` (likely "dev").
2924*/
2925func detectInstalledVersion() string {
2926 v := strings.TrimSpace(version)
2927 if v != "dev" && v != "" {
2928 return v
2929 }
2930
2931 // Try Homebrew (macOS)
2932 if runtime.GOOS == "darwin" {
2933 if _, err := exec.LookPath("brew"); err == nil {
2934 // `brew list --versions matcha` prints: matcha 1.2.3
2935 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil {
2936 parts := strings.Fields(string(out))
2937 if len(parts) >= 2 {
2938 return parts[1]
2939 }
2940 }
2941 }
2942 }
2943
2944 // Try WinGet (Windows)
2945 if runtime.GOOS == "windows" {
2946 if _, err := exec.LookPath("winget"); err == nil {
2947 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil {
2948 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2949 for _, line := range lines {
2950 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
2951 fields := strings.Fields(line)
2952 for _, f := range fields {
2953 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
2954 return f
2955 }
2956 }
2957 }
2958 }
2959 }
2960 }
2961 }
2962
2963 // Try snap (Linux)
2964 if runtime.GOOS == "linux" {
2965 if _, err := exec.LookPath("snap"); err == nil {
2966 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil {
2967 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2968 if len(lines) >= 2 {
2969 fields := strings.Fields(lines[1])
2970 if len(fields) >= 2 {
2971 return fields[1]
2972 }
2973 }
2974 }
2975 }
2976
2977 if _, err := exec.LookPath("flatpak"); err == nil {
2978 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil {
2979 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2980 for _, line := range lines {
2981 line = strings.TrimSpace(line)
2982 if strings.HasPrefix(line, "Version:") {
2983 fields := strings.Fields(line)
2984 if len(fields) >= 2 {
2985 return fields[1]
2986 }
2987 }
2988 }
2989 }
2990 }
2991 }
2992
2993 return v
2994}
2995
2996/*
2997checkForUpdatesCmd queries GitHub for the latest release tag and returns a
2998tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
2999installed version. This runs in the background when the TUI initializes.
3000*/
3001func checkForUpdatesCmd() tea.Cmd {
3002 return func() tea.Msg {
3003 // Non-fatal: if anything goes wrong we just don't show the update message.
3004 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3005 resp, err := httpClient.Get(api)
3006 if err != nil {
3007 return nil
3008 }
3009 defer resp.Body.Close()
3010
3011 var rel githubRelease
3012 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3013 return nil
3014 }
3015
3016 latest := strings.TrimPrefix(rel.TagName, "v")
3017 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3018 if latest != "" && installed != "" && latest != installed {
3019 return UpdateAvailableMsg{Latest: latest, Current: installed}
3020 }
3021 return nil
3022 }
3023}
3024
3025// runUpdateCLI implements the CLI entrypoint for `matcha update`.
3026// It detects the likely installation method and attempts the appropriate
3027// update path (Homebrew, Snap, or GitHub release binary extract).
3028// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
3029// Usage:
3030//
3031// matcha oauth auth <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
3032// matcha oauth token <email>
3033// matcha oauth revoke <email>
3034func runOAuthCLI(args []string) {
3035 if len(args) < 1 {
3036 fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
3037 fmt.Fprintln(os.Stderr, "")
3038 fmt.Fprintln(os.Stderr, "Commands:")
3039 fmt.Fprintln(os.Stderr, " auth <email> Authorize an email account via OAuth2 (opens browser)")
3040 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
3041 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
3042 fmt.Fprintln(os.Stderr, "")
3043 fmt.Fprintln(os.Stderr, "Flags for auth:")
3044 fmt.Fprintln(os.Stderr, " --provider gmail|outlook OAuth2 provider (auto-detected from email)")
3045 fmt.Fprintln(os.Stderr, " --client-id ID OAuth2 client ID")
3046 fmt.Fprintln(os.Stderr, " --client-secret SECRET OAuth2 client secret")
3047 fmt.Fprintln(os.Stderr, "")
3048 fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
3049 fmt.Fprintln(os.Stderr, " Gmail: ~/.config/matcha/oauth_client.json")
3050 fmt.Fprintln(os.Stderr, " Outlook: ~/.config/matcha/oauth_client_outlook.json")
3051 os.Exit(1)
3052 }
3053
3054 // Find the Python script and pass through to it
3055 script, err := config.OAuthScriptPath()
3056 if err != nil {
3057 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3058 os.Exit(1)
3059 }
3060
3061 cmdArgs := append([]string{script}, args...)
3062 cmd := exec.Command("python3", cmdArgs...)
3063 cmd.Stdin = os.Stdin
3064 cmd.Stdout = os.Stdout
3065 cmd.Stderr = os.Stderr
3066
3067 if err := cmd.Run(); err != nil {
3068 if exitErr, ok := err.(*exec.ExitError); ok {
3069 os.Exit(exitErr.ExitCode())
3070 }
3071 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3072 os.Exit(1)
3073 }
3074}
3075
3076// stringSliceFlag implements flag.Value to allow repeated --attach flags.
3077type stringSliceFlag []string
3078
3079func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
3080func (s *stringSliceFlag) Set(val string) error {
3081 *s = append(*s, val)
3082 return nil
3083}
3084
3085// runSendCLI implements the CLI entrypoint for `matcha send`.
3086// It sends an email non-interactively using configured accounts.
3087func runSendCLI(args []string) {
3088 fs := flag.NewFlagSet("send", flag.ExitOnError)
3089
3090 to := fs.String("to", "", "Recipient(s), comma-separated (required)")
3091 cc := fs.String("cc", "", "CC recipient(s), comma-separated")
3092 bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
3093 subject := fs.String("subject", "", "Email subject (required)")
3094 body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
3095 from := fs.String("from", "", "Sender account email (defaults to first configured account)")
3096 withSignature := fs.Bool("signature", true, "Append default signature")
3097 signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
3098 encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
3099 signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
3100
3101 var attachments stringSliceFlag
3102 fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
3103
3104 fs.Usage = func() {
3105 fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
3106 fmt.Fprintln(os.Stderr, "")
3107 fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
3108 fmt.Fprintln(os.Stderr, "")
3109 fmt.Fprintln(os.Stderr, "Flags:")
3110 fs.PrintDefaults()
3111 fmt.Fprintln(os.Stderr, "")
3112 fmt.Fprintln(os.Stderr, "Examples:")
3113 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
3114 fmt.Fprintln(os.Stderr, ` echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
3115 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
3116 }
3117
3118 if err := fs.Parse(args); err != nil {
3119 os.Exit(1)
3120 }
3121
3122 if *to == "" || *subject == "" {
3123 fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
3124 fs.Usage()
3125 os.Exit(1)
3126 }
3127
3128 // Read body from stdin if "-"
3129 emailBody := *body
3130 if emailBody == "-" {
3131 data, err := io.ReadAll(os.Stdin)
3132 if err != nil {
3133 fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
3134 os.Exit(1)
3135 }
3136 emailBody = string(data)
3137 }
3138
3139 // Load config
3140 cfg, err := config.LoadConfig()
3141 if err != nil {
3142 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
3143 os.Exit(1)
3144 }
3145 if !cfg.HasAccounts() {
3146 fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
3147 os.Exit(1)
3148 }
3149
3150 // Resolve account
3151 var account *config.Account
3152 if *from != "" {
3153 account = cfg.GetAccountByEmail(*from)
3154 if account == nil {
3155 // Also try matching against FetchEmail
3156 for i := range cfg.Accounts {
3157 if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
3158 account = &cfg.Accounts[i]
3159 break
3160 }
3161 }
3162 }
3163 if account == nil {
3164 fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
3165 os.Exit(1)
3166 }
3167 } else {
3168 account = cfg.GetFirstAccount()
3169 }
3170
3171 // Use account S/MIME/PGP defaults unless explicitly set
3172 if !isFlagSet(fs, "sign-smime") {
3173 *signSMIME = account.SMIMESignByDefault
3174 }
3175 if !isFlagSet(fs, "sign-pgp") {
3176 *signPGP = account.PGPSignByDefault
3177 }
3178
3179 // Append signature
3180 if *withSignature {
3181 if sig, err := config.LoadSignature(); err == nil && sig != "" {
3182 emailBody = emailBody + "\n\n" + sig
3183 }
3184 }
3185
3186 // Process inline images (same logic as TUI sendEmail)
3187 images := make(map[string][]byte)
3188 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3189 matches := re.FindAllStringSubmatch(emailBody, -1)
3190 for _, match := range matches {
3191 imgPath := match[1]
3192 imgData, err := os.ReadFile(imgPath)
3193 if err != nil {
3194 log.Printf("Could not read image file %s: %v", imgPath, err)
3195 continue
3196 }
3197 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3198 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3199 emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
3200 }
3201
3202 htmlBody := markdownToHTML([]byte(emailBody))
3203
3204 // Process attachments
3205 attachMap := make(map[string][]byte)
3206 for _, attachPath := range attachments {
3207 fileData, err := os.ReadFile(attachPath)
3208 if err != nil {
3209 fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
3210 os.Exit(1)
3211 }
3212 attachMap[filepath.Base(attachPath)] = fileData
3213 }
3214
3215 // Send
3216 recipients := splitEmails(*to)
3217 ccList := splitEmails(*cc)
3218 bccList := splitEmails(*bcc)
3219
3220 rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3221 if sendErr != nil {
3222 fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3223 os.Exit(1)
3224 }
3225
3226 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3227 if account.ServiceProvider != "gmail" {
3228 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3229 log.Printf("Failed to append sent message to Sent folder: %v", err)
3230 }
3231 }
3232
3233 fmt.Println("Email sent successfully.")
3234}
3235
3236// isFlagSet returns true if the named flag was explicitly provided on the command line.
3237func isFlagSet(fs *flag.FlagSet, name string) bool {
3238 found := false
3239 fs.Visit(func(f *flag.Flag) {
3240 if f.Name == name {
3241 found = true
3242 }
3243 })
3244 return found
3245}
3246
3247func runUpdateCLI() error {
3248 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3249 resp, err := httpClient.Get(api)
3250 if err != nil {
3251 return fmt.Errorf("could not query releases: %w", err)
3252 }
3253 defer resp.Body.Close()
3254
3255 var rel githubRelease
3256 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3257 return fmt.Errorf("could not parse release info: %w", err)
3258 }
3259
3260 latestTag := rel.TagName
3261 if strings.HasPrefix(latestTag, "v") {
3262 latestTag = latestTag[1:]
3263 }
3264
3265 fmt.Printf("Current version: %s\n", version)
3266 fmt.Printf("Latest version: %s\n", latestTag)
3267
3268 // Quick check: if already up-to-date, exit
3269 cur := version
3270 if strings.HasPrefix(cur, "v") {
3271 cur = cur[1:]
3272 }
3273 if latestTag == "" || cur == latestTag {
3274 fmt.Println("Already up to date.")
3275 return nil
3276 }
3277
3278 // Detect Homebrew
3279 if _, err := exec.LookPath("brew"); err == nil {
3280 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
3281
3282 updateCmd := exec.Command("brew", "update")
3283 updateCmd.Stdout = os.Stdout
3284 updateCmd.Stderr = os.Stderr
3285 if err := updateCmd.Run(); err != nil {
3286 fmt.Printf("Homebrew update failed: %v\n", err)
3287 // continue to attempt upgrade even if update failed
3288 }
3289
3290 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha")
3291 upgradeCmd.Stdout = os.Stdout
3292 upgradeCmd.Stderr = os.Stderr
3293 if err := upgradeCmd.Run(); err == nil {
3294 fmt.Println("Successfully upgraded via Homebrew.")
3295 return nil
3296 }
3297 fmt.Printf("Homebrew upgrade failed: %v\n", err)
3298 // fallthrough to other methods
3299 }
3300
3301 // Detect snap
3302 if _, err := exec.LookPath("snap"); err == nil {
3303 // Check if matcha is installed as a snap
3304 cmdCheck := exec.Command("snap", "list", "matcha")
3305 if err := cmdCheck.Run(); err == nil {
3306 fmt.Println("Detected Snap package — attempting to refresh.")
3307 cmd := exec.Command("snap", "refresh", "matcha")
3308 cmd.Stdout = os.Stdout
3309 cmd.Stderr = os.Stderr
3310 if err := cmd.Run(); err == nil {
3311 fmt.Println("Successfully refreshed snap.")
3312 return nil
3313 }
3314 fmt.Printf("Snap refresh failed: %v\n", err)
3315 // fallthrough
3316 }
3317 }
3318 // Detect flatpak
3319 if _, err := exec.LookPath("flatpak"); err == nil {
3320 // Check if matcha is installed as a flatpak
3321 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha")
3322 if err := cmdCheck.Run(); err == nil {
3323 fmt.Println("Detected Flatpak package — attempting to update.")
3324 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha")
3325 cmd.Stdout = os.Stdout
3326 cmd.Stderr = os.Stderr
3327 if err := cmd.Run(); err == nil {
3328 fmt.Println("Successfully updated flatpak.")
3329 return nil
3330 }
3331 fmt.Printf("Flatpak update failed: %v\n", err)
3332 // fallthrough
3333 }
3334 }
3335
3336 // Detect WinGet
3337 if _, err := exec.LookPath("winget"); err == nil {
3338 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity")
3339 if err := cmdCheck.Run(); err == nil {
3340 fmt.Println("Detected WinGet package — attempting to upgrade.")
3341 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity")
3342 cmd.Stdout = os.Stdout
3343 cmd.Stderr = os.Stderr
3344 if err := cmd.Run(); err == nil {
3345 fmt.Println("Successfully upgraded via WinGet.")
3346 return nil
3347 }
3348 fmt.Printf("WinGet upgrade failed: %v\n", err)
3349 // fallthrough
3350 }
3351 }
3352
3353 // Otherwise attempt to download the proper release asset and replace the binary.
3354 osName := runtime.GOOS
3355 arch := runtime.GOARCH
3356
3357 // Try to find a matching asset
3358 var assetURL, assetName string
3359 for _, a := range rel.Assets {
3360 n := strings.ToLower(a.Name)
3361 if strings.Contains(n, osName) && strings.Contains(n, arch) && (strings.HasSuffix(n, ".tar.gz") || strings.HasSuffix(n, ".tgz") || strings.HasSuffix(n, ".zip")) {
3362 assetURL = a.BrowserDownloadURL
3363 assetName = a.Name
3364 break
3365 }
3366 }
3367 if assetURL == "" {
3368 // Try any asset that contains 'matcha' and os/arch as a fallback
3369 for _, a := range rel.Assets {
3370 n := strings.ToLower(a.Name)
3371 if strings.Contains(n, "matcha") && (strings.Contains(n, osName) || strings.Contains(n, arch)) {
3372 assetURL = a.BrowserDownloadURL
3373 assetName = a.Name
3374 break
3375 }
3376 }
3377 }
3378
3379 if assetURL == "" {
3380 return fmt.Errorf("no suitable release artifact found for %s/%s", osName, arch)
3381 }
3382
3383 fmt.Printf("Found release asset: %s\n", assetName)
3384 fmt.Println("Downloading...")
3385
3386 // Download asset
3387 respAsset, err := httpClient.Get(assetURL)
3388 if err != nil {
3389 return fmt.Errorf("download failed: %w", err)
3390 }
3391 defer respAsset.Body.Close()
3392
3393 // Create a temp file for the download
3394 tmpDir, err := os.MkdirTemp("", "matcha-update-*")
3395 if err != nil {
3396 return fmt.Errorf("could not create temp dir: %w", err)
3397 }
3398 defer os.RemoveAll(tmpDir)
3399
3400 assetPath := filepath.Join(tmpDir, assetName)
3401 outFile, err := os.Create(assetPath)
3402 if err != nil {
3403 return fmt.Errorf("could not create temp file: %w", err)
3404 }
3405 _, err = io.Copy(outFile, respAsset.Body)
3406 outFile.Close()
3407 if err != nil {
3408 return fmt.Errorf("could not write asset to disk: %w", err)
3409 }
3410
3411 // Determine the expected binary name based on the OS.
3412 binaryName := "matcha"
3413 if runtime.GOOS == "windows" {
3414 binaryName = "matcha.exe"
3415 }
3416
3417 // Extract the binary from the archive.
3418 var binPath string
3419 if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
3420 f, err := os.Open(assetPath)
3421 if err != nil {
3422 return fmt.Errorf("could not open archive: %w", err)
3423 }
3424 defer f.Close()
3425 gzr, err := gzip.NewReader(f)
3426 if err != nil {
3427 return fmt.Errorf("could not create gzip reader: %w", err)
3428 }
3429 tr := tar.NewReader(gzr)
3430 for {
3431 hdr, err := tr.Next()
3432 if err == io.EOF {
3433 break
3434 }
3435 if err != nil {
3436 return fmt.Errorf("error reading tar: %w", err)
3437 }
3438 name := filepath.Base(hdr.Name)
3439 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
3440 binPath = filepath.Join(tmpDir, binaryName)
3441 out, err := os.Create(binPath)
3442 if err != nil {
3443 return fmt.Errorf("could not create binary file: %w", err)
3444 }
3445 if _, err := io.Copy(out, tr); err != nil {
3446 out.Close()
3447 return fmt.Errorf("could not extract binary: %w", err)
3448 }
3449 out.Close()
3450 if err := os.Chmod(binPath, 0755); err != nil {
3451 return fmt.Errorf("could not make binary executable: %w", err)
3452 }
3453 break
3454 }
3455 }
3456 } else if strings.HasSuffix(assetName, ".zip") {
3457 zr, err := zip.OpenReader(assetPath)
3458 if err != nil {
3459 return fmt.Errorf("could not open zip archive: %w", err)
3460 }
3461 defer zr.Close()
3462 for _, zf := range zr.File {
3463 name := filepath.Base(zf.Name)
3464 if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
3465 rc, err := zf.Open()
3466 if err != nil {
3467 return fmt.Errorf("could not open file in zip: %w", err)
3468 }
3469 binPath = filepath.Join(tmpDir, binaryName)
3470 out, err := os.Create(binPath)
3471 if err != nil {
3472 rc.Close()
3473 return fmt.Errorf("could not create binary file: %w", err)
3474 }
3475 if _, err := io.Copy(out, rc); err != nil {
3476 out.Close()
3477 rc.Close()
3478 return fmt.Errorf("could not extract binary: %w", err)
3479 }
3480 out.Close()
3481 rc.Close()
3482 if err := os.Chmod(binPath, 0755); err != nil {
3483 return fmt.Errorf("could not make binary executable: %w", err)
3484 }
3485 break
3486 }
3487 }
3488 } else {
3489 // For non-archive assets, assume the asset is the binary itself.
3490 binPath = assetPath
3491 if err := os.Chmod(binPath, 0755); err != nil {
3492 // ignore chmod errors but warn
3493 fmt.Printf("warning: could not chmod downloaded binary: %v\n", err)
3494 }
3495 }
3496
3497 if binPath == "" {
3498 return fmt.Errorf("could not locate matcha binary inside the release artifact")
3499 }
3500
3501 // Replace the running executable with the new binary
3502 execPath, err := os.Executable()
3503 if err != nil {
3504 return fmt.Errorf("could not determine executable path: %w", err)
3505 }
3506
3507 // Write the new binary to a temp file in same dir, then rename for atomic replacement.
3508 execDir := filepath.Dir(execPath)
3509 tmpNew := filepath.Join(execDir, fmt.Sprintf("matcha.new.%d", time.Now().Unix()))
3510 in, err := os.Open(binPath)
3511 if err != nil {
3512 return fmt.Errorf("could not open new binary: %w", err)
3513 }
3514 out, err := os.OpenFile(tmpNew, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
3515 if err != nil {
3516 in.Close()
3517 return fmt.Errorf("could not create temp binary in target dir: %w", err)
3518 }
3519 if _, err := io.Copy(out, in); err != nil {
3520 in.Close()
3521 out.Close()
3522 return fmt.Errorf("could not write new binary to disk: %w", err)
3523 }
3524 in.Close()
3525 out.Close()
3526
3527 // On Windows, a running executable cannot be overwritten directly.
3528 // Move the old binary out of the way first, then rename the new one in.
3529 if runtime.GOOS == "windows" {
3530 oldPath := execPath + ".old"
3531 _ = os.Remove(oldPath) // clean up any previous leftover
3532 if err := os.Rename(execPath, oldPath); err != nil {
3533 return fmt.Errorf("could not move old executable out of the way: %w", err)
3534 }
3535 }
3536
3537 if err := os.Rename(tmpNew, execPath); err != nil {
3538 return fmt.Errorf("could not replace executable: %w", err)
3539 }
3540
3541 fmt.Println("Successfully updated matcha to", latestTag)
3542 return nil
3543}
3544
3545func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
3546 seen := make(map[uint32]struct{})
3547 for _, e := range existing {
3548 seen[e.UID] = struct{}{}
3549 }
3550 var unique []fetcher.Email
3551 for _, e := range incoming {
3552 if _, ok := seen[e.UID]; !ok {
3553 unique = append(unique, e)
3554 }
3555 }
3556 return unique
3557}
3558
3559func main() {
3560 // If invoked with version flag, print version and exit
3561 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
3562 fmt.Printf("matcha version %s", version)
3563 if commit != "" {
3564 fmt.Printf(" (%s)", commit)
3565 }
3566 if date != "" {
3567 fmt.Printf(" built on %s", date)
3568 }
3569 fmt.Println()
3570 os.Exit(0)
3571 }
3572
3573 // If invoked as CLI update command, run updater and exit.
3574 if len(os.Args) > 1 && os.Args[1] == "update" {
3575 if err := runUpdateCLI(); err != nil {
3576 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
3577 os.Exit(1)
3578 }
3579 os.Exit(0)
3580 }
3581
3582 // Daemon CLI subcommand: matcha daemon <start|stop|status|run>
3583 if len(os.Args) > 1 && os.Args[1] == "daemon" {
3584 runDaemonCLI(os.Args[2:])
3585 os.Exit(0)
3586 }
3587
3588 // OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
3589 // "gmail" is kept as an alias for backwards compatibility.
3590 if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
3591 runOAuthCLI(os.Args[2:])
3592 os.Exit(0)
3593 }
3594
3595 // Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
3596 if len(os.Args) > 1 && os.Args[1] == "send" {
3597 runSendCLI(os.Args[2:])
3598 os.Exit(0)
3599 }
3600
3601 // Install plugin CLI subcommand: matcha install <url_or_file>
3602 if len(os.Args) > 1 && os.Args[1] == "install" {
3603 if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
3604 fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
3605 os.Exit(1)
3606 }
3607 os.Exit(0)
3608 }
3609
3610 // Config CLI subcommand: matcha config [plugin_name]
3611 if len(os.Args) > 1 && os.Args[1] == "config" {
3612 if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
3613 fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
3614 os.Exit(1)
3615 }
3616 os.Exit(0)
3617 }
3618
3619 // Contacts CLI subcommand: matcha contacts <export|sync> [flags]
3620 if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
3621 switch os.Args[2] {
3622 case "export":
3623 if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
3624 fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
3625 os.Exit(1)
3626 }
3627 os.Exit(0)
3628 case "sync":
3629 if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
3630 fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
3631 os.Exit(1)
3632 }
3633 os.Exit(0)
3634 }
3635 }
3636
3637 // setup-mailto CLI subcommand: matcha setup-mailto
3638 if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
3639 if err := matchaCli.SetupMailto(); err != nil {
3640 fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
3641 os.Exit(1)
3642 }
3643 os.Exit(0)
3644 }
3645
3646 // Marketplace TUI subcommand: matcha marketplace
3647 if len(os.Args) > 1 && os.Args[1] == "marketplace" {
3648 mp := tui.NewMarketplace(true)
3649 p := tea.NewProgram(mp)
3650 if _, err := p.Run(); err != nil {
3651 fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
3652 os.Exit(1)
3653 }
3654 os.Exit(0)
3655 }
3656
3657 // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
3658 _ = config.MigrateCacheFiles()
3659
3660 // Initialize i18n
3661 if err := i18n.Init("en"); err != nil {
3662 log.Printf("Failed to initialize i18n: %v", err)
3663 }
3664
3665 var mailtoURL *url.URL
3666 if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
3667 if u, err := url.Parse(os.Args[1]); err == nil {
3668 mailtoURL = u
3669 }
3670 }
3671
3672 var initialModel *mainModel
3673
3674 if config.IsSecureModeEnabled() {
3675 // Secure mode: show password prompt before loading config
3676 tui.RebuildStyles()
3677 initialModel = newInitialModel(nil, mailtoURL)
3678 initialModel.current = tui.NewPasswordPrompt()
3679 } else {
3680 cfg, err := config.LoadConfig()
3681 if err == nil {
3682 if cfg.Theme != "" {
3683 theme.SetTheme(cfg.Theme)
3684 }
3685 // Set language from config
3686 lang := i18n.DetectLanguage(cfg)
3687 if err := i18n.GetManager().SetLanguage(lang); err != nil {
3688 log.Printf("Failed to set language %s: %v", lang, err)
3689 }
3690 }
3691 tui.RebuildStyles()
3692
3693 // Ensure PGP keys directory exists
3694 _ = config.EnsurePGPDir()
3695
3696 if err != nil {
3697 initialModel = newInitialModel(nil, mailtoURL)
3698 } else {
3699 initialModel = newInitialModel(cfg, mailtoURL)
3700 }
3701 }
3702
3703 // Initialize plugin system
3704 plugins := plugin.NewManager()
3705 plugins.LoadPlugins()
3706 initialModel.plugins = plugins
3707 plugins.CallHook(plugin.HookStartup)
3708
3709 // Background sync macOS features
3710 if runtime.GOOS == "darwin" {
3711 disableNotifications := false
3712 if initialModel.config != nil {
3713 disableNotifications = initialModel.config.DisableNotifications
3714 }
3715 if !disableNotifications {
3716 go func() {
3717 _ = config.SyncMacOSContacts()
3718 _ = theme.SyncWithMacOS()
3719 }()
3720 }
3721 }
3722
3723 p := tea.NewProgram(initialModel)
3724
3725 if _, err := p.Run(); err != nil {
3726 plugins.Close()
3727 fmt.Printf("Alas, there's been an error: %v", err)
3728 os.Exit(1)
3729 }
3730
3731 plugins.CallHook(plugin.HookShutdown)
3732 plugins.Close()
3733}
3734
3735func runDaemonCLI(args []string) {
3736 if len(args) == 0 {
3737 fmt.Println("Usage: matcha daemon <start|stop|status|run>")
3738 fmt.Println()
3739 fmt.Println("Commands:")
3740 fmt.Println(" start Start the daemon in the background")
3741 fmt.Println(" stop Stop the running daemon")
3742 fmt.Println(" status Show daemon status")
3743 fmt.Println(" run Run the daemon in the foreground")
3744 os.Exit(1)
3745 }
3746
3747 switch args[0] {
3748 case "start":
3749 runDaemonStart()
3750 case "stop":
3751 runDaemonStop()
3752 case "status":
3753 runDaemonStatus()
3754 case "run":
3755 runDaemonRun()
3756 default:
3757 fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
3758 os.Exit(1)
3759 }
3760}
3761
3762func runDaemonStart() {
3763 pidPath := daemonrpc.PIDPath()
3764 if pid, running := matchaDaemon.IsRunning(pidPath); running {
3765 fmt.Printf("Daemon already running (PID %d)\n", pid)
3766 return
3767 }
3768
3769 // Fork ourselves with "daemon run".
3770 exe, err := os.Executable()
3771 if err != nil {
3772 fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
3773 os.Exit(1)
3774 }
3775
3776 cmd := exec.Command(exe, "daemon", "run")
3777 cmd.Stdout = nil
3778 cmd.Stderr = nil
3779 cmd.Stdin = nil
3780
3781 // Detach from parent process.
3782 cmd.SysProcAttr = daemonclient.DaemonProcAttr()
3783
3784 if err := cmd.Start(); err != nil {
3785 fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
3786 os.Exit(1)
3787 }
3788
3789 fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
3790}
3791
3792func runDaemonStop() {
3793 pidPath := daemonrpc.PIDPath()
3794 pid, running := matchaDaemon.IsRunning(pidPath)
3795 if !running {
3796 fmt.Println("Daemon is not running")
3797 return
3798 }
3799
3800 process, err := os.FindProcess(pid)
3801 if err != nil {
3802 fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
3803 os.Exit(1)
3804 }
3805
3806 if err := process.Signal(os.Interrupt); err != nil {
3807 fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
3808 os.Exit(1)
3809 }
3810
3811 fmt.Printf("Daemon stopped (PID %d)\n", pid)
3812}
3813
3814func runDaemonStatus() {
3815 // Try connecting to daemon for live status.
3816 client, err := daemonclient.Dial()
3817 if err != nil {
3818 pidPath := daemonrpc.PIDPath()
3819 if pid, running := matchaDaemon.IsRunning(pidPath); running {
3820 fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
3821 } else {
3822 fmt.Println("Daemon is not running")
3823 }
3824 return
3825 }
3826 defer client.Close()
3827
3828 status, err := client.Status()
3829 if err != nil {
3830 fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
3831 os.Exit(1)
3832 }
3833
3834 fmt.Printf("Daemon running (PID %d)\n", status.PID)
3835 fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
3836 fmt.Printf("Accounts: %d\n", len(status.Accounts))
3837 for _, acct := range status.Accounts {
3838 fmt.Printf(" - %s\n", acct)
3839 }
3840}
3841
3842func runDaemonRun() {
3843 cfg, err := config.LoadConfig()
3844 if err != nil {
3845 fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
3846 os.Exit(1)
3847 }
3848
3849 d := matchaDaemon.New(cfg)
3850 if err := d.Run(); err != nil {
3851 fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
3852 os.Exit(1)
3853 }
3854}
3855
3856func formatUptime(seconds int64) string {
3857 d := time.Duration(seconds) * time.Second
3858 if d < time.Minute {
3859 return fmt.Sprintf("%ds", int(d.Seconds()))
3860 }
3861 if d < time.Hour {
3862 return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
3863 }
3864 return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
3865}