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