Detailed changes
@@ -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.
@@ -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,
@@ -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))
@@ -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,
@@ -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
@@ -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) {
@@ -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)
@@ -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 {
@@ -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
@@ -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 {
@@ -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().