From 54017d8f893f7baffabb23e615ab2f4738cfba2b Mon Sep 17 00:00:00 2001 From: Mohamed Mahmoud Date: Sun, 31 May 2026 15:55:17 +0300 Subject: [PATCH] fix: move email actions to daemon (#1378) ## What? Delegates email operations (delete, archive, move) to the daemon instead of blocking the UI with loading spinners. ## Why? Email operations can take time, especially for batch actions or slower IMAP connections. By delegating them to the daemon, the UI remains responsive, operations continue in the background, and users can safely close the TUI without interrupting the work. Closes #1255 --- main.go | 296 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 176 insertions(+), 120 deletions(-) diff --git a/main.go b/main.go index 3ecafcec34f1b8675c57d35c2bee12316811394d..b9494921859eaaae223f379eb3606298df0b50c7 100644 --- a/main.go +++ b/main.go @@ -832,9 +832,27 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo if account == nil { return m, nil } - m.previousModel = m.current - m.current = tui.NewStatus("Moving email...") - return m, tea.Batch(m.current.Init(), moveEmailToFolderCmd(account, msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder)) + + folderName := folderInbox + if m.folderInbox != nil { + folderName = m.folderInbox.GetCurrentFolder() + m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID) + } + + m.removeEmailFromStores(msg.UID, msg.AccountID) + + if emails, ok := m.folderEmails[folderName]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if e.UID != msg.UID || e.AccountID != msg.AccountID { + filtered = append(filtered, e) + } + } + m.folderEmails[folderName] = filtered + go saveFolderEmailsToCache(folderName, filtered) + } + + return m, m.moveEmailToFolderCmd(msg.UID, msg.AccountID, msg.SourceFolder, msg.DestFolder) case tui.UpdatePreviewMsg: // Trigger preview body fetch @@ -921,11 +939,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo return tui.RestoreViewMsg{} }) } - // Remove email from current view - if m.folderInbox != nil { - m.folderInbox.RemoveEmail(msg.UID, msg.AccountID) - m.current = m.folderInbox - } return m, nil case tui.CachedEmailsLoadedMsg: @@ -1752,8 +1765,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo case tui.DeleteEmailMsg: tui.ClearKittyGraphics() - m.previousModel = m.current - m.current = tui.NewStatus("Deleting email...") account := m.config.GetAccountByID(msg.AccountID) if account == nil { @@ -1765,14 +1776,28 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo folderName := folderInbox if m.folderInbox != nil { + m.current = m.folderInbox folderName = m.folderInbox.GetCurrentFolder() + m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID) + } + + m.removeEmailFromStores(msg.UID, msg.AccountID) + + if emails, ok := m.folderEmails[folderName]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if e.UID != msg.UID || e.AccountID != msg.AccountID { + filtered = append(filtered, e) + } + } + m.folderEmails[folderName] = filtered + go saveFolderEmailsToCache(folderName, filtered) } - return m, tea.Batch(m.current.Init(), deleteFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox)) + + return m, m.deleteFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox) case tui.ArchiveEmailMsg: tui.ClearKittyGraphics() - m.previousModel = m.current - m.current = tui.NewStatus("Archiving email...") account := m.config.GetAccountByID(msg.AccountID) if account == nil { @@ -1784,9 +1809,25 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo folderName := folderInbox if m.folderInbox != nil { + m.current = m.folderInbox folderName = m.folderInbox.GetCurrentFolder() + m.folderInbox.GetInbox().RemoveEmail(msg.UID, msg.AccountID) } - return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox)) + + m.removeEmailFromStores(msg.UID, msg.AccountID) + + if emails, ok := m.folderEmails[folderName]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if e.UID != msg.UID || e.AccountID != msg.AccountID { + filtered = append(filtered, e) + } + } + m.folderEmails[folderName] = filtered + go saveFolderEmailsToCache(folderName, filtered) + } + + return m, m.archiveFolderEmailCmd(msg.UID, msg.AccountID, folderName, msg.Mailbox) case tui.EmailMarkedReadMsg: if msg.Err != nil { @@ -1814,24 +1855,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo }) } - // Remove email from stores - m.removeEmailFromStores(msg.UID, msg.AccountID) - - if m.folderInbox != nil { - m.folderInbox.RemoveEmail(msg.UID, msg.AccountID) - m.current = m.folderInbox - m.current, _ = m.current.Update(m.currentWindowSize()) - return m, m.current.Init() - } - m.current = tui.NewChoice() - m.current, _ = m.current.Update(m.currentWindowSize()) - return m, m.current.Init() + return m, nil case tui.BatchDeleteEmailsMsg: tui.ClearKittyGraphics() - m.previousModel = m.current - count := len(msg.UIDs) - m.current = tui.NewStatus(fmt.Sprintf("Deleting %d emails...", count)) account := m.config.GetAccountByID(msg.AccountID) if account == nil { @@ -1844,18 +1871,28 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() + m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID) } - return m, tea.Batch( - m.current.Init(), - m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count), - ) + for _, uid := range msg.UIDs { + m.removeEmailFromStores(uid, msg.AccountID) + } + + if emails, ok := m.folderEmails[folderName]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) { + filtered = append(filtered, e) + } + } + m.folderEmails[folderName] = filtered + go saveFolderEmailsToCache(folderName, filtered) + } + + return m, m.batchDeleteEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs)) case tui.BatchArchiveEmailsMsg: tui.ClearKittyGraphics() - m.previousModel = m.current - count := len(msg.UIDs) - m.current = tui.NewStatus(fmt.Sprintf("Archiving %d emails...", count)) account := m.config.GetAccountByID(msg.AccountID) if account == nil { @@ -1868,12 +1905,25 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo folderName := folderInbox if m.folderInbox != nil { folderName = m.folderInbox.GetCurrentFolder() + m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID) } - return m, tea.Batch( - m.current.Init(), - m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count), - ) + for _, uid := range msg.UIDs { + m.removeEmailFromStores(uid, msg.AccountID) + } + + if emails, ok := m.folderEmails[folderName]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) { + filtered = append(filtered, e) + } + } + m.folderEmails[folderName] = filtered + go saveFolderEmailsToCache(folderName, filtered) + } + + return m, m.batchArchiveEmailsCmd(msg.UIDs, msg.AccountID, folderName, msg.Mailbox, len(msg.UIDs)) case tui.BatchMoveEmailsMsg: if m.config == nil { @@ -1884,14 +1934,28 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo return m, nil } - count := len(msg.UIDs) - m.previousModel = m.current - m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count)) + folderName := folderInbox + if m.folderInbox != nil { + folderName = m.folderInbox.GetCurrentFolder() + m.folderInbox.GetInbox().RemoveEmails(msg.UIDs, msg.AccountID) + } - return m, tea.Batch( - m.current.Init(), - m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count), - ) + for _, uid := range msg.UIDs { + m.removeEmailFromStores(uid, msg.AccountID) + } + + if emails, ok := m.folderEmails[folderName]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if e.AccountID != msg.AccountID || !slices.Contains(msg.UIDs, e.UID) { + filtered = append(filtered, e) + } + } + m.folderEmails[folderName] = filtered + go saveFolderEmailsToCache(folderName, filtered) + } + + return m, m.batchMoveEmailsCmd(msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, len(msg.UIDs)) case tui.BatchEmailActionDoneMsg: if msg.Err != nil { @@ -1902,18 +1966,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo }) } - // Success - show brief confirmation - successMsg := fmt.Sprintf("%d emails %sd successfully", msg.SuccessCount, msg.Action) - if msg.FailureCount > 0 { - successMsg = fmt.Sprintf("%d of %d emails %sd (%d failed)", - msg.SuccessCount, msg.Count, msg.Action, msg.FailureCount) - } - - m.current = tui.NewStatus(successMsg) - - return m, tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg { - return tui.RestoreViewMsg{} - }) + return m, nil case tui.DownloadAttachmentMsg: m.previousModel = m.current @@ -2931,46 +2984,54 @@ func markEmailAsUnreadCmd(account *config.Account, uid uint32, accountID string, } } -func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { +func (m *mainModel) deleteFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { - err := fetcher.DeleteFolderEmail(account, folderName, uid) + if m.service == nil { + return tui.EmailActionDoneMsg{ + UID: uid, + AccountID: accountID, + Mailbox: mailbox, + Err: fmt.Errorf("service not initialized"), + } + } + err := m.service.DeleteEmails(accountID, folderName, []uint32{uid}) return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} } } -func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { +func (m *mainModel) archiveFolderEmailCmd(uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { - err := fetcher.ArchiveFolderEmail(account, folderName, uid) + if m.service == nil { + return tui.EmailActionDoneMsg{ + UID: uid, + AccountID: accountID, + Mailbox: mailbox, + Err: fmt.Errorf("service not initialized"), + } + } + err := m.service.ArchiveEmails(accountID, folderName, []uint32{uid}) return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} } } -func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { +func (m *mainModel) batchDeleteEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout) - defer cancel() - - p := m.getProvider(account) - if p == nil { + if m.service == nil { return tui.BatchEmailActionDoneMsg{ - Count: count, - Action: "delete", - Err: fmt.Errorf("provider not found"), + Count: count, + SuccessCount: 0, + FailureCount: count, + Action: "delete", + Mailbox: mailbox, + Err: fmt.Errorf("service not initialized"), } } - err := p.DeleteEmails(ctx, folderName, uids) + err := m.service.DeleteEmails(accountID, folderName, uids) - // Remove emails from local state on success - if err == nil && m.folderInbox != nil { - m.folderInbox.GetInbox().RemoveEmails(uids, accountID) - } - - successCount := count - failureCount := 0 + successCount, failureCount := count, 0 if err != nil { - failureCount = count - successCount = 0 + successCount, failureCount = 0, count } return tui.BatchEmailActionDoneMsg{ @@ -2984,31 +3045,24 @@ func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, } } -func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { +func (m *mainModel) batchArchiveEmailsCmd(uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout) - defer cancel() - - p := m.getProvider(account) - if p == nil { + if m.service == nil { return tui.BatchEmailActionDoneMsg{ - Count: count, - Action: "archive", - Err: fmt.Errorf("provider not found"), + Count: count, + SuccessCount: 0, + FailureCount: count, + Action: "archive", + Mailbox: mailbox, + Err: fmt.Errorf("service not initialized"), } } - err := p.ArchiveEmails(ctx, folderName, uids) - - if err == nil && m.folderInbox != nil { - m.folderInbox.GetInbox().RemoveEmails(uids, accountID) - } + err := m.service.ArchiveEmails(accountID, folderName, uids) - successCount := count - failureCount := 0 + successCount, failureCount := count, 0 if err != nil { - failureCount = count - successCount = 0 + successCount, failureCount = 0, count } return tui.BatchEmailActionDoneMsg{ @@ -3022,31 +3076,23 @@ func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32 } } -func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd { +func (m *mainModel) batchMoveEmailsCmd(uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout) - defer cancel() - - p := m.getProvider(account) - if p == nil { + if m.service == nil { return tui.BatchEmailActionDoneMsg{ - Count: count, - Action: "move", - Err: fmt.Errorf("provider not found"), + Count: count, + SuccessCount: 0, + FailureCount: count, + Action: "move", + Err: fmt.Errorf("service not initialized"), } } - err := p.MoveEmails(ctx, uids, sourceFolder, destFolder) + err := m.service.MoveEmails(accountID, uids, sourceFolder, destFolder) - if err == nil && m.folderInbox != nil { - m.folderInbox.GetInbox().RemoveEmails(uids, accountID) - } - - successCount := count - failureCount := 0 + successCount, failureCount := count, 0 if err != nil { - failureCount = count - successCount = 0 + successCount, failureCount = 0, count } return tui.BatchEmailActionDoneMsg{ @@ -3059,9 +3105,19 @@ func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, a } } -func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd { +func (m *mainModel) moveEmailToFolderCmd(uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd { return func() tea.Msg { - err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder) + if m.service == nil { + return tui.EmailMovedMsg{ + UID: uid, + AccountID: accountID, + SourceFolder: sourceFolder, + DestFolder: destFolder, + Err: fmt.Errorf("service not initialized"), + } + } + + err := m.service.MoveEmails(accountID, []uint32{uid}, sourceFolder, destFolder) return tui.EmailMovedMsg{ UID: uid, AccountID: accountID,