feat: visual mode (#495)

Drew Smirnoff created

Change summary

backend/backend.go   |   5 
backend/imap/imap.go |  12 ++
backend/jmap/jmap.go |  30 +++++
backend/pop3/pop3.go |  18 +++
docs/docs/usage.md   |  24 ++++
fetcher/fetcher.go   |  92 +++++++++++++++++
main.go              | 203 +++++++++++++++++++++++++++++++++++++++
tui/folder_inbox.go  |  69 +++++++++++--
tui/inbox.go         | 238 ++++++++++++++++++++++++++++++++++++++++++++-
tui/messages.go      |  29 +++++
tui/theme.go         |   1 
11 files changed, 699 insertions(+), 22 deletions(-)

Detailed changes

backend/backend.go 🔗

@@ -33,6 +33,11 @@ type EmailWriter interface {
 	DeleteEmail(ctx context.Context, folder string, uid uint32) error
 	ArchiveEmail(ctx context.Context, folder string, uid uint32) error
 	MoveEmail(ctx context.Context, uid uint32, srcFolder, dstFolder string) error
+
+	// Batch operations
+	DeleteEmails(ctx context.Context, folder string, uids []uint32) error
+	ArchiveEmails(ctx context.Context, folder string, uids []uint32) error
+	MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error
 }
 
 // EmailSender sends outgoing email.

backend/imap/imap.go 🔗

@@ -63,6 +63,18 @@ func (p *Provider) MoveEmail(_ context.Context, uid uint32, srcFolder, dstFolder
 	return fetcher.MoveEmailToFolder(p.account, uid, srcFolder, dstFolder)
 }
 
+func (p *Provider) DeleteEmails(_ context.Context, folder string, uids []uint32) error {
+	return fetcher.DeleteEmailsFromMailbox(p.account, folder, uids)
+}
+
+func (p *Provider) ArchiveEmails(_ context.Context, folder string, uids []uint32) error {
+	return fetcher.ArchiveEmailsFromMailbox(p.account, folder, uids)
+}
+
+func (p *Provider) MoveEmails(_ context.Context, uids []uint32, srcFolder, dstFolder string) error {
+	return fetcher.MoveEmailsToFolder(p.account, uids, srcFolder, dstFolder)
+}
+
 func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
 	return sender.SendEmail(
 		p.account, msg.To, msg.Cc, msg.Bcc,

backend/jmap/jmap.go 🔗

@@ -380,6 +380,36 @@ func (p *Provider) MoveEmail(_ context.Context, uid uint32, _, dstFolder string)
 	return err
 }
 
+func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
+	// JMAP can handle batch operations - loop through for now
+	for _, uid := range uids {
+		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (p *Provider) ArchiveEmails(ctx context.Context, folder string, uids []uint32) error {
+	// JMAP can handle batch operations - loop through for now
+	for _, uid := range uids {
+		if err := p.ArchiveEmail(ctx, folder, uid); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (p *Provider) MoveEmails(ctx context.Context, uids []uint32, srcFolder, dstFolder string) error {
+	// JMAP can handle batch operations - loop through for now
+	for _, uid := range uids {
+		if err := p.MoveEmail(ctx, uid, srcFolder, dstFolder); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
 	// Build the email as a draft first
 	toAddrs := make([]*mail.Address, len(msg.To))

backend/pop3/pop3.go 🔗

@@ -205,6 +205,24 @@ func (p *Provider) MoveEmail(_ context.Context, _ uint32, _, _ string) error {
 	return backend.ErrNotSupported
 }
 
+func (p *Provider) DeleteEmails(ctx context.Context, folder string, uids []uint32) error {
+	// POP3 doesn't support batch - loop through individual operations
+	for _, uid := range uids {
+		if err := p.DeleteEmail(ctx, folder, uid); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (p *Provider) ArchiveEmails(_ context.Context, _ string, _ []uint32) error {
+	return backend.ErrNotSupported
+}
+
+func (p *Provider) MoveEmails(_ context.Context, _ []uint32, _, _ string) error {
+	return backend.ErrNotSupported
+}
+
 func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) error {
 	return sender.SendEmail(
 		p.account, msg.To, msg.Cc, msg.Bcc,

docs/docs/usage.md 🔗

@@ -31,8 +31,32 @@ On first launch, Matcha will prompt you to configure an email account. You'll ne
 - `r` - Refresh inbox
 - `d` - Delete selected email
 - `a` - Archive selected email
+- `v` - Enter visual mode (multi-select)
 - `Esc` - Back to main menu
 
+#### Visual Mode (Multi-Select)
+
+Visual mode allows you to select multiple emails and perform batch operations, similar to Vim's visual mode:
+
+- `v` - Enter visual mode (selects current email)
+- `↑/↓` or `j/k` - Expand/contract selection
+- `d` - Delete all selected emails
+- `a` - Archive all selected emails
+- `m` - Move all selected emails to a folder
+- `v` or `Esc` - Exit visual mode
+
+**Visual indicators:**
+- `>` - Cursor position
+- `*` - Selected email (not cursor)
+- `>*` - Selected email with cursor
+- Title shows: "Inbox - VISUAL (N selected)"
+
+**Features:**
+- Efficient batch operations using single IMAP commands
+- Automatic prevention of cross-account selections
+- Works in both inbox and folder views
+- Visual mode disabled during search/filtering for safety
+
 ### Email View
 
 - `↑/↓` or `j/k` - Scroll email content

fetcher/fetcher.go 🔗

@@ -1140,6 +1140,98 @@ func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32
 	return c.UidMove(seqSet, archiveMailbox)
 }
 
+// Batch operations for multiple emails
+
+// DeleteEmailsFromMailbox deletes multiple emails from a mailbox (batch operation)
+func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
+	if len(uids) == 0 {
+		return nil
+	}
+
+	c, err := connect(account)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	if _, err := c.Select(mailbox, false); err != nil {
+		return err
+	}
+
+	seqSet := new(imap.SeqSet)
+	for _, uid := range uids {
+		seqSet.AddNum(uid)
+	}
+
+	item := imap.FormatFlagsOp(imap.AddFlags, true)
+	flags := []interface{}{imap.DeletedFlag}
+
+	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
+		return err
+	}
+
+	return c.Expunge(nil)
+}
+
+// ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation)
+func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
+	if len(uids) == 0 {
+		return nil
+	}
+
+	c, err := connect(account)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	var archiveMailbox string
+	switch account.ServiceProvider {
+	case "gmail":
+		archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
+		if err != nil {
+			archiveMailbox = "[Gmail]/All Mail"
+		}
+	default:
+		archiveMailbox = "Archive"
+	}
+
+	if _, err := c.Select(mailbox, false); err != nil {
+		return err
+	}
+
+	seqSet := new(imap.SeqSet)
+	for _, uid := range uids {
+		seqSet.AddNum(uid)
+	}
+
+	return c.UidMove(seqSet, archiveMailbox)
+}
+
+// MoveEmailsToFolder moves multiple emails to a different folder (batch operation)
+func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, destFolder string) error {
+	if len(uids) == 0 {
+		return nil
+	}
+
+	c, err := connect(account)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	if _, err := c.Select(sourceFolder, false); err != nil {
+		return err
+	}
+
+	seqSet := new(imap.SeqSet)
+	for _, uid := range uids {
+		seqSet.AddNum(uid)
+	}
+
+	return c.UidMove(seqSet, destFolder)
+}
+
 // Convenience wrappers defaulting to INBOX for existing call sites.
 
 func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {

main.go 🔗

@@ -4,6 +4,7 @@ import (
 	"archive/tar"
 	"archive/zip"
 	"compress/gzip"
+	"context"
 	"encoding/base64"
 	"encoding/json"
 	"flag"
@@ -1268,6 +1269,94 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
+	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 {
+			if m.folderInbox != nil {
+				m.current = m.folderInbox
+			}
+			return m, nil
+		}
+
+		folderName := "INBOX"
+		if m.folderInbox != nil {
+			folderName = m.folderInbox.GetCurrentFolder()
+		}
+
+		return m, tea.Batch(
+			m.current.Init(),
+			m.batchDeleteEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
+		)
+
+	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 {
+			if m.folderInbox != nil {
+				m.current = m.folderInbox
+			}
+			return m, nil
+		}
+
+		folderName := "INBOX"
+		if m.folderInbox != nil {
+			folderName = m.folderInbox.GetCurrentFolder()
+		}
+
+		return m, tea.Batch(
+			m.current.Init(),
+			m.batchArchiveEmailsCmd(account, msg.UIDs, msg.AccountID, folderName, msg.Mailbox, count),
+		)
+
+	case tui.BatchMoveEmailsMsg:
+		if m.config == nil {
+			return m, nil
+		}
+		account := m.config.GetAccountByID(msg.AccountID)
+		if account == nil {
+			return m, nil
+		}
+
+		count := len(msg.UIDs)
+		m.previousModel = m.current
+		m.current = tui.NewStatus(fmt.Sprintf("Moving %d emails...", count))
+
+		return m, tea.Batch(
+			m.current.Init(),
+			m.batchMoveEmailsCmd(account, msg.UIDs, msg.AccountID, msg.SourceFolder, msg.DestFolder, count),
+		)
+
+	case tui.BatchEmailActionDoneMsg:
+		if msg.Err != nil {
+			log.Printf("Batch %s failed: %v", msg.Action, msg.Err)
+			m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err))
+			return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
+				return tui.RestoreViewMsg{}
+			})
+		}
+
+		// 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{}
+		})
+
 	case tui.DownloadAttachmentMsg:
 		m.previousModel = m.current
 		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
