1package main
2
3import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "flag"
9 "fmt"
10 "io"
11 "log"
12 "net/mail"
13 "net/url"
14 "os"
15 "os/exec"
16 "path/filepath"
17 "regexp"
18 "runtime"
19 "slices"
20 "sort"
21 "strings"
22 "sync"
23 "time"
24 "unicode/utf8"
25
26 tea "charm.land/bubbletea/v2"
27 "charm.land/lipgloss/v2"
28 overlay "github.com/floatpane/bubble-overlay"
29 calendar "github.com/floatpane/go-icalendar"
30 "github.com/floatpane/matcha/backend"
31 _ "github.com/floatpane/matcha/backend/imap"
32 _ "github.com/floatpane/matcha/backend/jmap"
33 _ "github.com/floatpane/matcha/backend/maildir"
34 _ "github.com/floatpane/matcha/backend/pop3"
35 matchaCli "github.com/floatpane/matcha/cli"
36 "github.com/floatpane/matcha/clib"
37 "github.com/floatpane/matcha/clib/macos"
38 "github.com/floatpane/matcha/config"
39 matchaDaemon "github.com/floatpane/matcha/daemon"
40 "github.com/floatpane/matcha/daemonclient"
41 "github.com/floatpane/matcha/daemonrpc"
42 "github.com/floatpane/matcha/fetcher"
43 "github.com/floatpane/matcha/i18n"
44 _ "github.com/floatpane/matcha/i18n/languages"
45 "github.com/floatpane/matcha/internal/httpclient"
46 "github.com/floatpane/matcha/internal/logging"
47 "github.com/floatpane/matcha/internal/loglevel"
48 "github.com/floatpane/matcha/notify"
49 "github.com/floatpane/matcha/plugin"
50 "github.com/floatpane/matcha/sender"
51 "github.com/floatpane/matcha/theme"
52 "github.com/floatpane/matcha/tui"
53 "github.com/floatpane/termimage"
54 "github.com/google/uuid"
55 lua "github.com/yuin/gopher-lua"
56)
57
58const (
59 initialEmailLimit = 50
60 paginationLimit = 50
61 maxCacheEmails = 100
62)
63
64// Version variables are injected by the build (GoReleaser ldflags).
65// They default to "dev" when not set by the build system.
66var (
67 version = "dev"
68 commit = ""
69 date = ""
70
71 // httpClient is used for all outbound HTTP requests (update checks, asset downloads).
72 httpClient = httpclient.NewWithRedirectCap(httpclient.UpdateCheckTimeout, 5)
73)
74
75const (
76 goosDarwin = "darwin"
77 goosLinux = "linux"
78 goosWindows = "windows"
79 folderInbox = "INBOX"
80 actionKindDelete = "delete"
81 actionKindArchive = "archive"
82 actionKindMove = "move"
83)
84
85// UpdateAvailableMsg is sent into the TUI when a newer release is detected.
86type UpdateAvailableMsg struct {
87 Latest string
88 Current string
89}
90
91// V1RCAvailableMsg is sent into the TUI when a v1.0.0-rcN release is available
92// while the installed version is still pre-v1.
93type V1RCAvailableMsg struct {
94 Latest string
95 Current string
96}
97
98// internal struct for parsing GitHub release JSON.
99type pendingEmailAction struct {
100 jobID string
101 kind string // "delete", "archive", "move"
102 uids []uint32
103 accountID string
104 folderName string
105 destFolder string // for "move"
106 mailbox tui.MailboxKind
107 // Snapshots for undo restore
108 emailsSnap []fetcher.Email
109 acctSnap []fetcher.Email
110 folderSnap []fetcher.Email
111}
112
113type githubRelease = matchaCli.Release
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(), checkForV1RCCmd()}
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.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID})
1927 m.removeEmailFromStores(msg.UID, msg.AccountID)
1928
1929 if emails, ok := m.folderEmails[folderName]; ok {
1930 var filtered []fetcher.Email
1931 for _, e := range emails {
1932 if e.UID != msg.UID || e.AccountID != msg.AccountID {
1933 filtered = append(filtered, e)
1934 }
1935 }
1936 m.folderEmails[folderName] = filtered
1937 go saveFolderEmailsToCache(folderName, filtered)
1938 }
1939
1940 pa := &pendingEmailAction{
1941 jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
1942 kind: actionKindDelete,
1943 uids: []uint32{msg.UID},
1944 accountID: msg.AccountID,
1945 folderName: folderName,
1946 mailbox: msg.Mailbox,
1947 emailsSnap: emailsSnap,
1948 acctSnap: acctSnap,
1949 folderSnap: folderSnap,
1950 }
1951 flushCmd := m.flushPendingAction()
1952 notice := fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
1953 return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
1954
1955 case tui.ArchiveEmailMsg:
1956 tui.ClearKittyGraphics()
1957
1958 account := m.config.GetAccountByID(msg.AccountID)
1959 if account == nil {
1960 if m.folderInbox != nil {
1961 m.current = m.folderInbox
1962 }
1963 return m, nil
1964 }
1965
1966 folderName := folderInbox
1967 if m.folderInbox != nil {
1968 m.current = m.folderInbox
1969 folderName = m.folderInbox.GetCurrentFolder()
1970 m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID)
1971 }
1972
1973 emailsSnap := slices.Clone(m.emails)
1974 acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
1975 folderSnap := slices.Clone(m.folderEmails[folderName])
1976
1977 m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, []uint32{msg.UID})
1978 m.removeEmailFromStores(msg.UID, msg.AccountID)
1979
1980 if emails, ok := m.folderEmails[folderName]; ok {
1981 var filtered []fetcher.Email
1982 for _, e := range emails {
1983 if e.UID != msg.UID || e.AccountID != msg.AccountID {
1984 filtered = append(filtered, e)
1985 }
1986 }
1987 m.folderEmails[folderName] = filtered
1988 go saveFolderEmailsToCache(folderName, filtered)
1989 }
1990
1991 pa := &pendingEmailAction{
1992 jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
1993 kind: actionKindArchive,
1994 uids: []uint32{msg.UID},
1995 accountID: msg.AccountID,
1996 folderName: folderName,
1997 mailbox: msg.Mailbox,
1998 emailsSnap: emailsSnap,
1999 acctSnap: acctSnap,
2000 folderSnap: folderSnap,
2001 }
2002 flushCmd := m.flushPendingAction()
2003 notice := fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
2004 return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2005
2006 case tui.EmailMarkedReadMsg:
2007 if msg.Err != nil {
2008 log.Printf("Error marking email as read: %v", msg.Err)
2009 }
2010 m.syncUnreadBadge()
2011 return m, nil
2012
2013 case tui.EmailMarkedUnreadMsg:
2014 if msg.Err != nil {
2015 log.Printf("Error marking email as unread: %v", msg.Err)
2016 }
2017 m.syncUnreadBadge()
2018 return m, nil
2019
2020 case tui.EmailActionDoneMsg:
2021 if msg.Err != nil {
2022 log.Printf("Action failed: %v", msg.Err)
2023 if m.folderInbox != nil {
2024 m.previousModel = m.folderInbox
2025 }
2026 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
2027 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2028 return tui.RestoreViewMsg{}
2029 })
2030 }
2031
2032 return m, nil
2033
2034 case tui.BatchDeleteEmailsMsg:
2035 tui.ClearKittyGraphics()
2036
2037 account := m.config.GetAccountByID(msg.AccountID)
2038 if account == nil {
2039 if m.folderInbox != nil {
2040 m.current = m.folderInbox
2041 }
2042 return m, nil
2043 }
2044
2045 folderName := folderInbox
2046 if m.folderInbox != nil {
2047 folderName = m.folderInbox.GetCurrentFolder()
2048 m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2049 }
2050
2051 emailsSnap := slices.Clone(m.emails)
2052 acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2053 folderSnap := slices.Clone(m.folderEmails[folderName])
2054
2055 m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs)
2056 for _, uid := range msg.UIDs {
2057 m.removeEmailFromStores(uid, msg.AccountID)
2058 }
2059
2060 if emails, ok := m.folderEmails[folderName]; ok {
2061 var filtered []fetcher.Email
2062 for _, e := range emails {
2063 if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2064 filtered = append(filtered, e)
2065 }
2066 }
2067 m.folderEmails[folderName] = filtered
2068 go saveFolderEmailsToCache(folderName, filtered)
2069 }
2070
2071 pa := &pendingEmailAction{
2072 jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
2073 kind: actionKindDelete,
2074 uids: msg.UIDs,
2075 accountID: msg.AccountID,
2076 folderName: folderName,
2077 mailbox: msg.Mailbox,
2078 emailsSnap: emailsSnap,
2079 acctSnap: acctSnap,
2080 folderSnap: folderSnap,
2081 }
2082 flushCmd := m.flushPendingAction()
2083 notice := fmt.Sprintf("%d emails deleted (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
2084 if len(msg.UIDs) == 1 {
2085 notice = fmt.Sprintf("Email deleted (%s to undo)", config.Keybinds.Composer.UndoSend)
2086 }
2087 return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2088
2089 case tui.BatchArchiveEmailsMsg:
2090 tui.ClearKittyGraphics()
2091
2092 account := m.config.GetAccountByID(msg.AccountID)
2093 if account == nil {
2094 if m.folderInbox != nil {
2095 m.current = m.folderInbox
2096 }
2097 return m, nil
2098 }
2099
2100 folderName := folderInbox
2101 if m.folderInbox != nil {
2102 folderName = m.folderInbox.GetCurrentFolder()
2103 m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2104 }
2105
2106 emailsSnap := slices.Clone(m.emails)
2107 acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2108 folderSnap := slices.Clone(m.folderEmails[folderName])
2109
2110 m.decrementFolderUnreadForRemoved(folderName, msg.AccountID, msg.UIDs)
2111 for _, uid := range msg.UIDs {
2112 m.removeEmailFromStores(uid, msg.AccountID)
2113 }
2114
2115 if emails, ok := m.folderEmails[folderName]; ok {
2116 var filtered []fetcher.Email
2117 for _, e := range emails {
2118 if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2119 filtered = append(filtered, e)
2120 }
2121 }
2122 m.folderEmails[folderName] = filtered
2123 go saveFolderEmailsToCache(folderName, filtered)
2124 }
2125
2126 pa := &pendingEmailAction{
2127 jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
2128 kind: actionKindArchive,
2129 uids: msg.UIDs,
2130 accountID: msg.AccountID,
2131 folderName: folderName,
2132 mailbox: msg.Mailbox,
2133 emailsSnap: emailsSnap,
2134 acctSnap: acctSnap,
2135 folderSnap: folderSnap,
2136 }
2137 flushCmd := m.flushPendingAction()
2138 notice := fmt.Sprintf("%d emails archived (%s to undo)", len(msg.UIDs), config.Keybinds.Composer.UndoSend)
2139 if len(msg.UIDs) == 1 {
2140 notice = fmt.Sprintf("Email archived (%s to undo)", config.Keybinds.Composer.UndoSend)
2141 }
2142 return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2143
2144 case tui.BatchMoveEmailsMsg:
2145 if m.config == nil {
2146 return m, nil
2147 }
2148 account := m.config.GetAccountByID(msg.AccountID)
2149 if account == nil {
2150 return m, nil
2151 }
2152
2153 folderName := folderInbox
2154 if m.folderInbox != nil {
2155 folderName = m.folderInbox.GetCurrentFolder()
2156 m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID)
2157 }
2158
2159 emailsSnap := slices.Clone(m.emails)
2160 acctSnap := slices.Clone(m.emailsByAcct[msg.AccountID])
2161 folderSnap := slices.Clone(m.folderEmails[folderName])
2162
2163 for _, uid := range msg.UIDs {
2164 m.removeEmailFromStores(uid, msg.AccountID)
2165 }
2166
2167 if emails, ok := m.folderEmails[folderName]; ok {
2168 var filtered []fetcher.Email
2169 for _, e := range emails {
2170 if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) {
2171 filtered = append(filtered, e)
2172 }
2173 }
2174 m.folderEmails[folderName] = filtered
2175 go saveFolderEmailsToCache(folderName, filtered)
2176 }
2177
2178 pa := &pendingEmailAction{
2179 jobID: fmt.Sprintf("action-%d", time.Now().UnixNano()),
2180 kind: actionKindMove,
2181 uids: msg.UIDs,
2182 accountID: msg.AccountID,
2183 folderName: folderName,
2184 destFolder: msg.DestFolder,
2185 emailsSnap: emailsSnap,
2186 acctSnap: acctSnap,
2187 folderSnap: folderSnap,
2188 }
2189 flushCmd := m.flushPendingAction()
2190 notice := fmt.Sprintf("%d emails moved to %s (%s to undo)", len(msg.UIDs), msg.DestFolder, config.Keybinds.Composer.UndoSend)
2191 if len(msg.UIDs) == 1 {
2192 notice = fmt.Sprintf("Email moved to %s (%s to undo)", msg.DestFolder, config.Keybinds.Composer.UndoSend)
2193 }
2194 return m, tea.Batch(flushCmd, m.startActionGracePeriod(pa, notice))
2195
2196 case tui.BatchEmailActionDoneMsg:
2197 if msg.Err != nil {
2198 log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
2199 m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
2200 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2201 return tui.RestoreViewMsg{}
2202 })
2203 }
2204
2205 return m, nil
2206
2207 case tui.DownloadAttachmentMsg:
2208 m.previousModel = m.current
2209 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
2210
2211 account := m.config.GetAccountByID(msg.AccountID)
2212 if account == nil {
2213 m.current = m.previousModel
2214 return m, nil
2215 }
2216
2217 email := m.getEmailByIndex(msg.Index)
2218 if email == nil {
2219 m.current = m.previousModel
2220 return m, nil
2221 }
2222
2223 // Find the correct attachment to get encoding
2224 var encoding string
2225 for _, att := range email.Attachments {
2226 if att.PartID == msg.PartID {
2227 encoding = att.Encoding
2228 break
2229 }
2230 }
2231 newMsg := tui.DownloadAttachmentMsg{
2232 Index: msg.Index,
2233 Filename: msg.Filename,
2234 PartID: msg.PartID,
2235 Data: msg.Data,
2236 AccountID: msg.AccountID,
2237 Encoding: encoding,
2238 Mailbox: msg.Mailbox,
2239 }
2240 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg))
2241
2242 case tui.AttachmentDownloadedMsg:
2243 var statusMsg string
2244 if msg.Err != nil {
2245 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
2246 } else {
2247 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
2248 }
2249 m.current = tui.NewStatus(statusMsg)
2250 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
2251 return tui.RestoreViewMsg{}
2252 })
2253
2254 case tui.RestoreViewMsg:
2255 if m.previousModel != nil {
2256 m.current = m.previousModel
2257 m.previousModel = nil
2258 }
2259 return m, nil
2260 }
2261
2262 if cmd := m.pluginNotifyCmd(); cmd != nil {
2263 cmds = append(cmds, cmd)
2264 }
2265
2266 return m, tea.Batch(cmds...)
2267}
2268
2269func (m *mainModel) View() tea.View {
2270 v := m.current.View()
2271 if m.showLogPanel {
2272 v.Content = m.renderWithLogPanel(v.Content)
2273 }
2274 if m.sendNotice != "" {
2275 v.Content = m.renderSendNoticeOverlay(v.Content)
2276 }
2277 if m.actionNotice != "" {
2278 v.Content = m.renderActionNoticeOverlay(v.Content)
2279 }
2280 v.AltScreen = true
2281 return v
2282}
2283
2284func (m *mainModel) renderSendNoticeOverlay(content string) string {
2285 box := lipgloss.NewStyle().
2286 Border(lipgloss.RoundedBorder()).
2287 BorderForeground(theme.ActiveTheme.Accent).
2288 Padding(0, 1).
2289 Render(m.sendNotice)
2290 lines := strings.Split(box, "\n")
2291 boxWidth := lipgloss.Width(lines[0])
2292 col := max(0, m.width-boxWidth)
2293 return overlay.Block(content, lines, 0, col)
2294}
2295
2296func (m *mainModel) renderActionNoticeOverlay(content string) string {
2297 box := lipgloss.NewStyle().
2298 Border(lipgloss.RoundedBorder()).
2299 BorderForeground(theme.ActiveTheme.Accent).
2300 Padding(0, 1).
2301 Render(m.actionNotice)
2302 lines := strings.Split(box, "\n")
2303 boxWidth := lipgloss.Width(lines[0])
2304 col := max(0, m.width-boxWidth)
2305 return overlay.Block(content, lines, 0, col)
2306}
2307
2308func (m *mainModel) startActionGracePeriod(pa *pendingEmailAction, notice string) tea.Cmd {
2309 m.pendingAction = pa
2310 m.actionNotice = notice
2311 delay := time.Duration(m.config.GetUndoDelaySeconds()) * time.Second
2312 jobID := pa.jobID
2313 return tea.Tick(delay, func(t time.Time) tea.Msg {
2314 return tui.ActionGracePeriodExpiredMsg{JobID: jobID}
2315 })
2316}
2317
2318func (m *mainModel) flushPendingAction() tea.Cmd {
2319 if m.pendingAction == nil {
2320 return nil
2321 }
2322 pa := m.pendingAction
2323 m.pendingAction = nil
2324 m.actionNotice = ""
2325 return m.executePendingAction(pa)
2326}
2327
2328func (m *mainModel) executePendingAction(pa *pendingEmailAction) tea.Cmd {
2329 switch pa.kind {
2330 case actionKindDelete:
2331 return m.batchDeleteEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
2332 case actionKindArchive:
2333 return m.batchArchiveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.mailbox, len(pa.uids))
2334 case actionKindMove:
2335 return m.batchMoveEmailsCmd(pa.uids, pa.accountID, pa.folderName, pa.destFolder, len(pa.uids))
2336 }
2337 return nil
2338}
2339
2340func (m *mainModel) restorePendingAction() {
2341 if m.pendingAction == nil {
2342 return
2343 }
2344 pa := m.pendingAction
2345 m.pendingAction = nil
2346 m.actionNotice = ""
2347
2348 m.emails = pa.emailsSnap
2349 m.emailsByAcct[pa.accountID] = pa.acctSnap
2350 m.folderEmails[pa.folderName] = pa.folderSnap
2351
2352 if m.folderInbox != nil {
2353 m.folderInbox.SetEmails(pa.folderSnap, m.config.Accounts)
2354 // Restore the folder unread counter that was decremented when the action
2355 // was applied, so the sidebar count is correct again after undo.
2356 restored := false
2357 for _, uid := range pa.uids {
2358 for _, e := range pa.folderSnap {
2359 if e.UID == uid && e.AccountID == pa.accountID {
2360 if !e.IsRead {
2361 m.folderInbox.IncrementUnreadCount(pa.folderName)
2362 restored = true
2363 }
2364 break
2365 }
2366 }
2367 }
2368 if restored {
2369 config.SaveAccountFolders(pa.accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
2370 }
2371 }
2372 go saveFolderEmailsToCache(pa.folderName, pa.folderSnap)
2373}
2374
2375func (m *mainModel) currentWindowSize() tea.WindowSizeMsg {
2376 return tea.WindowSizeMsg{
2377 Width: m.width,
2378 Height: m.contentHeight(),
2379 }
2380}
2381
2382func (m *mainModel) contentHeight() int {
2383 height := m.height - m.logPanelHeight()
2384 if height < 1 {
2385 return 1
2386 }
2387 return height
2388}
2389
2390func (m *mainModel) renderWithLogPanel(content string) string {
2391 panelHeight := m.logPanelHeight()
2392 if panelHeight == 0 {
2393 return content
2394 }
2395
2396 contentHeight := m.contentHeight()
2397
2398 mainContent := lipgloss.NewStyle().
2399 MaxHeight(contentHeight).
2400 Height(contentHeight).
2401 Render(content)
2402
2403 if m.logPanel == nil {
2404 return mainContent
2405 }
2406 m.logPanel.SetSize(m.width, panelHeight)
2407 return lipgloss.JoinVertical(lipgloss.Left, mainContent, m.logPanel.View())
2408}
2409
2410func (m *mainModel) logPanelHeight() int {
2411 if !m.showLogPanel || m.height < 12 || m.width < 20 {
2412 return 0
2413 }
2414 if m.height < 20 {
2415 return 4
2416 }
2417 return 7
2418}
2419
2420func (m *mainModel) getEmailByIndex(index int) *fetcher.Email {
2421 if index >= 0 && index < len(m.emails) {
2422 return &m.emails[index]
2423 }
2424 return nil
2425}
2426
2427func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email {
2428 for i := range m.emails {
2429 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2430 return &m.emails[i]
2431 }
2432 }
2433 return nil
2434}
2435
2436func (m *mainModel) getEmailIndex(uid uint32, accountID string) int {
2437 for i := range m.emails {
2438 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2439 return i
2440 }
2441 }
2442 return -1
2443}
2444
2445func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body, bodyMIMEType string, attachments []fetcher.Attachment) {
2446 for i := range m.emails {
2447 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2448 m.emails[i].Body = body
2449 m.emails[i].BodyMIMEType = bodyMIMEType
2450 m.emails[i].Attachments = attachments
2451 break
2452 }
2453 }
2454 if emails, ok := m.emailsByAcct[accountID]; ok {
2455 for i := range emails {
2456 if emails[i].UID == uid {
2457 emails[i].Body = body
2458 emails[i].BodyMIMEType = bodyMIMEType
2459 emails[i].Attachments = attachments
2460 break
2461 }
2462 }
2463 }
2464}
2465
2466func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, _ tui.MailboxKind) {
2467 if m.getEmailByUIDAndAccount(email.UID, email.AccountID) != nil {
2468 return
2469 }
2470 if m.emailsByAcct == nil {
2471 m.emailsByAcct = make(map[string][]fetcher.Email)
2472 }
2473 m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email)
2474 m.emails = flattenAndSort(m.emailsByAcct)
2475}
2476
2477func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
2478 for i := range m.emails {
2479 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2480 m.emails[i].IsRead = true
2481 break
2482 }
2483 }
2484 if emails, ok := m.emailsByAcct[accountID]; ok {
2485 for i := range emails {
2486 if emails[i].UID == uid {
2487 emails[i].IsRead = true
2488 break
2489 }
2490 }
2491 }
2492 // Update folder email cache
2493 for folderName, folderEmails := range m.folderEmails {
2494 for i := range folderEmails {
2495 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2496 folderEmails[i].IsRead = true
2497 m.folderEmails[folderName] = folderEmails
2498 go saveFolderEmailsToCache(folderName, folderEmails)
2499 break
2500 }
2501 }
2502 }
2503 // Update the inbox UI
2504 if m.folderInbox != nil {
2505 m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
2506
2507 for folderName, folderEmails := range m.folderEmails {
2508 for _, e := range folderEmails {
2509 if e.UID == uid && e.AccountID == accountID {
2510 m.folderInbox.DecrementUnreadCount(folderName)
2511 config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
2512 return
2513 }
2514 }
2515 }
2516 }
2517}
2518
2519func (m *mainModel) markEmailAsUnreadInStores(uid uint32, accountID string) {
2520 for i := range m.emails {
2521 if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
2522 m.emails[i].IsRead = false
2523 break
2524 }
2525 }
2526 if emails, ok := m.emailsByAcct[accountID]; ok {
2527 for i := range emails {
2528 if emails[i].UID == uid {
2529 emails[i].IsRead = false
2530 break
2531 }
2532 }
2533 }
2534 for folderName, folderEmails := range m.folderEmails {
2535 for i := range folderEmails {
2536 if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
2537 folderEmails[i].IsRead = false
2538 m.folderEmails[folderName] = folderEmails
2539 go saveFolderEmailsToCache(folderName, folderEmails)
2540 break
2541 }
2542 }
2543 }
2544 if m.folderInbox != nil {
2545 m.folderInbox.GetInbox().MarkEmailAsUnread(uid, accountID)
2546 }
2547}
2548
2549func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
2550 var filtered []fetcher.Email
2551 for _, e := range m.emails {
2552 if e.UID != uid || e.AccountID != accountID {
2553 filtered = append(filtered, e)
2554 }
2555 }
2556 m.emails = filtered
2557 if emails, ok := m.emailsByAcct[accountID]; ok {
2558 var filteredAcct []fetcher.Email
2559 for _, e := range emails {
2560 if e.UID != uid {
2561 filteredAcct = append(filteredAcct, e)
2562 }
2563 }
2564 m.emailsByAcct[accountID] = filteredAcct
2565 }
2566}
2567
2568// decrementFolderUnreadForRemoved updates the sidebar folder unread counter when
2569// emails are removed from a folder (delete/archive). It mirrors the read-path in
2570// markEmailAsReadInStores: each removed email that is currently unread decrements
2571// the folder's counter, so the count shown in the folder list updates immediately
2572// instead of only on the next fetch (issue #1404).
2573//
2574// It must be called before the email is removed from the stores, since it reads
2575// the email's unread status from folderEmails.
2576func (m *mainModel) decrementFolderUnreadForRemoved(folderName, accountID string, uids []uint32) {
2577 if m.folderInbox == nil || len(uids) == 0 {
2578 return
2579 }
2580
2581 emails, ok := m.folderEmails[folderName]
2582 if !ok {
2583 return
2584 }
2585
2586 decremented := false
2587 for _, uid := range uids {
2588 for _, e := range emails {
2589 if e.UID == uid && e.AccountID == accountID {
2590 if !e.IsRead {
2591 m.folderInbox.DecrementUnreadCount(folderName)
2592 decremented = true
2593 }
2594 break
2595 }
2596 }
2597 }
2598
2599 if decremented {
2600 config.SaveAccountFolders(accountID, m.folderInbox.GetFolders(), m.folderInbox.GetUnreadCountsCopy()) //nolint:errcheck,gosec
2601 }
2602}
2603
2604// pluginFlagCmds drains pending flag ops from plugins and returns the corresponding tea.Cmds.
2605func (m *mainModel) pluginFlagCmds() []tea.Cmd {
2606 if m.plugins == nil {
2607 return nil
2608 }
2609 ops := m.plugins.TakePendingFlagOps()
2610 if len(ops) == 0 {
2611 return nil
2612 }
2613 var cmds []tea.Cmd
2614 for _, op := range ops {
2615 account := m.config.GetAccountByID(op.AccountID)
2616 if account == nil {
2617 continue
2618 }
2619 if op.Read {
2620 m.markEmailAsReadInStores(op.UID, op.AccountID)
2621 cmds = append(cmds, markEmailAsReadCmd(account, op.UID, op.AccountID, op.Folder))
2622 } else {
2623 m.markEmailAsUnreadInStores(op.UID, op.AccountID)
2624 cmds = append(cmds, markEmailAsUnreadCmd(account, op.UID, op.AccountID, op.Folder))
2625 }
2626 }
2627 return cmds
2628}
2629
2630// pluginNotifyCmd checks for a pending plugin notification and returns a command if one exists.
2631func (m *mainModel) pluginNotifyCmd() tea.Cmd {
2632 if m.plugins == nil {
2633 return nil
2634 }
2635 if n, ok := m.plugins.TakePendingNotification(); ok {
2636 return func() tea.Msg {
2637 return tui.PluginNotifyMsg{Message: n.Message, Duration: n.Duration}
2638 }
2639 }
2640 return nil
2641}
2642
2643func (m *mainModel) syncPluginStatus() {
2644 if m.plugins == nil {
2645 return
2646 }
2647 if m.folderInbox != nil {
2648 m.folderInbox.GetInbox().SetPluginStatus(m.plugins.StatusText(plugin.StatusInbox))
2649 }
2650 switch v := m.current.(type) {
2651 case *tui.Composer:
2652 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusComposer))
2653 case *tui.EmailView:
2654 v.SetPluginStatus(m.plugins.StatusText(plugin.StatusEmailView))
2655 }
2656}
2657
2658func (m *mainModel) handlePluginKeyBinding(msg tea.KeyPressMsg) tea.Cmd {
2659 keyStr := msg.String()
2660
2661 var area string
2662 switch m.current.(type) {
2663 case *tui.Inbox:
2664 area = plugin.StatusInbox
2665 case *tui.FolderInbox:
2666 area = plugin.StatusInbox
2667 case *tui.EmailView:
2668 area = plugin.StatusEmailView
2669 case *tui.Composer:
2670 area = plugin.StatusComposer
2671 default:
2672 return nil
2673 }
2674
2675 bindings := m.plugins.Bindings(area)
2676 for _, binding := range bindings {
2677 if binding.Key != keyStr {
2678 continue
2679 }
2680
2681 // Build context table based on the current view
2682 switch v := m.current.(type) {
2683 case *tui.Inbox:
2684 if email := v.GetSelectedEmail(); email != nil {
2685 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2686 m.plugins.CallKeyBinding(binding, t)
2687 } else {
2688 m.plugins.CallKeyBinding(binding)
2689 }
2690 case *tui.FolderInbox:
2691 if email := v.GetInbox().GetSelectedEmail(); email != nil {
2692 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, v.GetCurrentFolder())
2693 m.plugins.CallKeyBinding(binding, t)
2694 } else {
2695 m.plugins.CallKeyBinding(binding)
2696 }
2697 case *tui.EmailView:
2698 email := v.GetEmail()
2699 t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, "")
2700 m.plugins.CallKeyBinding(binding, t)
2701 case *tui.Composer:
2702 L := m.plugins.LuaState()
2703 t := L.NewTable()
2704 t.RawSetString("body", lua.LString(v.GetBody()))
2705 t.RawSetString("body_len", lua.LNumber(len(v.GetBody())))
2706 t.RawSetString("subject", lua.LString(v.GetSubject()))
2707 t.RawSetString("to", lua.LString(v.GetTo()))
2708 t.RawSetString("cc", lua.LString(v.GetCc()))
2709 t.RawSetString("bcc", lua.LString(v.GetBcc()))
2710 m.plugins.CallKeyBinding(binding, t)
2711 m.applyPluginFields(v)
2712
2713 // Check if the plugin requested a prompt overlay
2714 if p, ok := m.plugins.TakePendingPrompt(); ok {
2715 m.pendingPrompt = p
2716 v.ShowPluginPrompt(p.Placeholder)
2717 }
2718 }
2719
2720 m.syncPluginStatus()
2721 return tea.Batch(m.pluginFlagCmds()...)
2722 }
2723 return nil
2724}
2725
2726func (m *mainModel) isSearchOverlayOpen() bool {
2727 switch v := m.current.(type) {
2728 case *tui.Inbox:
2729 return v.IsSearchOverlayOpen()
2730 case *tui.FolderInbox:
2731 return v.GetInbox().IsSearchOverlayOpen()
2732 }
2733 return false
2734}
2735
2736func (m *mainModel) syncPluginKeyBindings() {
2737 if m.plugins == nil {
2738 return
2739 }
2740
2741 toPluginKeyBindings := func(bindings []plugin.KeyBinding) []tui.PluginKeyBinding {
2742 result := make([]tui.PluginKeyBinding, len(bindings))
2743 for i, b := range bindings {
2744 result[i] = tui.PluginKeyBinding{Key: b.Key, Description: b.Description}
2745 }
2746 return result
2747 }
2748
2749 if m.folderInbox != nil {
2750 m.folderInbox.GetInbox().SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusInbox)))
2751 }
2752 switch v := m.current.(type) {
2753 case *tui.Composer:
2754 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusComposer)))
2755 case *tui.EmailView:
2756 v.SetPluginKeyBindings(toPluginKeyBindings(m.plugins.Bindings(plugin.StatusEmailView)))
2757 }
2758}
2759
2760func (m *mainModel) applyPluginFields(composer *tui.Composer) {
2761 fields := m.plugins.TakePendingFields()
2762 if fields == nil {
2763 return
2764 }
2765 for field, value := range fields {
2766 switch field {
2767 case "to":
2768 composer.SetTo(value)
2769 case "cc":
2770 composer.SetCc(value)
2771 case "bcc":
2772 composer.SetBcc(value)
2773 case "subject":
2774 composer.SetSubject(value)
2775 case "body":
2776 composer.SetBody(value)
2777 }
2778 }
2779}
2780
2781func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email {
2782 var allEmails []fetcher.Email
2783 for _, emails := range emailsByAccount {
2784 allEmails = append(allEmails, emails...)
2785 }
2786 for i := 0; i < len(allEmails); i++ {
2787 for j := i + 1; j < len(allEmails); j++ {
2788 if allEmails[j].Date.After(allEmails[i].Date) {
2789 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
2790 }
2791 }
2792 }
2793 return allEmails
2794}
2795
2796func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd {
2797 return func() tea.Msg {
2798 ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout)
2799 defer cancel()
2800
2801 var accounts []config.Account
2802 for _, acc := range m.config.Accounts {
2803 if accountID == "" || acc.ID == accountID {
2804 accounts = append(accounts, acc)
2805 }
2806 }
2807
2808 var results []fetcher.Email
2809 var firstErr error
2810 succeeded := false
2811 for i := range accounts {
2812 acc := &accounts[i]
2813 p := m.getProvider(acc)
2814 if p == nil {
2815 if firstErr == nil {
2816 firstErr = fmt.Errorf("provider not found for account %s", acc.ID)
2817 }
2818 continue
2819 }
2820 emails, err := p.Search(ctx, folderName, query)
2821 if err != nil {
2822 if errors.Is(err, backend.ErrNotSupported) {
2823 continue
2824 }
2825 if firstErr == nil {
2826 firstErr = err
2827 }
2828 continue
2829 }
2830 succeeded = true
2831 results = append(results, backendEmailsToFetcher(emails)...)
2832 }
2833 if !succeeded && firstErr != nil {
2834 return tui.SearchResultsMsg{Query: query, Err: firstErr}
2835 }
2836 sortFetcherEmails(results)
2837
2838 return tui.SearchResultsMsg{Query: query, Emails: results}
2839 }
2840}
2841
2842func backendEmailsToFetcher(emails []backend.Email) []fetcher.Email {
2843 result := make([]fetcher.Email, len(emails))
2844 for i, e := range emails {
2845 result[i] = fetcher.Email{
2846 UID: e.UID, From: e.From, To: e.To, ReplyTo: e.ReplyTo,
2847 Subject: e.Subject, Body: e.Body, Date: e.Date, IsRead: e.IsRead,
2848 MessageID: e.MessageID, References: e.References, AccountID: e.AccountID,
2849 }
2850 }
2851 return result
2852}
2853
2854func sortFetcherEmails(emails []fetcher.Email) {
2855 sort.Slice(emails, func(i, j int) bool {
2856 if emails[i].Date.Equal(emails[j].Date) {
2857 return emails[i].UID > emails[j].UID
2858 }
2859 return emails[i].Date.After(emails[j].Date)
2860 })
2861}
2862
2863func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
2864 return func() tea.Msg {
2865 emailsByAccount := make(map[string][]fetcher.Email)
2866 var mu sync.Mutex
2867 var wg sync.WaitGroup
2868
2869 for _, account := range cfg.Accounts {
2870 wg.Add(1)
2871 go func(acc config.Account) {
2872 defer wg.Done()
2873 var emails []fetcher.Email
2874 var err error
2875
2876 limit := uint32(initialEmailLimit)
2877 if counts != nil {
2878 if c, ok := counts[acc.ID]; ok && c > 0 {
2879 limit = uint32(c)
2880 }
2881 }
2882
2883 if mailbox == tui.MailboxSent {
2884 emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
2885 } else {
2886 emails, err = fetcher.FetchEmails(&acc, limit, 0)
2887 }
2888 if err != nil {
2889 log.Printf("Error fetching from %s: %v", acc.Email, err)
2890 return
2891 }
2892 mu.Lock()
2893 emailsByAccount[acc.ID] = emails
2894 mu.Unlock()
2895 }(account)
2896 }
2897
2898 wg.Wait()
2899 return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox}
2900 }
2901}
2902
2903func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
2904 cached := make([]config.CachedEmail, 0, len(emails))
2905 for _, email := range emails {
2906 cached = append(cached, config.CachedEmail{
2907 UID: email.UID,
2908 From: email.From,
2909 To: email.To,
2910 Subject: email.Subject,
2911 Date: email.Date,
2912 MessageID: email.MessageID,
2913 InReplyTo: email.InReplyTo,
2914 References: email.References,
2915 AccountID: email.AccountID,
2916 IsRead: email.IsRead,
2917 })
2918 }
2919 return cached
2920}
2921
2922func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
2923 emails := make([]fetcher.Email, 0, len(cached))
2924 for _, c := range cached {
2925 emails = append(emails, fetcher.Email{
2926 UID: c.UID,
2927 From: c.From,
2928 To: c.To,
2929 Subject: c.Subject,
2930 Date: c.Date,
2931 MessageID: c.MessageID,
2932 InReplyTo: c.InReplyTo,
2933 References: c.References,
2934 AccountID: c.AccountID,
2935 IsRead: c.IsRead,
2936 })
2937 }
2938 return emails
2939}
2940
2941func saveFolderEmailsToCache(folderName string, emails []fetcher.Email) {
2942 cached := emailsToCache(emails)
2943 if err := config.SaveFolderEmailCache(folderName, cached); err != nil {
2944 log.Printf("Error saving folder email cache for %s: %v", folderName, err)
2945 }
2946}
2947
2948func loadFolderEmailsFromCache(folderName string) []fetcher.Email {
2949 cached, err := config.LoadFolderEmailCache(folderName)
2950 if err != nil {
2951 return nil
2952 }
2953 return cacheToEmails(cached)
2954}
2955
2956// parseEmailAddress parses "Name <email>" or just "email" format
2957func parseEmailAddress(addr string) (name, email string) {
2958 addr = strings.TrimSpace(addr)
2959 if idx := strings.Index(addr, "<"); idx != -1 {
2960 name = strings.TrimSpace(addr[:idx])
2961 endIdx := strings.Index(addr, ">")
2962 if endIdx > idx {
2963 email = strings.TrimSpace(addr[idx+1 : endIdx])
2964 } else {
2965 email = strings.TrimSpace(addr[idx+1:])
2966 }
2967 } else {
2968 email = addr
2969 }
2970 return name, email
2971}
2972
2973func markdownToHTML(md []byte) []byte {
2974 return clib.MarkdownToHTML(md)
2975}
2976
2977func splitEmails(s string) []string {
2978 if s == "" {
2979 return nil
2980 }
2981 parts := strings.Split(s, ",")
2982 var res []string
2983 for _, p := range parts {
2984 if trimmed := strings.TrimSpace(p); trimmed != "" {
2985 res = append(res, trimmed)
2986 }
2987 }
2988 return res
2989}
2990
2991func (m *mainModel) sendEmailCmd(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
2992 return func() tea.Msg {
2993 if account == nil {
2994 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
2995 }
2996
2997 // Apply custom From address for catch-all accounts.
2998 if msg.FromOverride != "" {
2999 acc := *account
3000 acc.SendAsEmail = msg.FromOverride
3001 account = &acc
3002 }
3003
3004 recipients := splitEmails(msg.To)
3005 cc := splitEmails(msg.Cc)
3006 bcc := splitEmails(msg.Bcc)
3007 body := msg.Body
3008 // Append signature if present
3009 if msg.Signature != "" {
3010 body = body + "\n\n" + msg.Signature
3011 }
3012 // Append quoted text if present (for replies)
3013 if msg.QuotedText != "" {
3014 body += msg.QuotedText
3015 }
3016 images := make(map[string][]byte)
3017 attachments := make(map[string][]byte)
3018
3019 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3020 matches := re.FindAllStringSubmatch(body, -1)
3021
3022 for _, match := range matches {
3023 imgPath := match[1]
3024 imgData, err := os.ReadFile(imgPath)
3025 if err != nil {
3026 log.Printf("Could not read image file %s: %v", imgPath, err)
3027 continue
3028 }
3029 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3030 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3031 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
3032 }
3033
3034 htmlBody := markdownToHTML([]byte(body))
3035
3036 for _, attachPath := range msg.AttachmentPaths {
3037 fileData, err := os.ReadFile(attachPath)
3038 if err != nil {
3039 log.Printf("Could not read attachment file %s: %v", attachPath, err)
3040 continue
3041 }
3042 _, filename := filepath.Split(attachPath)
3043 attachments[filename] = fileData
3044 }
3045
3046 delaySeconds := m.config.GetUndoDelaySeconds()
3047 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)
3048
3049 if err != nil {
3050 log.Printf("Failed to queue email: %v", err)
3051 return tui.EmailResultMsg{Err: err}
3052 }
3053
3054 return tui.EmailQueuedMsg{JobID: jobID, DelaySeconds: delaySeconds}
3055 }
3056}
3057
3058func sendRSVP(account *config.Account, msg tui.SendRSVPMsg) tea.Cmd {
3059 return func() tea.Msg {
3060 if account == nil {
3061 return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")}
3062 }
3063
3064 // Generate RSVP .ics
3065 rsvpICS, err := calendar.GenerateRSVP(msg.OriginalICS, account.Email, msg.Response)
3066 if err != nil {
3067 return tui.EmailResultMsg{Err: fmt.Errorf("generate RSVP: %w", err)}
3068 }
3069
3070 // Compose reply email
3071 subject := fmt.Sprintf("Re: %s", msg.Event.Summary)
3072 bodyText := fmt.Sprintf("%s: %s\n\n%s",
3073 msg.Response,
3074 msg.Event.Summary,
3075 msg.Event.Start.Local().Format("Mon Jan 2, 2006 3:04 PM"))
3076 if msg.Event.Location != "" {
3077 bodyText += " at " + msg.Event.Location
3078 }
3079
3080 // Send as multipart/alternative with text/calendar; method=REPLY
3081 // This iMIP format is required for Google Calendar to recognize the RSVP
3082 references := append(msg.References, msg.InReplyTo) //nolint:gocritic
3083 rawMsg, err := sender.SendCalendarReply(
3084 account,
3085 []string{msg.Event.Organizer},
3086 subject,
3087 bodyText,
3088 rsvpICS,
3089 msg.InReplyTo,
3090 references,
3091 )
3092
3093 if err != nil {
3094 return tui.RSVPResultMsg{Err: fmt.Errorf("send RSVP: %w", err), Response: msg.Response, Organizer: msg.Event.Organizer}
3095 }
3096
3097 // Append to Sent folder
3098 if account.ServiceProvider != "gmail" {
3099 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3100 log.Printf("Failed to append RSVP to Sent folder: %v", err)
3101 }
3102 }
3103
3104 return tui.RSVPResultMsg{Response: msg.Response, Organizer: msg.Event.Organizer}
3105 }
3106}
3107
3108// --- External editor command ---
3109
3110// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
3111func openExternalEditor(body string) tea.Cmd {
3112 editor := os.Getenv("EDITOR")
3113 if editor == "" {
3114 editor = os.Getenv("VISUAL")
3115 }
3116 if editor == "" {
3117 editor = "vi"
3118 }
3119
3120 tmpFile, err := os.CreateTemp("", "matcha-*.md")
3121 if err != nil {
3122 return func() tea.Msg {
3123 return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
3124 }
3125 }
3126 tmpPath := tmpFile.Name()
3127
3128 if _, err := tmpFile.WriteString(body); err != nil {
3129 writeErr := err
3130 if err := tmpFile.Close(); err != nil {
3131 _ = os.Remove(tmpPath)
3132 return func() tea.Msg {
3133 return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file after write failure: %w", err)}
3134 }
3135 }
3136 _ = os.Remove(tmpPath)
3137 return func() tea.Msg {
3138 return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", writeErr)}
3139 }
3140 }
3141 if err := tmpFile.Close(); err != nil {
3142 _ = os.Remove(tmpPath)
3143 return func() tea.Msg {
3144 return tui.EditorFinishedMsg{Err: fmt.Errorf("closing temp file: %w", err)}
3145 }
3146 }
3147
3148 parts := strings.Fields(editor)
3149 args := append(parts[1:], tmpPath) //nolint:gocritic
3150 c := exec.Command(parts[0], args...) //nolint:gosec,noctx
3151 return tea.ExecProcess(c, func(err error) tea.Msg {
3152 defer func() {
3153 _ = os.Remove(tmpPath)
3154 }()
3155 if err != nil {
3156 return tui.EditorFinishedMsg{Err: err}
3157 }
3158 content, readErr := os.ReadFile(tmpPath)
3159 if readErr != nil {
3160 return tui.EditorFinishedMsg{Err: readErr}
3161 }
3162 return tui.EditorFinishedMsg{Body: string(content)}
3163 })
3164}
3165
3166// --- IDLE command ---
3167
3168// listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.
3169func listenForIdleUpdates(ch <-chan fetcher.IdleUpdate) tea.Cmd {
3170 return func() tea.Msg {
3171 update, ok := <-ch
3172 if !ok {
3173 return nil
3174 }
3175 return tui.IdleNewMailMsg{
3176 AccountID: update.AccountID,
3177 FolderName: update.FolderName,
3178 }
3179 }
3180}
3181
3182// --- Daemon event listener ---
3183
3184// listenForDaemonEvents blocks until a daemon event arrives, then returns it as a tea.Msg.
3185func listenForDaemonEvents(ch <-chan *daemonrpc.Event) tea.Cmd {
3186 return func() tea.Msg {
3187 ev, ok := <-ch
3188 if !ok {
3189 return nil
3190 }
3191 return tui.DaemonEventMsg{Event: ev}
3192 }
3193}
3194
3195// --- Folder-based command functions ---
3196
3197func fetchFoldersCmd(cfg *config.Config) tea.Cmd {
3198 return func() tea.Msg {
3199 if !cfg.HasAccounts() {
3200 return nil
3201 }
3202 foldersByAccount := make(map[string][]fetcher.Folder)
3203 errsByAccount := make(map[string]error)
3204 seen := make(map[string]fetcher.Folder)
3205 var mu sync.Mutex
3206 var wg sync.WaitGroup
3207
3208 for _, account := range cfg.Accounts {
3209 wg.Add(1)
3210 go func(acc config.Account) {
3211 defer wg.Done()
3212 folders, err := fetcher.FetchFolders(&acc)
3213 if err != nil {
3214 mu.Lock()
3215 errsByAccount[acc.ID] = err
3216 mu.Unlock()
3217 return
3218 }
3219 mu.Lock()
3220 foldersByAccount[acc.ID] = folders
3221 for _, f := range folders {
3222 if _, ok := seen[f.Name]; !ok {
3223 seen[f.Name] = f
3224 }
3225 }
3226 mu.Unlock()
3227 }(account)
3228 }
3229 wg.Wait()
3230
3231 var merged []fetcher.Folder
3232 for _, f := range seen {
3233 merged = append(merged, f)
3234 }
3235
3236 return tui.FoldersFetchedMsg{
3237 FoldersByAccount: foldersByAccount,
3238 MergedFolders: merged,
3239 Errors: errsByAccount,
3240 }
3241 }
3242}
3243
3244func fetchFolderEmailsCmd(cfg *config.Config, folderName string) tea.Cmd {
3245 return func() tea.Msg {
3246 emailsByAccount := make(map[string][]fetcher.Email)
3247 var mu sync.Mutex
3248 var wg sync.WaitGroup
3249
3250 for _, account := range cfg.Accounts {
3251 wg.Add(1)
3252 go func(acc config.Account) {
3253 defer wg.Done()
3254 emails, err := fetcher.FetchFolderEmails(&acc, folderName, initialEmailLimit, 0)
3255 if err != nil {
3256 // Folder may not exist for this account — silently skip
3257 return
3258 }
3259 mu.Lock()
3260 emailsByAccount[acc.ID] = emails
3261 mu.Unlock()
3262 }(account)
3263 }
3264
3265 wg.Wait()
3266
3267 // Flatten all account emails
3268 var allEmails []fetcher.Email
3269 for _, emails := range emailsByAccount {
3270 allEmails = append(allEmails, emails...)
3271 }
3272 // Sort newest first
3273 for i := 0; i < len(allEmails); i++ {
3274 for j := i + 1; j < len(allEmails); j++ {
3275 if allEmails[j].Date.After(allEmails[i].Date) {
3276 allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
3277 }
3278 }
3279 }
3280
3281 return tui.FolderEmailsFetchedMsg{
3282 Emails: allEmails,
3283 FolderName: folderName,
3284 }
3285 }
3286}
3287
3288func fetchFolderEmailsPaginatedCmd(account *config.Account, folderName string, limit, offset uint32) tea.Cmd {
3289 return func() tea.Msg {
3290 emails, err := fetcher.FetchFolderEmails(account, folderName, limit, offset)
3291 if err != nil {
3292 return tui.FetchErr(err)
3293 }
3294 return tui.FolderEmailsAppendedMsg{
3295 Emails: emails,
3296 AccountID: account.ID,
3297 FolderName: folderName,
3298 }
3299 }
3300}
3301
3302func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
3303 return func() tea.Msg {
3304 account := cfg.GetAccountByID(accountID)
3305 if account == nil {
3306 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")}
3307 }
3308
3309 body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3310 if err != nil {
3311 return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err}
3312 }
3313
3314 return tui.EmailBodyFetchedMsg{
3315 UID: uid,
3316 Body: body,
3317 BodyMIMEType: bodyMIMEType,
3318 Attachments: attachments,
3319 AccountID: accountID,
3320 Mailbox: mailbox,
3321 }
3322 }
3323}
3324
3325func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folderName string) tea.Cmd {
3326 return func() tea.Msg {
3327 account := cfg.GetAccountByID(accountID)
3328 if account == nil {
3329 return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")}
3330 }
3331
3332 body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid)
3333 if err != nil {
3334 return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err}
3335 }
3336
3337 return tui.PreviewBodyFetchedMsg{
3338 UID: uid,
3339 Body: body,
3340 BodyMIMEType: bodyMIMEType,
3341 Attachments: attachments,
3342 AccountID: accountID,
3343 }
3344 }
3345}
3346
3347func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3348 return func() tea.Msg {
3349 err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
3350 return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
3351 }
3352}
3353
3354func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
3355 return func() tea.Msg {
3356 err := fetcher.MarkEmailAsUnreadInMailbox(account, folderName, uid)
3357 return tui.EmailMarkedUnreadMsg{UID: uid, AccountID: accountID, Err: err}
3358 }
3359}
3360
3361func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3362 return func() tea.Msg {
3363 if m.service == nil {
3364 return tui.BatchEmailActionDoneMsg{
3365 Count: count,
3366 SuccessCount: 0,
3367 FailureCount: count,
3368 Action: actionKindDelete,
3369 Mailbox: mailbox,
3370 Err: fmt.Errorf("service not initialized"),
3371 }
3372 }
3373
3374 err := m.service.DeleteEmails(accountID, folderName, uids)
3375
3376 successCount, failureCount := count, 0
3377 if err != nil {
3378 successCount, failureCount = 0, count
3379 }
3380
3381 return tui.BatchEmailActionDoneMsg{
3382 Count: count,
3383 SuccessCount: successCount,
3384 FailureCount: failureCount,
3385 Action: actionKindDelete,
3386 Mailbox: mailbox,
3387 Err: err,
3388 }
3389 }
3390}
3391
3392func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
3393 return func() tea.Msg {
3394 if m.service == nil {
3395 return tui.BatchEmailActionDoneMsg{
3396 Count: count,
3397 SuccessCount: 0,
3398 FailureCount: count,
3399 Action: actionKindArchive,
3400 Mailbox: mailbox,
3401 Err: fmt.Errorf("service not initialized"),
3402 }
3403 }
3404
3405 err := m.service.ArchiveEmails(accountID, folderName, uids)
3406
3407 successCount, failureCount := count, 0
3408 if err != nil {
3409 successCount, failureCount = 0, count
3410 }
3411
3412 return tui.BatchEmailActionDoneMsg{
3413 Count: count,
3414 SuccessCount: successCount,
3415 FailureCount: failureCount,
3416 Action: actionKindArchive,
3417 Mailbox: mailbox,
3418 Err: err,
3419 }
3420 }
3421}
3422
3423func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
3424 return func() tea.Msg {
3425 if m.service == nil {
3426 return tui.BatchEmailActionDoneMsg{
3427 Count: count,
3428 SuccessCount: 0,
3429 FailureCount: count,
3430 Action: actionKindMove,
3431 Err: fmt.Errorf("service not initialized"),
3432 }
3433 }
3434
3435 err := m.service.MoveEmails(accountID, uids, sourceFolder, destFolder)
3436
3437 successCount, failureCount := count, 0
3438 if err != nil {
3439 successCount, failureCount = 0, count
3440 }
3441
3442 return tui.BatchEmailActionDoneMsg{
3443 Count: count,
3444 SuccessCount: successCount,
3445 FailureCount: failureCount,
3446 Action: actionKindMove,
3447 Err: err,
3448 }
3449 }
3450}
3451
3452// sanitizeFilename prevents path traversal attacks on attachment downloads.
3453// Email attachment filenames come from untrusted email headers and could
3454// contain path separators or ".." sequences to escape the Downloads directory.
3455func sanitizeFilename(name string) string {
3456 // Normalize backslashes to forward slashes so filepath.Base works
3457 // correctly on all platforms (Linux doesn't treat \ as a separator)
3458 name = strings.ReplaceAll(name, "\\", "/")
3459 // Strip any path components, keep only the base filename
3460 name = filepath.Base(name)
3461 // Replace any remaining path separators (defensive)
3462 name = strings.ReplaceAll(name, "/", "_")
3463 name = strings.ReplaceAll(name, "..", "_")
3464 // Reject hidden files and empty names
3465 if name == "" || name == "." || strings.HasPrefix(name, ".") {
3466 name = "attachment"
3467 }
3468 // Sanitize filename: enforce length limit to prevent filesystem errors
3469 // with extremely long names from untrusted email headers.
3470 const maxFilenameLen = 255
3471 if len(name) > maxFilenameLen {
3472 ext := filepath.Ext(name)
3473 if len(ext) > maxFilenameLen {
3474 ext = truncateUTF8(ext, maxFilenameLen)
3475 }
3476 base := strings.TrimSuffix(name, ext)
3477 name = truncateUTF8(base, maxFilenameLen-len(ext)) + ext
3478 }
3479 return name
3480}
3481
3482func truncateUTF8(s string, maxBytes int) string {
3483 if maxBytes <= 0 {
3484 return ""
3485 }
3486 if len(s) <= maxBytes {
3487 return s
3488 }
3489 s = s[:maxBytes]
3490 for !utf8.ValidString(s) {
3491 _, size := utf8.DecodeLastRuneInString(s)
3492 s = s[:len(s)-size]
3493 }
3494 return s
3495}
3496
3497func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
3498 return func() tea.Msg {
3499 // Download and decode the attachment using encoding provided in msg.Encoding.
3500 var data []byte
3501 var err error
3502 switch msg.Mailbox {
3503 case tui.MailboxSent:
3504 data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding)
3505 case tui.MailboxTrash:
3506 data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding)
3507 case tui.MailboxArchive:
3508 data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding)
3509 case tui.MailboxInbox:
3510 data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding)
3511 }
3512
3513 if err != nil {
3514 return tui.AttachmentDownloadedMsg{Err: err}
3515 }
3516
3517 homeDir, err := os.UserHomeDir()
3518 if err != nil {
3519 return tui.AttachmentDownloadedMsg{Err: err}
3520 }
3521 downloadsPath := filepath.Join(homeDir, "Downloads")
3522 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
3523 if mkErr := os.MkdirAll(downloadsPath, 0750); mkErr != nil {
3524 return tui.AttachmentDownloadedMsg{Err: mkErr}
3525 }
3526 }
3527
3528 // Save the attachment using an exclusive create so we never overwrite an existing file.
3529 // If the filename already exists, append \" (n)\" before the extension.
3530 origName := sanitizeFilename(msg.Filename)
3531 ext := filepath.Ext(origName)
3532 base := strings.TrimSuffix(origName, ext)
3533 candidate := origName
3534 i := 1
3535 var filePath string
3536
3537 for {
3538 filePath = filepath.Join(downloadsPath, candidate)
3539
3540 // Try to create file exclusively. If it already exists, os.OpenFile will return an error
3541 // that satisfies os.IsExist(err), so we can increment the candidate.
3542 f, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) //nolint:gosec
3543 if err != nil {
3544 if os.IsExist(err) {
3545 // file exists, try next candidate
3546 candidate = fmt.Sprintf("%s (%d)%s", base, i, ext)
3547 i++
3548 continue
3549 }
3550 // Some other error while attempting to create file
3551 log.Printf("error creating file %s: %v", filePath, err)
3552 return tui.AttachmentDownloadedMsg{Err: err}
3553 }
3554
3555 // Successfully created the file descriptor; write and close.
3556 if _, writeErr := f.Write(data); writeErr != nil {
3557 _ = f.Close()
3558 log.Printf("error writing to file %s: %v", filePath, writeErr)
3559 return tui.AttachmentDownloadedMsg{Err: writeErr}
3560 }
3561 if closeErr := f.Close(); closeErr != nil {
3562 log.Printf("warning: error closing file %s: %v", filePath, closeErr)
3563 }
3564
3565 // file saved successfully
3566 break
3567 }
3568
3569 log.Printf("attachment saved to %s", filePath)
3570
3571 // Try to open the file using a platform-specific opener asynchronously and log the outcome.
3572 go func(p string) {
3573 var cmd *exec.Cmd
3574 switch runtime.GOOS {
3575 case goosDarwin:
3576 cmd = exec.Command("open", p) //nolint:noctx
3577 case goosLinux:
3578 cmd = exec.Command("xdg-open", p) //nolint:noctx
3579 case goosWindows:
3580 // 'start' is a cmd builtin; provide an empty title argument to avoid interpreting the path as the title.
3581 cmd = exec.Command("cmd", "/c", "start", "", p) //nolint:noctx
3582 default:
3583 // Unsupported OS: nothing to do.
3584 return
3585 }
3586 if err := cmd.Start(); err != nil {
3587 log.Printf("failed to open file %s: %v", p, err)
3588 }
3589 }(filePath)
3590
3591 return tui.AttachmentDownloadedMsg{Path: filePath, Err: nil}
3592 }
3593}
3594
3595/*
3596detectInstalledVersion returns a best-effort installed version string.
3597Priority:
3598 1. If the build-in `version` variable is set to something other than "dev", return it.
3599 2. If Homebrew is present and reports a version for `matcha`, return that.
3600 3. If snap is present and lists `matcha`, return that.
3601 4. Fallback to the build `version` (likely "dev").
3602*/
3603func detectInstalledVersion() string {
3604 v := strings.TrimSpace(version)
3605 if v != "dev" && v != "" {
3606 return v
3607 }
3608
3609 // Try Homebrew (macOS)
3610 if runtime.GOOS == goosDarwin {
3611 if _, err := exec.LookPath("brew"); err == nil {
3612 // `brew list --versions matcha` prints: matcha 1.2.3
3613 if out, err := exec.Command("brew", "list", "--versions", "matcha").Output(); err == nil { //nolint:noctx
3614 parts := strings.Fields(string(out))
3615 if len(parts) >= 2 {
3616 return parts[1]
3617 }
3618 }
3619 }
3620 }
3621
3622 // Try WinGet (Windows)
3623 if runtime.GOOS == goosWindows {
3624 if _, err := exec.LookPath("winget"); err == nil {
3625 if out, err := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity").Output(); err == nil { //nolint:noctx
3626 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3627 for _, line := range lines {
3628 if strings.Contains(strings.ToLower(line), "floatpane.matcha") {
3629 fields := strings.Fields(line)
3630 for _, f := range fields {
3631 if len(f) > 0 && f[0] >= '0' && f[0] <= '9' && strings.Contains(f, ".") {
3632 return f
3633 }
3634 }
3635 }
3636 }
3637 }
3638 }
3639 }
3640
3641 // Try snap (Linux)
3642 if runtime.GOOS == goosLinux {
3643 if _, err := exec.LookPath("snap"); err == nil {
3644 if out, err := exec.Command("snap", "list", "matcha").Output(); err == nil { //nolint:noctx
3645 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3646 if len(lines) >= 2 {
3647 fields := strings.Fields(lines[1])
3648 if len(fields) >= 2 {
3649 return fields[1]
3650 }
3651 }
3652 }
3653 }
3654
3655 if _, err := exec.LookPath("flatpak"); err == nil {
3656 if out, err := exec.Command("flatpak", "info", "com.floatpane.matcha").Output(); err == nil { //nolint:noctx
3657 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
3658 for _, line := range lines {
3659 line = strings.TrimSpace(line)
3660 if strings.HasPrefix(line, "Version:") {
3661 fields := strings.Fields(line)
3662 if len(fields) >= 2 {
3663 return fields[1]
3664 }
3665 }
3666 }
3667 }
3668 }
3669 }
3670
3671 return v
3672}
3673
3674var (
3675 v0ReleaseRegex = regexp.MustCompile(`^0\.\d+\.\d+$`)
3676 v1RCReleaseRegex = regexp.MustCompile(`^1\.0\.0-rc\d+$`)
3677)
3678
3679/*
3680checkForUpdatesCmd queries GitHub for the latest release tag and returns a
3681tea.Msg (UpdateAvailableMsg) if the latest version differs from the current
3682installed version. This runs in the background when the TUI initializes.
3683*/
3684func checkForUpdatesCmd() tea.Cmd {
3685 return func() tea.Msg {
3686 // Non-fatal: if anything goes wrong we just don't show the update message.
3687 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3688 resp, err := httpClient.Get(api)
3689 if err != nil {
3690 return nil
3691 }
3692 defer resp.Body.Close() //nolint:errcheck
3693
3694 var rel githubRelease
3695 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3696 return nil
3697 }
3698
3699 latest := strings.TrimPrefix(rel.TagName, "v")
3700 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3701 if latest != "" && installed != "" && latest != installed {
3702 return UpdateAvailableMsg{Latest: latest, Current: installed}
3703 }
3704 return nil
3705 }
3706}
3707
3708// checkForV1RCCmd queries GitHub for the newest v1.0.0-rcN release and returns
3709// a V1RCAvailableMsg when the installed version is still pre-v1.
3710func checkForV1RCCmd() tea.Cmd {
3711 return func() tea.Msg {
3712 const api = "https://api.github.com/repos/floatpane/matcha/releases"
3713 resp, err := httpClient.Get(api)
3714 if err != nil {
3715 return nil
3716 }
3717 defer resp.Body.Close() //nolint:errcheck
3718
3719 var rels []githubRelease
3720 if err := json.NewDecoder(resp.Body).Decode(&rels); err != nil {
3721 return nil
3722 }
3723
3724 installed := strings.TrimPrefix(detectInstalledVersion(), "v")
3725 if installed == "" || !v0ReleaseRegex.MatchString(installed) {
3726 return nil
3727 }
3728
3729 for _, rel := range rels {
3730 latest := strings.TrimPrefix(rel.TagName, "v")
3731 if v1RCReleaseRegex.MatchString(latest) {
3732 return V1RCAvailableMsg{Latest: latest, Current: installed}
3733 }
3734 }
3735 return nil
3736 }
3737}
3738
3739// runUpdateCLI implements the CLI entrypoint for `matcha update`.
3740// It detects the likely installation method and attempts the appropriate
3741// update path (Homebrew, Snap, or GitHub release binary extract).
3742// runOAuthCLI handles the "matcha oauth" subcommand for OAuth2 management.
3743// Usage:
3744//
3745// matcha oauth auth <email> [--provider gmail|outlook] [--client-id ID --client-secret SECRET]
3746// matcha oauth token <email>
3747// matcha oauth revoke <email>
3748func runOAuthCLI(args []string) {
3749 if len(args) < 1 {
3750 fmt.Fprintln(os.Stderr, "Usage: matcha oauth <auth|token|revoke> <email> [flags]")
3751 fmt.Fprintln(os.Stderr, "")
3752 fmt.Fprintln(os.Stderr, "Commands:")
3753 fmt.Fprintln(os.Stderr, " auth <email> Authorize an email account via OAuth2 (opens browser)")
3754 fmt.Fprintln(os.Stderr, " token <email> Print a fresh access token (refreshes automatically)")
3755 fmt.Fprintln(os.Stderr, " revoke <email> Revoke and delete stored OAuth2 tokens")
3756 fmt.Fprintln(os.Stderr, "")
3757 fmt.Fprintln(os.Stderr, "Flags for auth:")
3758 fmt.Fprintln(os.Stderr, " --provider gmail|outlook OAuth2 provider (auto-detected from email)")
3759 fmt.Fprintln(os.Stderr, " --client-id ID OAuth2 client ID")
3760 fmt.Fprintln(os.Stderr, " --client-secret SECRET OAuth2 client secret")
3761 fmt.Fprintln(os.Stderr, "")
3762 fmt.Fprintln(os.Stderr, "Credentials are stored per provider in:")
3763 fmt.Fprintln(os.Stderr, " Gmail: ~/.config/matcha/oauth_client.json")
3764 fmt.Fprintln(os.Stderr, " Outlook: ~/.config/matcha/oauth_client_outlook.json")
3765 exit(1)
3766 }
3767
3768 // Find the Python script and pass through to it
3769 script, err := config.OAuthScriptPath()
3770 if err != nil {
3771 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3772 exit(1)
3773 }
3774
3775 cmdArgs := append([]string{script}, args...)
3776 cmd := exec.Command("python3", cmdArgs...) //nolint:gosec,noctx
3777 cmd.Stdin = os.Stdin
3778 cmd.Stdout = os.Stdout
3779 cmd.Stderr = os.Stderr
3780
3781 if err := cmd.Run(); err != nil {
3782 var exitErr *exec.ExitError
3783 if errors.As(err, &exitErr) {
3784 exit(exitErr.ExitCode())
3785 }
3786 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
3787 exit(1)
3788 }
3789}
3790
3791// stringSliceFlag implements flag.Value to allow repeated --attach flags.
3792type stringSliceFlag []string
3793
3794func (s *stringSliceFlag) String() string { return strings.Join(*s, ", ") }
3795func (s *stringSliceFlag) Set(val string) error {
3796 *s = append(*s, val)
3797 return nil
3798}
3799
3800// runSendCLI implements the CLI entrypoint for `matcha send`.
3801// It sends an email non-interactively using configured accounts.
3802func runSendCLI(args []string) {
3803 fs := flag.NewFlagSet("send", flag.ExitOnError)
3804
3805 to := fs.String("to", "", "Recipient(s), comma-separated (required)")
3806 cc := fs.String("cc", "", "CC recipient(s), comma-separated")
3807 bcc := fs.String("bcc", "", "BCC recipient(s), comma-separated")
3808 subject := fs.String("subject", "", "Email subject (required)")
3809 body := fs.String("body", "", `Email body (Markdown supported). Use "-" to read from stdin`)
3810 from := fs.String("from", "", "Sender account email (defaults to first configured account)")
3811 withSignature := fs.Bool("signature", true, "Append default signature")
3812 signSMIME := fs.Bool("sign-smime", false, "Sign with S/MIME")
3813 encryptSMIME := fs.Bool("encrypt-smime", false, "Encrypt with S/MIME")
3814 signPGP := fs.Bool("sign-pgp", false, "Sign with PGP")
3815
3816 var attachments stringSliceFlag
3817 fs.Var(&attachments, "attach", "Attachment file path (can be repeated)")
3818
3819 fs.Usage = func() {
3820 fmt.Fprintln(os.Stderr, "Usage: matcha send [flags]")
3821 fmt.Fprintln(os.Stderr, "")
3822 fmt.Fprintln(os.Stderr, "Send an email non-interactively using a configured account.")
3823 fmt.Fprintln(os.Stderr, "")
3824 fmt.Fprintln(os.Stderr, "Flags:")
3825 fs.PrintDefaults()
3826 fmt.Fprintln(os.Stderr, "")
3827 fmt.Fprintln(os.Stderr, "Examples:")
3828 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Hello" --body "Hi there"`)
3829 fmt.Fprintln(os.Stderr, ` echo "Body text" | matcha send --to user@example.com --subject "Hello" --body -`)
3830 fmt.Fprintln(os.Stderr, ` matcha send --to user@example.com --subject "Report" --body "See attached" --attach report.pdf`)
3831 }
3832
3833 if err := fs.Parse(args); err != nil {
3834 exit(1)
3835 }
3836
3837 if *to == "" || *subject == "" {
3838 fmt.Fprintln(os.Stderr, "Error: --to and --subject are required")
3839 fs.Usage()
3840 exit(1)
3841 }
3842
3843 // Read body from stdin if "-"
3844 emailBody := *body
3845 if emailBody == "-" {
3846 data, err := io.ReadAll(os.Stdin)
3847 if err != nil {
3848 fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
3849 exit(1)
3850 }
3851 emailBody = string(data)
3852 }
3853
3854 // Load config
3855 cfg, err := config.LoadConfig()
3856 if err != nil {
3857 fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
3858 exit(1)
3859 }
3860 if !cfg.HasAccounts() {
3861 fmt.Fprintln(os.Stderr, "Error: no accounts configured. Run matcha to set up an account first.")
3862 exit(1)
3863 }
3864
3865 // Resolve account
3866 var account *config.Account
3867 if *from != "" {
3868 account = cfg.GetAccountByEmail(*from)
3869 if account == nil {
3870 // Also try matching against FetchEmail
3871 for i := range cfg.Accounts {
3872 if strings.EqualFold(cfg.Accounts[i].FetchEmail, *from) {
3873 account = &cfg.Accounts[i]
3874 break
3875 }
3876 }
3877 }
3878 if account == nil {
3879 fmt.Fprintf(os.Stderr, "Error: no account found matching %q\n", *from)
3880 exit(1)
3881 }
3882 } else {
3883 account = cfg.GetFirstAccount()
3884 }
3885
3886 // Use account S/MIME/PGP defaults unless explicitly set
3887 if !isFlagSet(fs, "sign-smime") {
3888 *signSMIME = account.SMIMESignByDefault
3889 }
3890 if !isFlagSet(fs, "sign-pgp") {
3891 *signPGP = account.PGPSignByDefault
3892 }
3893
3894 // Append signature
3895 if *withSignature {
3896 if sig, err := config.LoadSignature(); err == nil && sig != "" {
3897 emailBody = emailBody + "\n\n" + sig
3898 }
3899 }
3900
3901 // Process inline images (same logic as TUI sendEmail)
3902 images := make(map[string][]byte)
3903 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
3904 matches := re.FindAllStringSubmatch(emailBody, -1)
3905 for _, match := range matches {
3906 imgPath := match[1]
3907 imgData, err := os.ReadFile(imgPath)
3908 if err != nil {
3909 log.Printf("Could not read image file %s: %v", imgPath, err)
3910 continue
3911 }
3912 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
3913 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
3914 emailBody = strings.Replace(emailBody, imgPath, "cid:"+cid, 1)
3915 }
3916
3917 htmlBody := markdownToHTML([]byte(emailBody))
3918
3919 // Process attachments
3920 attachMap := make(map[string][]byte)
3921 for _, attachPath := range attachments {
3922 fileData, err := os.ReadFile(attachPath)
3923 if err != nil {
3924 fmt.Fprintf(os.Stderr, "Error reading attachment %s: %v\n", attachPath, err)
3925 exit(1)
3926 }
3927 attachMap[filepath.Base(attachPath)] = fileData
3928 }
3929
3930 // Send
3931 recipients := splitEmails(*to)
3932 ccList := splitEmails(*cc)
3933 bccList := splitEmails(*bcc)
3934
3935 rawMsg, sendErr := sender.SendEmail(account, recipients, ccList, bccList, *subject, emailBody, string(htmlBody), images, attachMap, "", nil, *signSMIME, *encryptSMIME, *signPGP, false)
3936 if sendErr != nil {
3937 fmt.Fprintf(os.Stderr, "Error: %v\n", sendErr)
3938 exit(1)
3939 }
3940
3941 // Append to Sent folder via IMAP (Gmail auto-saves, so skip it)
3942 if account.ServiceProvider != "gmail" {
3943 if err := fetcher.AppendToSentMailbox(account, rawMsg); err != nil {
3944 log.Printf("Failed to append sent message to Sent folder: %v", err)
3945 }
3946 }
3947
3948 fmt.Println("Email sent successfully.")
3949}
3950
3951// isFlagSet returns true if the named flag was explicitly provided on the command line.
3952func isFlagSet(fs *flag.FlagSet, name string) bool {
3953 found := false
3954 fs.Visit(func(f *flag.Flag) {
3955 if f.Name == name {
3956 found = true
3957 }
3958 })
3959 return found
3960}
3961
3962func runUpdateCLI() (err error) {
3963 const api = "https://api.github.com/repos/floatpane/matcha/releases/latest"
3964 resp, err := httpClient.Get(api)
3965 if err != nil {
3966 return fmt.Errorf("could not query releases: %w", err)
3967 }
3968 defer resp.Body.Close() //nolint:errcheck
3969
3970 var rel githubRelease
3971 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
3972 return fmt.Errorf("could not parse release info: %w", err)
3973 }
3974
3975 latestTag := strings.TrimPrefix(rel.TagName, "v")
3976
3977 fmt.Printf("Current version: %s\n", version)
3978 fmt.Printf("Latest version: %s\n", latestTag)
3979
3980 // Quick check: if already up-to-date, exit
3981 cur := strings.TrimPrefix(version, "v")
3982 if latestTag == "" || cur == latestTag {
3983 fmt.Println("Already up to date.")
3984 return nil
3985 }
3986
3987 // Determine OS and try package managers in priority order
3988 osName := runtime.GOOS
3989
3990 switch osName {
3991 case goosDarwin: // macOS
3992 // Priority: Homebrew > Manual binary update
3993 if tryHomebrewUpgrade() {
3994 return nil
3995 }
3996 // Fall through to manual binary download
3997
3998 case goosLinux: // Linux
3999 // Priority: Snap > Flatpak > AUR (yay) > Nix > Manual binary update
4000 if trySnapRefresh() {
4001 return nil
4002 }
4003 if tryFlatpakUpdate() {
4004 return nil
4005 }
4006 if tryAURUpdate() {
4007 return nil
4008 }
4009 if tryNixUpdate() {
4010 return nil
4011 }
4012 // Fall through to manual binary download
4013
4014 case goosWindows: // Windows
4015 // Priority: WinGet > Scoop > Manual binary update
4016 if tryWinGetUpgrade() {
4017 return nil
4018 }
4019 if tryScoopUpdate() {
4020 return nil
4021 }
4022 // Fall through to manual binary download
4023 }
4024
4025 // If no package manager succeeded, fall back to manual binary download
4026 return runUpdateCLIManual(latestTag, rel)
4027}
4028
4029// tryHomebrewUpgrade attempts to upgrade via Homebrew
4030func tryHomebrewUpgrade() bool {
4031 if _, err := exec.LookPath("brew"); err != nil {
4032 return false
4033 }
4034
4035 fmt.Println("Detected Homebrew — updating taps and attempting to upgrade via brew.")
4036
4037 updateCmd := exec.Command("brew", "update") //nolint:noctx
4038 updateCmd.Stdout = os.Stdout
4039 updateCmd.Stderr = os.Stderr
4040 if err := updateCmd.Run(); err != nil {
4041 fmt.Printf("Homebrew update failed: %v\n", err)
4042 // continue to attempt upgrade even if update failed
4043 }
4044
4045 upgradeCmd := exec.Command("brew", "upgrade", "floatpane/matcha/matcha") //nolint:noctx
4046 upgradeCmd.Stdout = os.Stdout
4047 upgradeCmd.Stderr = os.Stderr
4048 if err := upgradeCmd.Run(); err == nil {
4049 fmt.Println("Successfully upgraded via Homebrew.")
4050 return true
4051 }
4052 fmt.Printf("Homebrew upgrade failed\n")
4053 return false
4054}
4055
4056// trySnapRefresh attempts to refresh via Snap
4057func trySnapRefresh() bool {
4058 if _, err := exec.LookPath("snap"); err != nil {
4059 return false
4060 }
4061
4062 // Check if matcha is installed as a snap
4063 cmdCheck := exec.Command("snap", "list", "matcha") //nolint:noctx
4064 if err := cmdCheck.Run(); err != nil {
4065 return false
4066 }
4067
4068 fmt.Println("Detected Snap package — attempting to refresh.")
4069 cmd := exec.Command("snap", "refresh", "matcha") //nolint:noctx
4070 cmd.Stdout = os.Stdout
4071 cmd.Stderr = os.Stderr
4072 if err := cmd.Run(); err == nil {
4073 fmt.Println("Successfully refreshed snap.")
4074 return true
4075 }
4076 fmt.Printf("Snap refresh failed\n")
4077 return false
4078}
4079
4080// tryFlatpakUpdate attempts to update via Flatpak
4081func tryFlatpakUpdate() bool {
4082 if _, err := exec.LookPath("flatpak"); err != nil {
4083 return false
4084 }
4085
4086 // Check if matcha is installed as a flatpak
4087 cmdCheck := exec.Command("flatpak", "info", "com.floatpane.matcha") //nolint:noctx
4088 if err := cmdCheck.Run(); err != nil {
4089 return false
4090 }
4091
4092 fmt.Println("Detected Flatpak package — attempting to update.")
4093 cmd := exec.Command("flatpak", "update", "-y", "com.floatpane.matcha") //nolint:noctx
4094 cmd.Stdout = os.Stdout
4095 cmd.Stderr = os.Stderr
4096 if err := cmd.Run(); err == nil {
4097 fmt.Println("Successfully updated flatpak.")
4098 return true
4099 }
4100 fmt.Printf("Flatpak update failed\n")
4101 return false
4102}
4103
4104// tryAURUpdate attempts to update via AUR (using yay)
4105func tryAURUpdate() bool {
4106 if _, err := exec.LookPath("yay"); err != nil {
4107 return false
4108 }
4109
4110 // Check if matcha-client-bin is installed
4111 cmdCheck := exec.Command("yay", "-Q", "matcha-client-bin") //nolint:noctx
4112 if err := cmdCheck.Run(); err != nil {
4113 return false
4114 }
4115
4116 fmt.Println("Detected AUR package (matcha-client-bin) — attempting to update via yay.")
4117 cmd := exec.Command("yay", "-Syu", "--noconfirm", "matcha-client-bin") //nolint:noctx
4118 cmd.Stdout = os.Stdout
4119 cmd.Stderr = os.Stderr
4120 if err := cmd.Run(); err == nil {
4121 fmt.Println("Successfully updated via AUR.")
4122 return true
4123 }
4124 fmt.Printf("AUR update failed\n")
4125 return false
4126}
4127
4128// tryNixUpdate attempts to update via Nix
4129func tryNixUpdate() bool {
4130 if _, err := exec.LookPath("nix"); err != nil {
4131 return false
4132 }
4133
4134 // Check if matcha is in the user's profile
4135 cmdCheck := exec.Command("nix", "profile", "list") //nolint:noctx
4136 output, err := cmdCheck.Output()
4137 if err != nil || !strings.Contains(string(output), "matcha") {
4138 return false
4139 }
4140
4141 fmt.Println("Detected Nix package — attempting to update via nix profile upgrade.")
4142 cmd := exec.Command("nix", "profile", "upgrade", "github:floatpane/matcha") //nolint:noctx
4143 cmd.Stdout = os.Stdout
4144 cmd.Stderr = os.Stderr
4145 if err := cmd.Run(); err == nil {
4146 fmt.Println("Successfully updated via Nix.")
4147 return true
4148 }
4149 fmt.Printf("Nix update failed\n")
4150 return false
4151}
4152
4153// tryWinGetUpgrade attempts to upgrade via WinGet
4154func tryWinGetUpgrade() bool {
4155 if _, err := exec.LookPath("winget"); err != nil {
4156 return false
4157 }
4158
4159 cmdCheck := exec.Command("winget", "list", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
4160 if err := cmdCheck.Run(); err != nil {
4161 return false
4162 }
4163
4164 fmt.Println("Detected WinGet package — attempting to upgrade.")
4165 cmd := exec.Command("winget", "upgrade", "--id", "floatpane.matcha", "--disable-interactivity") //nolint:noctx
4166 cmd.Stdout = os.Stdout
4167 cmd.Stderr = os.Stderr
4168 if err := cmd.Run(); err == nil {
4169 fmt.Println("Successfully upgraded via WinGet.")
4170 return true
4171 }
4172 fmt.Printf("WinGet upgrade failed\n")
4173 return false
4174}
4175
4176// tryScoopUpdate attempts to update via Scoop
4177func tryScoopUpdate() bool {
4178 if _, err := exec.LookPath("scoop"); err != nil {
4179 return false
4180 }
4181
4182 // Check if matcha is installed via scoop
4183 cmdCheck := exec.Command("scoop", "list", "matcha") //nolint:noctx
4184 if err := cmdCheck.Run(); err != nil {
4185 return false
4186 }
4187
4188 fmt.Println("Detected Scoop package — attempting to update.")
4189 cmd := exec.Command("scoop", "update", "matcha") //nolint:noctx
4190 cmd.Stdout = os.Stdout
4191 cmd.Stderr = os.Stderr
4192 if err := cmd.Run(); err == nil {
4193 fmt.Println("Successfully updated via Scoop.")
4194 return true
4195 }
4196 fmt.Printf("Scoop update failed\n")
4197 return false
4198}
4199
4200// runUpdateCLIManual handles manual binary download and replacement
4201func runUpdateCLIManual(latestTag string, rel githubRelease) error {
4202 osName := runtime.GOOS
4203 arch := runtime.GOARCH
4204
4205 assetName, assetURL, err := matchaCli.FindAsset(&rel, osName, arch)
4206 if err != nil {
4207 return err
4208 }
4209 return matchaCli.UpgradeBinaryFromAsset(assetURL, assetName, latestTag, "matcha update")
4210}
4211
4212func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
4213 seen := make(map[uint32]struct{})
4214 for _, e := range existing {
4215 seen[e.UID] = struct{}{}
4216 }
4217 var unique []fetcher.Email
4218 for _, e := range incoming {
4219 if _, ok := seen[e.UID]; !ok {
4220 unique = append(unique, e)
4221 }
4222 }
4223 return unique
4224}
4225
4226func parseGlobalFlags(args []string) ([]string, loglevel.Level, bool) {
4227 level := loglevel.LevelInfo
4228 showLogPanel := false
4229 if len(args) <= 1 {
4230 return args, level, showLogPanel
4231 }
4232
4233 filtered := make([]string, 0, len(args))
4234 filtered = append(filtered, args[0])
4235
4236 for i := 1; i < len(args); i++ {
4237 switch args[i] {
4238 case "--debug":
4239 level = loglevel.LevelDebug
4240 case "--verbose", "-V":
4241 if level < loglevel.LevelVerbose {
4242 level = loglevel.LevelVerbose
4243 }
4244 case "--logs":
4245 showLogPanel = true
4246 default:
4247 filtered = append(filtered, args[i:]...)
4248 return filtered, level, showLogPanel
4249 }
4250 }
4251
4252 return filtered, level, showLogPanel
4253}
4254
4255func exit(code int) {
4256 fetcher.CloseDebugFiles()
4257 os.Exit(code)
4258}
4259
4260func main() { //nolint:gocyclo
4261 // termimage sandbox worker: if this process was spawned as a decode
4262 // worker (TERMIMAGE_WORKER=1), apply OS restrictions, decode, exit.
4263 // Must run before any other initialization.
4264 termimage.MaybeRunWorker()
4265
4266 args, level, showLogPanel := parseGlobalFlags(os.Args)
4267 os.Args = args
4268 loglevel.Set(level)
4269
4270 // If invoked with version flag, print version and exit
4271 if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") {
4272 fmt.Printf("matcha version %s", version)
4273 if commit != "" {
4274 fmt.Printf(" (%s)", commit)
4275 }
4276 if date != "" {
4277 fmt.Printf(" built on %s", date)
4278 }
4279 fmt.Println()
4280 exit(0)
4281 }
4282
4283 // If invoked as CLI update command, run updater and exit.
4284 if len(os.Args) > 1 && os.Args[1] == "update" {
4285 if err := runUpdateCLI(); err != nil {
4286 fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
4287 exit(1)
4288 }
4289 exit(0)
4290 }
4291
4292 // Daemon CLI subcommand: matcha daemon <start|stop|status|run>
4293 if len(os.Args) > 1 && os.Args[1] == "daemon" {
4294 runDaemonCLI(os.Args[2:])
4295 exit(0)
4296 }
4297
4298 // OAuth2 CLI subcommand: matcha oauth <auth|token|revoke> <email> [flags]
4299 // "gmail" is kept as an alias for backwards compatibility.
4300 if len(os.Args) > 1 && (os.Args[1] == "oauth" || os.Args[1] == "gmail") {
4301 runOAuthCLI(os.Args[2:])
4302 exit(0)
4303 }
4304
4305 // Send email CLI subcommand: matcha send --to <email> --subject <subject> [flags]
4306 if len(os.Args) > 1 && os.Args[1] == "send" {
4307 runSendCLI(os.Args[2:])
4308 exit(0)
4309 }
4310
4311 // Install plugin CLI subcommand: matcha install <url_or_file>
4312 if len(os.Args) > 1 && os.Args[1] == "install" {
4313 if err := matchaCli.RunInstall(os.Args[2:]); err != nil {
4314 fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
4315 exit(1)
4316 }
4317 exit(0)
4318 }
4319
4320 // Config CLI subcommand: matcha config [plugin_name]
4321 if len(os.Args) > 1 && os.Args[1] == "config" {
4322 if err := matchaCli.RunConfig(os.Args[2:]); err != nil {
4323 fmt.Fprintf(os.Stderr, "config failed: %v\n", err)
4324 exit(1)
4325 }
4326 exit(0)
4327 }
4328
4329 // Contacts CLI subcommand: matcha contacts <export|sync> [flags]
4330 if len(os.Args) > 1 && os.Args[1] == "contacts" && len(os.Args) > 2 {
4331 switch os.Args[2] {
4332 case "export":
4333 if err := matchaCli.RunContactsExport(os.Args[3:]); err != nil {
4334 fmt.Fprintf(os.Stderr, "contacts export failed: %v\n", err)
4335 exit(1)
4336 }
4337 exit(0)
4338 case "sync":
4339 if err := matchaCli.RunContactsSync(os.Args[3:]); err != nil {
4340 fmt.Fprintf(os.Stderr, "contacts sync failed: %v\n", err)
4341 exit(1)
4342 }
4343 exit(0)
4344 }
4345 }
4346
4347 // Dict CLI subcommand: matcha dict <add|remove|list> [lang]
4348 if len(os.Args) > 1 && os.Args[1] == "dict" {
4349 if err := matchaCli.RunDict(os.Args[2:]); err != nil {
4350 fmt.Fprintf(os.Stderr, "dict: %v\n", err)
4351 os.Exit(1)
4352 }
4353 os.Exit(0)
4354 }
4355
4356 // setup-mailto CLI subcommand: matcha setup-mailto
4357 if len(os.Args) > 1 && os.Args[1] == "setup-mailto" {
4358 if err := matchaCli.SetupMailto(); err != nil {
4359 fmt.Fprintf(os.Stderr, "setup-mailto failed: %v\n", err)
4360 exit(1)
4361 }
4362 exit(0)
4363 }
4364
4365 // Upgrade-v1 CLI subcommand: matcha upgrade-v1
4366 if len(os.Args) > 1 && os.Args[1] == "upgrade-v1" {
4367 if err := matchaCli.RunUpgradeV1(os.Args[2:]); err != nil {
4368 fmt.Fprintf(os.Stderr, "upgrade-v1 failed: %v\n", err)
4369 exit(1)
4370 }
4371 exit(0)
4372 }
4373
4374 // Marketplace TUI subcommand: matcha marketplace
4375 if len(os.Args) > 1 && os.Args[1] == "marketplace" {
4376 mp := tui.NewMarketplace(true)
4377 p := tea.NewProgram(mp)
4378 if _, err := p.Run(); err != nil {
4379 fmt.Fprintf(os.Stderr, "marketplace failed: %v\n", err)
4380 exit(1)
4381 }
4382 exit(0)
4383 }
4384
4385 // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed
4386 if err := config.MigrateCacheFiles(); err != nil {
4387 log.Printf("warning: cache migration failed: %v", err)
4388 }
4389
4390 // Initialize i18n
4391 if err := i18n.Init("en"); err != nil {
4392 log.Printf("Failed to initialize i18n: %v", err)
4393 }
4394
4395 var mailtoURL *url.URL
4396 if len(os.Args) > 1 && strings.HasPrefix(strings.ToLower(os.Args[1]), "mailto:") {
4397 if u, err := url.Parse(os.Args[1]); err == nil {
4398 mailtoURL = u
4399 }
4400 }
4401
4402 var initialModel *mainModel
4403
4404 if config.IsSecureModeEnabled() {
4405 // Secure mode: show password prompt before loading config
4406 tui.RebuildStyles()
4407 initialModel = newInitialModel(nil, mailtoURL)
4408 initialModel.current = tui.NewPasswordPrompt()
4409 } else {
4410 cfg, err := config.LoadConfig()
4411 if err == nil {
4412 loglevel.Verbosef("matcha: loaded config with %d account(s)", len(cfg.GetAccountIDs()))
4413 if migrateErr := config.MigrateContactsCacheUsage(cfg.GetAccountIDs()); migrateErr != nil {
4414 log.Printf("warning: contacts migration failed: %v", migrateErr)
4415 }
4416 if cfg.Theme != "" {
4417 theme.SetTheme(cfg.Theme)
4418 }
4419 // Set language from config
4420 lang := i18n.DetectLanguage(cfg)
4421 if err := i18n.GetManager().SetLanguage(lang); err != nil {
4422 log.Printf("Failed to set language %s: %v", lang, err)
4423 }
4424 }
4425 tui.RebuildStyles()
4426
4427 // Ensure PGP keys directory exists
4428 _ = config.EnsurePGPDir()
4429
4430 if err != nil {
4431 initialModel = newInitialModel(nil, mailtoURL)
4432 } else {
4433 initialModel = newInitialModel(cfg, mailtoURL)
4434 }
4435 }
4436
4437 if showLogPanel {
4438 logger := logging.NewBuffer(logging.DefaultMaxEntries)
4439 log.SetOutput(logger)
4440 initialModel.showLogPanel = true
4441 initialModel.logCh = logger.Subscribe()
4442 initialModel.logPanel = tui.NewLogPanel(logger)
4443 }
4444
4445 // Initialize plugin system
4446 plugins := plugin.NewManager()
4447 plugins.LoadPlugins()
4448 if initialModel.config != nil {
4449 plugins.LoadSettingValues(initialModel.config.PluginSettings)
4450 }
4451 initialModel.plugins = plugins
4452 tui.BodyTransformer = func(body string, email fetcher.Email) string {
4453 folder := folderInbox
4454 if initialModel.folderInbox != nil {
4455 folder = initialModel.folderInbox.GetCurrentFolder()
4456 }
4457 t := plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folder)
4458 return plugins.CallBodyRenderHook(t, body, email.Body)
4459 }
4460 plugins.CallHook(plugin.HookStartup)
4461
4462 // Background sync macOS features
4463 if runtime.GOOS == goosDarwin {
4464 disableNotifications := false
4465 if initialModel.config != nil {
4466 disableNotifications = initialModel.config.DisableNotifications
4467 }
4468 if !disableNotifications {
4469 go func() {
4470 defer func() {
4471 if r := recover(); r != nil {
4472 log.Printf("panic in macOS sync goroutine: %v", r)
4473 }
4474 }()
4475 _ = config.SyncMacOSContacts()
4476 _ = theme.SyncWithMacOS()
4477 }()
4478 }
4479 }
4480
4481 p := tea.NewProgram(initialModel)
4482
4483 if _, err := p.Run(); err != nil {
4484 plugins.Close()
4485 fmt.Printf("Alas, there's been an error: %v", err)
4486 exit(1)
4487 }
4488
4489 plugins.CallHook(plugin.HookShutdown)
4490 plugins.Close()
4491 fetcher.CloseDebugFiles()
4492}
4493
4494func runDaemonCLI(args []string) {
4495 if len(args) == 0 {
4496 fmt.Println("Usage: matcha daemon <start|stop|status|run>")
4497 fmt.Println()
4498 fmt.Println("Commands:")
4499 fmt.Println(" start Start the daemon in the background")
4500 fmt.Println(" stop Stop the running daemon")
4501 fmt.Println(" status Show daemon status")
4502 fmt.Println(" run Run the daemon in the foreground")
4503 exit(1)
4504 }
4505
4506 switch args[0] {
4507 case "start":
4508 runDaemonStart()
4509 case "stop":
4510 runDaemonStop()
4511 case "status":
4512 runDaemonStatus()
4513 case "run":
4514 runDaemonRun()
4515 default:
4516 fmt.Fprintf(os.Stderr, "unknown daemon command: %s\n", args[0])
4517 exit(1)
4518 }
4519}
4520
4521func runDaemonStart() {
4522 pidPath := daemonrpc.PIDPath()
4523 if pid, running := matchaDaemon.IsRunning(pidPath); running {
4524 fmt.Printf("Daemon already running (PID %d)\n", pid)
4525 return
4526 }
4527
4528 // Fork ourselves with "daemon run".
4529 exe, err := os.Executable()
4530 if err != nil {
4531 fmt.Fprintf(os.Stderr, "cannot find executable: %v\n", err)
4532 exit(1)
4533 }
4534
4535 cmd := exec.Command(exe, "daemon", "run") //nolint:noctx
4536 cmd.Stdout = nil
4537 cmd.Stderr = nil
4538 cmd.Stdin = nil
4539
4540 // Detach from parent process.
4541 cmd.SysProcAttr = daemonclient.DaemonProcAttr()
4542
4543 if err := cmd.Start(); err != nil {
4544 fmt.Fprintf(os.Stderr, "failed to start daemon: %v\n", err)
4545 exit(1)
4546 }
4547
4548 fmt.Printf("Daemon started (PID %d)\n", cmd.Process.Pid)
4549}
4550
4551func runDaemonStop() {
4552 pidPath := daemonrpc.PIDPath()
4553 pid, running := matchaDaemon.IsRunning(pidPath)
4554 if !running {
4555 fmt.Println("Daemon is not running")
4556 return
4557 }
4558
4559 process, err := os.FindProcess(pid)
4560 if err != nil {
4561 fmt.Fprintf(os.Stderr, "cannot find process %d: %v\n", pid, err)
4562 exit(1)
4563 }
4564
4565 if err := process.Signal(os.Interrupt); err != nil {
4566 fmt.Fprintf(os.Stderr, "failed to stop daemon: %v\n", err)
4567 exit(1)
4568 }
4569
4570 fmt.Printf("Daemon stopped (PID %d)\n", pid)
4571}
4572
4573func runDaemonStatus() {
4574 // Try connecting to daemon for live status.
4575 client, err := daemonclient.Dial()
4576 if err != nil {
4577 pidPath := daemonrpc.PIDPath()
4578 if pid, running := matchaDaemon.IsRunning(pidPath); running {
4579 fmt.Printf("Daemon running (PID %d) but not responding\n", pid)
4580 } else {
4581 fmt.Println("Daemon is not running")
4582 }
4583 return
4584 }
4585 status, err := client.Status()
4586 client.Close() //nolint:errcheck,gosec
4587 if err != nil {
4588 fmt.Fprintf(os.Stderr, "failed to get status: %v\n", err)
4589 exit(1)
4590 }
4591
4592 fmt.Printf("Daemon running (PID %d)\n", status.PID)
4593 fmt.Printf("Uptime: %s\n", formatUptime(status.Uptime))
4594 fmt.Printf("Accounts: %d\n", len(status.Accounts))
4595 for _, acct := range status.Accounts {
4596 fmt.Printf(" - %s\n", acct)
4597 }
4598}
4599
4600func runDaemonRun() {
4601 cfg, err := config.LoadConfig()
4602 if err != nil {
4603 fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
4604 exit(1)
4605 }
4606
4607 d := matchaDaemon.New(cfg)
4608 if err := d.Run(); err != nil {
4609 fmt.Fprintf(os.Stderr, "daemon error: %v\n", err)
4610 exit(1)
4611 }
4612}
4613
4614func formatUptime(seconds int64) string {
4615 d := time.Duration(seconds) * time.Second
4616 if d < time.Minute {
4617 return fmt.Sprintf("%ds", int(d.Seconds()))
4618 }
4619 if d < time.Hour {
4620 return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
4621 }
4622 return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
4623}