fix: move email actions to daemon (#1378)

Mohamed Mahmoud created

## 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

Change summary

main.go | 296 +++++++++++++++++++++++++++++++++++-----------------------
1 file changed, 176 insertions(+), 120 deletions(-)

Detailed changes

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,