@@ -2159,6 +2248,120 @@ func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string
 	}
 }
 
+func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+		defer cancel()
+
+		p := m.getProvider(account)
+		if p == nil {
+			return tui.BatchEmailActionDoneMsg{
+				Count:  count,
+				Action: "delete",
+				Err:    fmt.Errorf("provider not found"),
+			}
+		}
+
+		err := p.DeleteEmails(ctx, 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
+		if err != nil {
+			failureCount = count
+			successCount = 0
+		}
+
+		return tui.BatchEmailActionDoneMsg{
+			Count:        count,
+			SuccessCount: successCount,
+			FailureCount: failureCount,
+			Action:       "delete",
+			Mailbox:      mailbox,
+			Err:          err,
+		}
+	}
+}
+
+func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+		defer cancel()
+
+		p := m.getProvider(account)
+		if p == nil {
+			return tui.BatchEmailActionDoneMsg{
+				Count:  count,
+				Action: "archive",
+				Err:    fmt.Errorf("provider not found"),
+			}
+		}
+
+		err := p.ArchiveEmails(ctx, folderName, uids)
+
+		if err == nil && m.folderInbox != nil {
+			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
+		}
+
+		successCount := count
+		failureCount := 0
+		if err != nil {
+			failureCount = count
+			successCount = 0
+		}
+
+		return tui.BatchEmailActionDoneMsg{
+			Count:        count,
+			SuccessCount: successCount,
+			FailureCount: failureCount,
+			Action:       "archive",
+			Mailbox:      mailbox,
+			Err:          err,
+		}
+	}
+}
+
+func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd {
+	return func() tea.Msg {
+		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+		defer cancel()
+
+		p := m.getProvider(account)
+		if p == nil {
+			return tui.BatchEmailActionDoneMsg{
+				Count:  count,
+				Action: "move",
+				Err:    fmt.Errorf("provider not found"),
+			}
+		}
+
+		err := p.MoveEmails(ctx, uids, sourceFolder, destFolder)
+
+		if err == nil && m.folderInbox != nil {
+			m.folderInbox.GetInbox().RemoveEmails(uids, accountID)
+		}
+
+		successCount := count
+		failureCount := 0
+		if err != nil {
+			failureCount = count
+			successCount = 0
+		}
+
+		return tui.BatchEmailActionDoneMsg{
+			Count:        count,
+			SuccessCount: successCount,
+			FailureCount: failureCount,
+			Action:       "move",
+			Err:          err,
+		}
+	}
+}
+
 func moveEmailToFolderCmd(account *config.Account, uid uint32, accountID string, sourceFolder, destFolder string) tea.Cmd {
 	return func() tea.Msg {
 		err := fetcher.MoveEmailToFolder(account, uid, sourceFolder, destFolder)

tui/folder_inbox.go 🔗

@@ -72,7 +72,8 @@ type FolderInbox struct {
 	// Move-to-folder overlay state
 	movingEmail      bool
 	moveTargetIdx    int
-	moveUID          uint32
+	moveUID          uint32   // Legacy: single UID
+	moveUIDs         []uint32 // Batch: multiple UIDs
 	moveAccountID    string
 	moveSourceFolder string
 }
@@ -151,14 +152,31 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, m.switchFolder()
 		case "m":
 			// Start move-to-folder flow
-			selectedItem, ok := m.inbox.list.SelectedItem().(item)
-			if ok {
+			if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 {
+				// Batch move
 				m.movingEmail = true
 				m.moveTargetIdx = 0
-				m.moveUID = selectedItem.uid
-				m.moveAccountID = selectedItem.accountID
+				m.moveUIDs = make([]uint32, len(m.inbox.selectionOrder))
+				copy(m.moveUIDs, m.inbox.selectionOrder)
+				m.moveAccountID = ""
+				for _, acctID := range m.inbox.selectedUIDs {
+					m.moveAccountID = acctID
+					break
+				}
 				m.moveSourceFolder = m.currentFolder
 				return m, nil
+			} else {
+				// Single move
+				selectedItem, ok := m.inbox.list.SelectedItem().(item)
+				if ok {
+					m.movingEmail = true
+					m.moveTargetIdx = 0
+					m.moveUID = selectedItem.uid
+					m.moveUIDs = []uint32{selectedItem.uid}
+					m.moveAccountID = selectedItem.accountID
+					m.moveSourceFolder = m.currentFolder
+					return m, nil
+				}
 			}
 		}
 
@@ -272,12 +290,35 @@ func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if len(choices) > 0 && m.moveTargetIdx < len(choices) {
 				destFolder := choices[m.moveTargetIdx]
 				m.movingEmail = false
-				return m, func() tea.Msg {
-					return MoveEmailToFolderMsg{
-						UID:          m.moveUID,
-						AccountID:    m.moveAccountID,
-						SourceFolder: m.moveSourceFolder,
-						DestFolder:   destFolder,
+
+				if len(m.moveUIDs) > 1 {
+					// Batch move
+					uids := m.moveUIDs
+					m.moveUIDs = nil
+
+					// Exit visual mode in inbox
+					m.inbox.visualMode = false
+					m.inbox.selectedUIDs = make(map[uint32]string)
+					m.inbox.selectionOrder = []uint32{}
+					m.inbox.updateListTitle()
+
+					return m, func() tea.Msg {
+						return BatchMoveEmailsMsg{
+							UIDs:         uids,
+							AccountID:    m.moveAccountID,
+							SourceFolder: m.moveSourceFolder,
+							DestFolder:   destFolder,
+						}
+					}
+				} else {
+					// Single move
+					return m, func() tea.Msg {
+						return MoveEmailToFolderMsg{
+							UID:          m.moveUID,
+							AccountID:    m.moveAccountID,
+							SourceFolder: m.moveSourceFolder,
+							DestFolder:   destFolder,
+						}
 					}
 				}
 			}
@@ -386,7 +427,11 @@ func (m *FolderInbox) renderWithMoveOverlay(content string) string {
 	}
 
 	var b strings.Builder
-	b.WriteString(moveOverlayTitleStyle.Render("Move to folder:"))
+	title := "Move to folder:"
+	if len(m.moveUIDs) > 1 {
+		title = fmt.Sprintf("Move %d emails to folder:", len(m.moveUIDs))
+	}
+	b.WriteString(moveOverlayTitleStyle.Render(title))
 	b.WriteString("\n")
 
 	for i, folder := range choices {

tui/inbox.go 🔗

@@ -27,6 +27,7 @@ var (
 var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
 var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+var visualSelectedStyle lipgloss.Style
 
 type item struct {
 	title, desc   string
@@ -42,7 +43,9 @@ func (i item) Title() string       { return i.title }
 func (i item) Description() string { return i.desc }
 func (i item) FilterValue() string { return i.title + " " + i.desc }
 
-type itemDelegate struct{}
+type itemDelegate struct {
+	inbox *Inbox
+}
 
 func (d itemDelegate) Height() int                               { return 1 }
 func (d itemDelegate) Spacing() int                              { return 0 }
@@ -119,10 +122,36 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 		padding = 1
 	}
 
+	// Check if this item is in visual selection
+	inVisualSelection := false
+	if d.inbox != nil && d.inbox.visualMode {
+		_, inVisualSelection = d.inbox.selectedUIDs[i.uid]
+	}
+
 	fn := itemStyle.Render
-	if index == m.Index() {
+	if inVisualSelection && !isSelected {
+		// Item is in visual selection but not the cursor
+		fn = func(s ...string) string {
+			return visualSelectedStyle.Render("* " + s[0])
+		}
+		cursorWidth = 2 // "* " prefix
+		padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
+		if padding < 1 {
+			padding = 1
+		}
+	} else if isSelected {
+		// Cursor position (may also be in selection)
+		prefix := "> "
+		if inVisualSelection {
+			prefix = ">*"
+		}
 		fn = func(s ...string) string {
-			return selectedItemStyle.Render("> " + s[0])
+			return selectedItemStyle.Render(prefix + s[0])
+		}
+		cursorWidth = len(prefix)
+		padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
+		if padding < 1 {
+			padding = 1
 		}
 	}
 
@@ -219,6 +248,12 @@ type Inbox struct {
 	extraShortHelpKeys []key.Binding
 	pluginStatus       string // Persistent status text set by plugins
 	pluginKeyBindings  []PluginKeyBinding
+
+	// Visual mode state (Vim-style multi-select)
+	visualMode     bool              // Whether visual mode is active
+	visualAnchor   int               // Index where visual selection started
+	selectedUIDs   map[uint32]string // map[uid]accountID for selected emails
+	selectionOrder []uint32          // Ordered list of UIDs for display
 }
 
 func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
@@ -275,6 +310,9 @@ func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mail
 		currentAccountID: "",
 		emailCountByAcct: emailCountByAcct,
 		mailbox:          mailbox,
+		visualMode:       false,
+		selectedUIDs:     make(map[uint32]string),
+		selectionOrder:   []uint32{},
 	}
 
 	inbox.updateList()
@@ -330,7 +368,7 @@ func (m *Inbox) updateList() {
 		}
 	}
 
-	l := list.New(items, itemDelegate{}, 20, 14)
+	l := list.New(items, itemDelegate{inbox: m}, 20, 14)
 	l.Title = m.getTitle()
 	l.SetShowStatusBar(true)
 	l.SetFilteringEnabled(true)
@@ -340,6 +378,7 @@ func (m *Inbox) updateList() {
 	l.SetStatusBarItemName("email", "emails")
 	l.AdditionalShortHelpKeys = func() []key.Binding {
 		bindings := []key.Binding{
+			key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual mode")),
 			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
 			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
 			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
@@ -437,9 +476,55 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		if m.list.FilterState() == list.Filtering {
+			// Don't allow visual mode while filtering
+			if m.visualMode {
+				m.visualMode = false
+				m.selectedUIDs = make(map[uint32]string)
+				m.selectionOrder = []uint32{}
+				m.updateListTitle()
+			}
 			break
 		}
 		switch keypress := msg.String(); keypress {
+		case "v":
+			if !m.visualMode {
+				// Enter visual mode
+				m.visualMode = true
+				m.visualAnchor = m.list.Index()
+				selectedItem, ok := m.list.SelectedItem().(item)
+				if ok {
+					m.selectedUIDs = make(map[uint32]string)
+					m.selectionOrder = []uint32{}
+					m.selectedUIDs[selectedItem.uid] = selectedItem.accountID
+					m.selectionOrder = append(m.selectionOrder, selectedItem.uid)
+				}
+				m.updateListTitle()
+			} else {
+				// Exit visual mode
+				m.visualMode = false
+				m.selectedUIDs = make(map[uint32]string)
+				m.selectionOrder = []uint32{}
+				m.updateListTitle()
+			}
+			return m, nil
+		case "esc":
+			if m.visualMode {
+				// Exit visual mode on ESC
+				m.visualMode = false
+				m.selectedUIDs = make(map[uint32]string)
+				m.selectionOrder = []uint32{}
+				m.updateListTitle()
+				return m, nil
+			}
+		case "j", "down", "k", "up":
+			if m.visualMode {
+				// Let the list handle navigation first
+				var cmd tea.Cmd
+				m.list, cmd = m.list.Update(msg)
+				// Then update selection
+				m.updateVisualSelection()
+				return m, cmd
+			}
 		case "left", "h":
 			if len(m.tabs) > 1 {
 				m.activeTabIndex--
@@ -447,6 +532,10 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					m.activeTabIndex = len(m.tabs) - 1
 				}
 				m.currentAccountID = m.tabs[m.activeTabIndex].ID
+				// Exit visual mode when switching tabs
+				m.visualMode = false
+				m.selectedUIDs = make(map[uint32]string)
+				m.selectionOrder = []uint32{}
 				m.updateList()
 				return m, nil
 			}
@@ -457,21 +546,69 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					m.activeTabIndex = 0
 				}
 				m.currentAccountID = m.tabs[m.activeTabIndex].ID
+				// Exit visual mode when switching tabs
+				m.visualMode = false
+				m.selectedUIDs = make(map[uint32]string)
+				m.selectionOrder = []uint32{}
 				m.updateList()
 				return m, nil
 			}
 		case "d":
-			selectedItem, ok := m.list.SelectedItem().(item)
-			if ok {
+			if m.visualMode && len(m.selectedUIDs) > 0 {
+				// Batch delete
+				uids := make([]uint32, len(m.selectionOrder))
+				copy(uids, m.selectionOrder)
+				accountID := ""
+				for _, aid := range m.selectedUIDs {
+					accountID = aid // Get any account (all should be same in single-account selection)
+					break
+				}
+
+				// Exit visual mode
+				m.visualMode = false
+				m.selectedUIDs = make(map[uint32]string)
+				m.selectionOrder = []uint32{}
+				m.updateListTitle()
+
 				return m, func() tea.Msg {
-					return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
+					return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
+				}
+			} else {
+				// Single delete
+				selectedItem, ok := m.list.SelectedItem().(item)
+				if ok {
+					return m, func() tea.Msg {
+						return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
+					}
 				}
 			}
 		case "a":
-			selectedItem, ok := m.list.SelectedItem().(item)
-			if ok {
+			if m.visualMode && len(m.selectedUIDs) > 0 {
+				// Batch archive
+				uids := make([]uint32, len(m.selectionOrder))
+				copy(uids, m.selectionOrder)
+				accountID := ""
+				for _, aid := range m.selectedUIDs {
+					accountID = aid
+					break
+				}
+
+				// Exit visual mode
+				m.visualMode = false
+				m.selectedUIDs = make(map[uint32]string)
+				m.selectionOrder = []uint32{}
+				m.updateListTitle()
+
 				return m, func() tea.Msg {
-					return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
+					return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
+				}
+			} else {
+				// Single archive
+				selectedItem, ok := m.list.SelectedItem().(item)
+				if ok {
+					return m, func() tea.Msg {
+						return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
+					}
 				}
 			}
 		case "r":
@@ -739,6 +876,87 @@ func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
 	m.updateList()
 }
 
+// updateVisualSelection updates the selected UIDs based on anchor and current index
+func (m *Inbox) updateVisualSelection() {
+	if !m.visualMode {
+		return
+	}
+
+	currentIdx := m.list.Index()
+	start := m.visualAnchor
+	end := currentIdx
+
+	if start > end {
+		start, end = end, start
+	}
+
+	// Clear and rebuild selection
+	m.selectedUIDs = make(map[uint32]string)
+	m.selectionOrder = []uint32{}
+
+	items := m.list.Items()
+	firstAccountID := ""
+	for i := start; i <= end && i < len(items); i++ {
+		if itm, ok := items[i].(item); ok {
+			// Ensure all selected emails are from the same account (prevent cross-account batch ops)
+			if firstAccountID == "" {
+				firstAccountID = itm.accountID
+			}
+			if itm.accountID != firstAccountID {
+				// Don't add emails from different accounts
+				continue
+			}
+
+			if _, exists := m.selectedUIDs[itm.uid]; !exists {
+				m.selectedUIDs[itm.uid] = itm.accountID
+				m.selectionOrder = append(m.selectionOrder, itm.uid)
+			}
+		}
+	}
+
+	m.updateListTitle()
+}
+
+// updateListTitle updates the title to show selection count when in visual mode
+func (m *Inbox) updateListTitle() {
+	if m.visualMode && len(m.selectedUIDs) > 0 {
+		baseTitle := m.getBaseTitle()
+		m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
+	} else {
+		m.list.Title = m.getTitle()
+	}
+}
+
+// RemoveEmails removes multiple emails by UID and account ID (batch operation)
+func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
+	uidSet := make(map[uint32]bool)
+	for _, uid := range uids {
+		uidSet[uid] = true
+	}
+
+	// Remove from account-specific list
+	if emails, ok := m.emailsByAccount[accountID]; ok {
+		var filtered []fetcher.Email
+		for _, e := range emails {
+			if !uidSet[e.UID] {
+				filtered = append(filtered, e)
+			}
+		}
+		m.emailsByAccount[accountID] = filtered
+	}
+
+	// Remove from all emails list
+	var filteredAll []fetcher.Email
+	for _, e := range m.allEmails {
+		if !(uidSet[e.UID] && e.AccountID == accountID) {
+			filteredAll = append(filteredAll, e)
+		}
+	}
+	m.allEmails = filteredAll
+
+	m.updateList()
+}
+
 // RemoveEmail removes an email by UID and account ID
 func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
 	// Remove from account-specific list

tui/messages.go 🔗

@@ -153,6 +153,35 @@ type EmailActionDoneMsg struct {
 	Err       error
 }
 
+// Batch operation messages
+type BatchDeleteEmailsMsg struct {
+	UIDs      []uint32
+	AccountID string
+	Mailbox   MailboxKind
+}
+
+type BatchArchiveEmailsMsg struct {
+	UIDs      []uint32
+	AccountID string
+	Mailbox   MailboxKind
+}
+
+type BatchMoveEmailsMsg struct {
+	UIDs         []uint32
+	AccountID    string
+	SourceFolder string
+	DestFolder   string
+}
+
+type BatchEmailActionDoneMsg struct {
+	Count        int
+	SuccessCount int
+	FailureCount int
+	Action       string // "delete", "archive", or "move"
+	Mailbox      MailboxKind
+	Err          error
+}
+
 type GoToChoiceMenuMsg struct{}
 
 type DownloadAttachmentMsg struct {

tui/theme.go 🔗

@@ -75,6 +75,7 @@ func RebuildStyles() {
 	dateStyle = lipgloss.NewStyle().Foreground(t.MutedText)
 	unreadEmailStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
 	readEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary)
+	visualSelectedStyle = lipgloss.NewStyle().Background(t.AccentDark).Foreground(t.AccentText)
 
 	// folder_inbox.go
 	sidebarStyle = lipgloss.NewStyle().