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