inbox.go

   1package tui
   2
   3import (
   4	"fmt"
   5	"io"
   6	"strings"
   7	"time"
   8
   9	"charm.land/bubbles/v2/key"
  10	"charm.land/bubbles/v2/list"
  11	tea "charm.land/bubbletea/v2"
  12	"charm.land/lipgloss/v2"
  13	"github.com/floatpane/matcha/config"
  14	"github.com/floatpane/matcha/fetcher"
  15	"github.com/floatpane/matcha/theme"
  16)
  17
  18var (
  19	// In bubbles v2, list.DefaultStyles() takes a boolean for hasDarkBackground
  20	paginationStyle = list.DefaultStyles(true).PaginationStyle.PaddingLeft(4)
  21	inboxHelpStyle  = list.DefaultStyles(true).HelpStyle.PaddingLeft(4).PaddingBottom(1)
  22	tabStyle        = lipgloss.NewStyle().Padding(0, 2)
  23	activeTabStyle  = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
  24	tabBarStyle     = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
  25)
  26
  27var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
  28var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
  29var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
  30var visualSelectedStyle lipgloss.Style
  31var selectedDateStyle lipgloss.Style
  32
  33type item struct {
  34	title, desc   string
  35	originalIndex int
  36	uid           uint32
  37	accountID     string
  38	accountEmail  string
  39	date          time.Time
  40	isRead        bool
  41}
  42
  43func (i item) Title() string       { return i.title }
  44func (i item) Description() string { return i.desc }
  45func (i item) FilterValue() string { return i.title + " " + i.desc }
  46
  47type itemDelegate struct {
  48	inbox *Inbox
  49}
  50
  51func (d itemDelegate) Height() int                               { return 1 }
  52func (d itemDelegate) Spacing() int                              { return 0 }
  53func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
  54func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
  55	i, ok := listItem.(item)
  56	if !ok {
  57		return
  58	}
  59
  60	prefix := fmt.Sprintf("%d. ", index+1)
  61	sender := parseSenderName(i.desc)
  62	statusStyle := unreadEmailStyle
  63	statusIcon := "\uf0e0"
  64	if i.isRead {
  65		statusStyle = readEmailStyle
  66		statusIcon = "\uf2b6"
  67	}
  68	styledIcon := statusStyle.Render(statusIcon)
  69	styledSender := statusStyle.Render(sender)
  70	separator := " · "
  71
  72	// For "ALL" view, show account indicator instead of number
  73	if i.accountEmail != "" {
  74		prefix = fmt.Sprintf("%d. [%s] ", index+1, truncateEmail(i.accountEmail))
  75	}
  76
  77	// Format and right-align date
  78	layout := ""
  79	if d.inbox != nil {
  80		layout = d.inbox.dateFormat
  81	}
  82	dateStr := formatRelativeDate(i.date, layout)
  83	listWidth := m.Width() - 2 // account for PaddingLeft(2) in itemStyle
  84	isSelected := index == m.Index()
  85
  86	styledDate := dateStyle.Render(dateStr)
  87	if isSelected {
  88		styledDate = selectedDateStyle.Render(dateStr)
  89	} else {
  90		styledDate = statusStyle.Render(dateStr)
  91	}
  92	dateWidth := lipgloss.Width(styledDate)
  93	cursorWidth := 0
  94	if isSelected {
  95		cursorWidth = 2 // "> " prefix
  96	}
  97
  98	// Available width for the whole left side (prefix + sender + separator + subject)
  99	maxLeft := listWidth - dateWidth - 2 - cursorWidth // 2 for spacing
 100	if maxLeft < 10 {
 101		maxLeft = 10
 102	}
 103
 104	prefixWidth := lipgloss.Width(prefix)
 105	iconWidth := lipgloss.Width(styledIcon) + 1
 106	sepWidth := len(separator)
 107
 108	availableForText := maxLeft - prefixWidth - iconWidth - sepWidth
 109	if availableForText < 10 {
 110		availableForText = 10
 111	}
 112
 113	maxSenderWidth := availableForText / 2
 114	if lipgloss.Width(sender) > maxSenderWidth {
 115		runes := []rune(sender)
 116		for lipgloss.Width(string(runes)) > maxSenderWidth-1 && len(runes) > 0 {
 117			runes = runes[:len(runes)-1]
 118		}
 119		sender = string(runes) + "…"
 120		styledSender = statusStyle.Render(sender)
 121	}
 122
 123	senderWidth := lipgloss.Width(styledSender)
 124	subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth
 125
 126	subject := i.title
 127	if subjectBudget < 4 {
 128		subjectBudget = 4
 129	}
 130	if lipgloss.Width(subject) > subjectBudget {
 131		runes := []rune(subject)
 132		for lipgloss.Width(string(runes)) > subjectBudget-1 && len(runes) > 0 {
 133			runes = runes[:len(runes)-1]
 134		}
 135		subject = string(runes) + "…"
 136	}
 137	styledSubject := statusStyle.Render(subject)
 138
 139	str := prefix + styledIcon + " " + styledSender + separator + styledSubject
 140
 141	// Pad to push date to the right
 142	padding := listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
 143	if padding < 1 {
 144		padding = 1
 145	}
 146
 147	// Check if this item is in visual selection
 148	inVisualSelection := false
 149	if d.inbox != nil && d.inbox.visualMode {
 150		_, inVisualSelection = d.inbox.selectedUIDs[i.uid]
 151	}
 152
 153	fn := itemStyle.Render
 154	if inVisualSelection && !isSelected {
 155		// Item is in visual selection but not the cursor
 156		fn = func(s ...string) string {
 157			return visualSelectedStyle.Render("* " + s[0])
 158		}
 159		cursorWidth = 2 // "* " prefix
 160		padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
 161		if padding < 1 {
 162			padding = 1
 163		}
 164	} else if isSelected {
 165		// Cursor position (may also be in selection)
 166		prefix := "> "
 167		if inVisualSelection {
 168			prefix = ">*"
 169		}
 170		fn = func(s ...string) string {
 171			return selectedItemStyle.Render(prefix + s[0])
 172		}
 173		cursorWidth = len(prefix)
 174		padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
 175		if padding < 1 {
 176			padding = 1
 177		}
 178	}
 179
 180	fmt.Fprint(w, fn(str+strings.Repeat(" ", padding)+styledDate))
 181}
 182
 183// formatRelativeDate formats a time as relative if within the last week,
 184// otherwise as an absolute date using the caller-supplied Go time layout.
 185// When layout is empty, falls back to the built-in short/long defaults.
 186func formatRelativeDate(timestamp time.Time, layout string) string {
 187	if timestamp.IsZero() {
 188		return ""
 189	}
 190	now := time.Now()
 191	d := now.Sub(timestamp)
 192
 193	switch {
 194	case d < time.Minute:
 195		return t("time.just_now")
 196	case d < time.Hour:
 197		mins := int(d.Minutes())
 198		return tn("time.minute_ago", mins, map[string]interface{}{"count": mins})
 199	case d < 24*time.Hour:
 200		hours := int(d.Hours())
 201		return tn("time.hour_ago", hours, map[string]interface{}{"count": hours})
 202	case d < 7*24*time.Hour:
 203		days := int(d.Hours() / 24)
 204		return tn("time.day_ago", days, map[string]interface{}{"count": days})
 205	default:
 206		timestamp = timestamp.Local()
 207		if layout != "" {
 208			return timestamp.Format(layout)
 209		}
 210		if timestamp.Year() == now.Year() {
 211			return timestamp.Format("Jan 02")
 212		}
 213		return timestamp.Format("Jan 02, 2006")
 214	}
 215}
 216
 217// parseSenderName extracts the display name from a "Name <email>" string,
 218// falling back to the local part of the email address.
 219func parseSenderName(from string) string {
 220	if idx := strings.Index(from, " <"); idx > 0 {
 221		return strings.TrimSpace(from[:idx])
 222	}
 223	// No display name — use local part of email
 224	if idx := strings.Index(from, "@"); idx > 0 {
 225		return from[:idx]
 226	}
 227	return from
 228}
 229
 230// truncateEmail shortens an email for display
 231func truncateEmail(email string) string {
 232	maxLength := 18
 233
 234	if len(email) <= maxLength {
 235		return email
 236	}
 237
 238	parts := strings.SplitN(email, "@", 2)
 239	if len(parts) != 2 {
 240		if len(email) > maxLength {
 241			return email[:maxLength-3] + "..."
 242		}
 243		return email
 244	}
 245
 246	local := parts[0]
 247	domain := parts[1]
 248
 249	// Keep full domain visible (e.g. ...@gmail.com) and truncate local part first.
 250	if len(local) > 8 {
 251		return local[:8] + "...@" + domain
 252	}
 253
 254	return local + "@" + domain
 255}
 256
 257// AccountTab represents a tab for an account
 258type AccountTab struct {
 259	ID    string
 260	Label string
 261	Email string
 262}
 263
 264type Inbox struct {
 265	list               list.Model
 266	isFetching         bool
 267	isRefreshing       bool
 268	emailsCount        int
 269	accounts           []config.Account
 270	emailsByAccount    map[string][]fetcher.Email
 271	allEmails          []fetcher.Email
 272	tabs               []AccountTab
 273	activeTabIndex     int
 274	width              int
 275	height             int
 276	currentAccountID   string // Empty means "ALL"
 277	emailCountByAcct   map[string]int
 278	mailbox            MailboxKind
 279	folderName         string          // Custom folder name override for title
 280	noMoreByAccount    map[string]bool // Per-account: true when pagination returns 0 results
 281	extraShortHelpKeys []key.Binding
 282	pluginStatus       string // Persistent status text set by plugins
 283	pluginKeyBindings  []PluginKeyBinding
 284
 285	// Visual mode state (Vim-style multi-select)
 286	visualMode     bool              // Whether visual mode is active
 287	visualAnchor   int               // Index where visual selection started
 288	selectedUIDs   map[uint32]string // map[uid]accountID for selected emails
 289	selectionOrder []uint32          // Ordered list of UIDs for display
 290
 291	// dateFormat is the Go reference-time layout used for absolute dates
 292	// older than a week. When empty, the built-in defaults apply.
 293	dateFormat string
 294}
 295
 296// SetDateFormat configures the Go time layout used to render absolute
 297// dates in the email list. Pass the value returned by
 298// config.Config.GetDateFormat.
 299func (m *Inbox) SetDateFormat(layout string) {
 300	m.dateFormat = layout
 301}
 302
 303func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
 304	return NewInboxWithMailbox(emails, accounts, MailboxInbox)
 305}
 306
 307func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
 308	return NewInboxWithMailbox(emails, accounts, MailboxSent)
 309}
 310
 311func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
 312	return NewInboxWithMailbox(emails, accounts, MailboxTrash)
 313}
 314
 315func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
 316	return NewInboxWithMailbox(emails, accounts, MailboxArchive)
 317}
 318
 319func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
 320	// Build tabs: empty for single account, "ALL" + accounts for multiple
 321	var tabs []AccountTab
 322	if len(accounts) <= 1 {
 323		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
 324	} else {
 325		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
 326		for _, acc := range accounts {
 327			// Use FetchEmail for display, fall back to Email if not set
 328			displayEmail := acc.FetchEmail
 329			if displayEmail == "" {
 330				displayEmail = acc.Email
 331			}
 332			tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
 333		}
 334	}
 335
 336	// Group emails by account
 337	emailsByAccount := make(map[string][]fetcher.Email)
 338	for _, email := range emails {
 339		emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
 340	}
 341
 342	// Track email counts per account
 343	emailCountByAcct := make(map[string]int)
 344	for accID, accEmails := range emailsByAccount {
 345		emailCountByAcct[accID] = len(accEmails)
 346	}
 347
 348	inbox := &Inbox{
 349		accounts:         accounts,
 350		emailsByAccount:  emailsByAccount,
 351		allEmails:        emails,
 352		tabs:             tabs,
 353		activeTabIndex:   0,
 354		currentAccountID: "",
 355		emailCountByAcct: emailCountByAcct,
 356		mailbox:          mailbox,
 357		visualMode:       false,
 358		selectedUIDs:     make(map[uint32]string),
 359		selectionOrder:   []uint32{},
 360	}
 361
 362	inbox.updateList()
 363	return inbox
 364}
 365
 366// NewInboxSingleAccount creates an inbox for a single account (legacy support)
 367func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
 368	return NewInbox(emails, nil)
 369}
 370
 371func (m *Inbox) updateList() {
 372	// Capture current index to restore later
 373	currentIndex := m.list.Index()
 374
 375	var displayEmails []fetcher.Email
 376	var showAccountLabel bool
 377
 378	if m.currentAccountID == "" {
 379		// "ALL" view - show all emails sorted by date
 380		displayEmails = m.allEmails
 381		showAccountLabel = !(len(m.accounts) <= 1)
 382	} else {
 383		// Specific account view
 384		displayEmails = m.emailsByAccount[m.currentAccountID]
 385		showAccountLabel = false
 386	}
 387
 388	m.emailsCount = len(displayEmails)
 389
 390	items := make([]list.Item, len(displayEmails))
 391	for i, email := range displayEmails {
 392		accountEmail := ""
 393		if showAccountLabel {
 394			// Find the account email for display
 395			for _, acc := range m.accounts {
 396				if acc.ID == email.AccountID {
 397					accountEmail = acc.FetchEmail
 398					break
 399				}
 400			}
 401		}
 402
 403		items[i] = item{
 404			title:         email.Subject,
 405			desc:          email.From,
 406			originalIndex: i,
 407			uid:           email.UID,
 408			accountID:     email.AccountID,
 409			accountEmail:  accountEmail,
 410			date:          email.Date,
 411			isRead:        email.IsRead,
 412		}
 413	}
 414
 415	l := list.New(items, itemDelegate{inbox: m}, 20, 14)
 416	l.Title = m.getTitle()
 417	l.SetShowStatusBar(true)
 418	l.SetFilteringEnabled(true)
 419	l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
 420	l.Styles.PaginationStyle = paginationStyle
 421	l.Styles.HelpStyle = inboxHelpStyle
 422	l.SetStatusBarItemName("email", "emails")
 423	l.AdditionalShortHelpKeys = func() []key.Binding {
 424		bindings := []key.Binding{
 425			key.NewBinding(key.WithKeys("v"), key.WithHelp("v", t("inbox.visual_mode"))),
 426			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))),
 427			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))),
 428			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))),
 429		}
 430		if len(m.tabs) > 1 {
 431			bindings = append(bindings,
 432				key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
 433				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
 434			)
 435		}
 436		bindings = append(bindings, m.extraShortHelpKeys...)
 437		for _, pk := range m.pluginKeyBindings {
 438			bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
 439		}
 440		return bindings
 441	}
 442
 443	l.KeyMap.Quit.SetEnabled(false)
 444
 445	// Disable default help to render it manually at the bottom
 446	l.SetShowHelp(false)
 447
 448	if m.width > 0 {
 449		l.SetWidth(m.width)
 450	}
 451	if m.height > 0 {
 452		l.SetHeight(m.height / 2)
 453	}
 454
 455	// Restore index
 456	// If index is out of bounds (e.g. list shrank), clamp it.
 457	if currentIndex >= len(items) {
 458		currentIndex = len(items) - 1
 459	}
 460	if currentIndex < 0 {
 461		currentIndex = 0
 462	}
 463	l.Select(currentIndex)
 464
 465	m.list = l
 466}
 467
 468func (m *Inbox) getTitle() string {
 469	var title string
 470	if m.currentAccountID == "" {
 471		title = m.getBaseTitle() + " - " + t("inbox.all_accounts")
 472	} else {
 473		title = m.getBaseTitle()
 474		for _, acc := range m.accounts {
 475			if acc.ID == m.currentAccountID {
 476				if acc.Name != "" {
 477					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
 478				} else {
 479					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
 480				}
 481				break
 482			}
 483		}
 484	}
 485	if m.isRefreshing {
 486		title += " (refreshing...)"
 487	}
 488	if m.isFetching {
 489		title += " (loading more...)"
 490	}
 491	if m.pluginStatus != "" {
 492		title += " (" + m.pluginStatus + ")"
 493	}
 494	return title
 495}
 496
 497func (m *Inbox) getBaseTitle() string {
 498	if m.folderName != "" {
 499		return m.folderName
 500	}
 501	switch m.mailbox {
 502	case MailboxSent:
 503		return "Sent"
 504	case MailboxTrash:
 505		return "Trash"
 506	case MailboxArchive:
 507		return "Archive"
 508	default:
 509		return "Inbox"
 510	}
 511}
 512
 513func (m *Inbox) Init() tea.Cmd {
 514	return nil
 515}
 516
 517func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 518	var cmds []tea.Cmd
 519
 520	switch msg := msg.(type) {
 521	case tea.KeyPressMsg:
 522		if m.list.FilterState() == list.Filtering {
 523			// Don't allow visual mode while filtering
 524			if m.visualMode {
 525				m.visualMode = false
 526				m.selectedUIDs = make(map[uint32]string)
 527				m.selectionOrder = []uint32{}
 528				m.updateListTitle()
 529			}
 530			break
 531		}
 532		switch keypress := msg.String(); keypress {
 533		case "v":
 534			if !m.visualMode {
 535				// Enter visual mode
 536				m.visualMode = true
 537				m.visualAnchor = m.list.Index()
 538				selectedItem, ok := m.list.SelectedItem().(item)
 539				if ok {
 540					m.selectedUIDs = make(map[uint32]string)
 541					m.selectionOrder = []uint32{}
 542					m.selectedUIDs[selectedItem.uid] = selectedItem.accountID
 543					m.selectionOrder = append(m.selectionOrder, selectedItem.uid)
 544				}
 545				m.updateListTitle()
 546			} else {
 547				// Exit visual mode
 548				m.visualMode = false
 549				m.selectedUIDs = make(map[uint32]string)
 550				m.selectionOrder = []uint32{}
 551				m.updateListTitle()
 552			}
 553			return m, nil
 554		case "esc":
 555			if m.visualMode {
 556				// Exit visual mode on ESC
 557				m.visualMode = false
 558				m.selectedUIDs = make(map[uint32]string)
 559				m.selectionOrder = []uint32{}
 560				m.updateListTitle()
 561				return m, nil
 562			}
 563		case "j", "down", "k", "up":
 564			if m.visualMode {
 565				// Let the list handle navigation first
 566				var cmd tea.Cmd
 567				m.list, cmd = m.list.Update(msg)
 568				// Then update selection
 569				m.updateVisualSelection()
 570				return m, cmd
 571			}
 572		case "left", "h":
 573			if len(m.tabs) > 1 {
 574				m.activeTabIndex--
 575				if m.activeTabIndex < 0 {
 576					m.activeTabIndex = len(m.tabs) - 1
 577				}
 578				m.currentAccountID = m.tabs[m.activeTabIndex].ID
 579				// Exit visual mode when switching tabs
 580				m.visualMode = false
 581				m.selectedUIDs = make(map[uint32]string)
 582				m.selectionOrder = []uint32{}
 583				m.updateList()
 584				return m, nil
 585			}
 586		case "right", "l":
 587			if len(m.tabs) > 1 {
 588				m.activeTabIndex++
 589				if m.activeTabIndex >= len(m.tabs) {
 590					m.activeTabIndex = 0
 591				}
 592				m.currentAccountID = m.tabs[m.activeTabIndex].ID
 593				// Exit visual mode when switching tabs
 594				m.visualMode = false
 595				m.selectedUIDs = make(map[uint32]string)
 596				m.selectionOrder = []uint32{}
 597				m.updateList()
 598				return m, nil
 599			}
 600		case "d":
 601			if m.visualMode && len(m.selectedUIDs) > 0 {
 602				// Batch delete
 603				uids := make([]uint32, len(m.selectionOrder))
 604				copy(uids, m.selectionOrder)
 605				accountID := ""
 606				for _, aid := range m.selectedUIDs {
 607					accountID = aid // Get any account (all should be same in single-account selection)
 608					break
 609				}
 610
 611				// Exit visual mode
 612				m.visualMode = false
 613				m.selectedUIDs = make(map[uint32]string)
 614				m.selectionOrder = []uint32{}
 615				m.updateListTitle()
 616
 617				return m, func() tea.Msg {
 618					return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
 619				}
 620			} else {
 621				// Single delete
 622				selectedItem, ok := m.list.SelectedItem().(item)
 623				if ok {
 624					return m, func() tea.Msg {
 625						return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
 626					}
 627				}
 628			}
 629		case "a":
 630			if m.visualMode && len(m.selectedUIDs) > 0 {
 631				// Batch archive
 632				uids := make([]uint32, len(m.selectionOrder))
 633				copy(uids, m.selectionOrder)
 634				accountID := ""
 635				for _, aid := range m.selectedUIDs {
 636					accountID = aid
 637					break
 638				}
 639
 640				// Exit visual mode
 641				m.visualMode = false
 642				m.selectedUIDs = make(map[uint32]string)
 643				m.selectionOrder = []uint32{}
 644				m.updateListTitle()
 645
 646				return m, func() tea.Msg {
 647					return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
 648				}
 649			} else {
 650				// Single archive
 651				selectedItem, ok := m.list.SelectedItem().(item)
 652				if ok {
 653					return m, func() tea.Msg {
 654						return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
 655					}
 656				}
 657			}
 658		case "r":
 659			m.isRefreshing = true
 660			m.list.Title = m.getTitle()
 661			// Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
 662			counts := make(map[string]int)
 663			for k, v := range m.emailCountByAcct {
 664				counts[k] = v
 665			}
 666			return m, func() tea.Msg {
 667				return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
 668			}
 669		case "enter":
 670			selectedItem, ok := m.list.SelectedItem().(item)
 671			if ok {
 672				idx := selectedItem.originalIndex
 673				uid := selectedItem.uid
 674				accountID := selectedItem.accountID
 675				return m, func() tea.Msg {
 676					return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
 677				}
 678			}
 679		}
 680	case tea.WindowSizeMsg:
 681		m.width = msg.Width
 682		m.height = msg.Height
 683		m.list.SetWidth(msg.Width)
 684		m.list.SetHeight(msg.Height / 2)
 685		if m.shouldFetchMore() {
 686			return m, tea.Batch(m.fetchMoreCmds()...)
 687		}
 688		return m, nil
 689
 690	case FetchingMoreEmailsMsg:
 691		m.isFetching = true
 692		m.list.Title = m.getTitle()
 693		return m, nil
 694
 695	case EmailsAppendedMsg:
 696		if msg.Mailbox != m.mailbox {
 697			return m, nil
 698		}
 699		m.isFetching = false
 700		m.list.Title = m.getTitle()
 701
 702		if len(msg.Emails) == 0 {
 703			if m.noMoreByAccount == nil {
 704				m.noMoreByAccount = make(map[string]bool)
 705			}
 706			m.noMoreByAccount[msg.AccountID] = true
 707			return m, nil
 708		}
 709
 710		// Add emails to the appropriate account
 711		for _, email := range msg.Emails {
 712			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
 713			m.allEmails = append(m.allEmails, email)
 714		}
 715		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
 716
 717		m.updateList()
 718		return m, nil
 719
 720	case RefreshingEmailsMsg:
 721		if msg.Mailbox != m.mailbox {
 722			return m, nil
 723		}
 724		m.isRefreshing = true
 725		m.list.Title = m.getTitle()
 726		return m, nil
 727
 728	case EmailsRefreshedMsg:
 729		if msg.Mailbox != m.mailbox {
 730			return m, nil
 731		}
 732		// Only clear the refreshing indicator. The actual email data is
 733		// merged by the main model (preserving paginated emails) and
 734		// pushed to us via SetEmails, so we must not overwrite it here.
 735		m.isRefreshing = false
 736		m.list.Title = m.getTitle()
 737		return m, nil
 738	}
 739
 740	var cmd tea.Cmd
 741	m.list, cmd = m.list.Update(msg)
 742	cmds = append(cmds, cmd)
 743
 744	if m.shouldFetchMore() {
 745		cmds = append(cmds, m.fetchMoreCmds()...)
 746	}
 747	return m, tea.Batch(cmds...)
 748}
 749
 750func (m *Inbox) shouldFetchMore() bool {
 751	if m.isFetching || m.isRefreshing {
 752		return false
 753	}
 754	if m.allAccountsExhausted() {
 755		return false
 756	}
 757	if len(m.list.Items()) == 0 {
 758		return false
 759	}
 760	if m.list.FilterState() == list.Filtering {
 761		return false
 762	}
 763	// Fetch if we've reached the bottom OR if we don't have enough items to fill the view
 764	return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
 765}
 766
 767// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
 768func (m *Inbox) allAccountsExhausted() bool {
 769	if len(m.noMoreByAccount) == 0 {
 770		return false
 771	}
 772	if m.currentAccountID != "" {
 773		return m.noMoreByAccount[m.currentAccountID]
 774	}
 775	// "ALL" view: all accounts must be exhausted
 776	for _, acc := range m.accounts {
 777		if !m.noMoreByAccount[acc.ID] {
 778			return false
 779		}
 780	}
 781	return len(m.accounts) > 0
 782}
 783
 784func (m *Inbox) fetchMoreCmds() []tea.Cmd {
 785	var cmds []tea.Cmd
 786	limit := uint32(m.list.Height())
 787	if limit < 20 {
 788		limit = 20
 789	}
 790
 791	if m.currentAccountID == "" {
 792		if len(m.accounts) == 0 {
 793			return nil
 794		}
 795		for _, acc := range m.accounts {
 796			accountID := acc.ID
 797			if m.noMoreByAccount[accountID] {
 798				continue
 799			}
 800			offset := uint32(len(m.emailsByAccount[accountID]))
 801			cmds = append(cmds, func(id string, off uint32) tea.Cmd {
 802				return func() tea.Msg {
 803					return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
 804				}
 805			}(accountID, offset))
 806		}
 807		return cmds
 808	}
 809
 810	if m.noMoreByAccount[m.currentAccountID] {
 811		return nil
 812	}
 813	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
 814	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
 815		return func() tea.Msg {
 816			return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
 817		}
 818	}(m.currentAccountID, offset))
 819	return cmds
 820}
 821
 822func (m *Inbox) View() tea.View {
 823	var b strings.Builder
 824
 825	// Render tabs if there are multiple accounts
 826	if len(m.tabs) > 1 {
 827		var tabViews []string
 828		for i, tab := range m.tabs {
 829			label := tab.Label
 830			if tab.ID == "" {
 831				label = "ALL"
 832			}
 833
 834			if i == m.activeTabIndex {
 835				tabViews = append(tabViews, activeTabStyle.Render(label))
 836			} else {
 837				tabViews = append(tabViews, tabStyle.Render(label))
 838			}
 839		}
 840		tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
 841		b.WriteString(tabBar)
 842		b.WriteString("\n")
 843	}
 844
 845	b.WriteString(m.list.View())
 846
 847	// Ensure we don't start gap calculation on the same line as the list
 848	if !strings.HasSuffix(b.String(), "\n") {
 849		b.WriteString("\n")
 850	}
 851
 852	helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
 853
 854	if m.height > 0 {
 855		usedHeight := lipgloss.Height(b.String())
 856		helpHeight := lipgloss.Height(helpView)
 857
 858		gap := m.height - usedHeight - helpHeight
 859		if gap > 0 {
 860			b.WriteString(strings.Repeat("\n", gap))
 861		}
 862	} else {
 863		b.WriteString("\n")
 864	}
 865
 866	b.WriteString(helpView)
 867
 868	return tea.NewView(b.String())
 869}
 870
 871// GetCurrentAccountID returns the currently selected account ID
 872func (m *Inbox) GetCurrentAccountID() string {
 873	return m.currentAccountID
 874}
 875
 876// GetEmailAtIndex returns the email at the given index for the current view
 877func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
 878	var displayEmails []fetcher.Email
 879	if m.currentAccountID == "" {
 880		displayEmails = m.allEmails
 881	} else {
 882		displayEmails = m.emailsByAccount[m.currentAccountID]
 883	}
 884
 885	if index >= 0 && index < len(displayEmails) {
 886		return &displayEmails[index]
 887	}
 888	return nil
 889}
 890
 891func (m *Inbox) GetMailbox() MailboxKind {
 892	return m.mailbox
 893}
 894
 895// GetSelectedEmail returns the currently selected email, or nil if none is selected.
 896func (m *Inbox) GetSelectedEmail() *fetcher.Email {
 897	selectedItem, ok := m.list.SelectedItem().(item)
 898	if !ok {
 899		return nil
 900	}
 901	return m.GetEmailAtIndex(selectedItem.originalIndex)
 902}
 903
 904// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
 905func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
 906	for i := range m.allEmails {
 907		if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
 908			m.allEmails[i].IsRead = true
 909			break
 910		}
 911	}
 912	if emails, ok := m.emailsByAccount[accountID]; ok {
 913		for i := range emails {
 914			if emails[i].UID == uid {
 915				emails[i].IsRead = true
 916				break
 917			}
 918		}
 919	}
 920	m.updateList()
 921}
 922
 923// updateVisualSelection updates the selected UIDs based on anchor and current index
 924func (m *Inbox) updateVisualSelection() {
 925	if !m.visualMode {
 926		return
 927	}
 928
 929	currentIdx := m.list.Index()
 930	start := m.visualAnchor
 931	end := currentIdx
 932
 933	if start > end {
 934		start, end = end, start
 935	}
 936
 937	// Clear and rebuild selection
 938	m.selectedUIDs = make(map[uint32]string)
 939	m.selectionOrder = []uint32{}
 940
 941	items := m.list.Items()
 942	firstAccountID := ""
 943	for i := start; i <= end && i < len(items); i++ {
 944		if itm, ok := items[i].(item); ok {
 945			// Ensure all selected emails are from the same account (prevent cross-account batch ops)
 946			if firstAccountID == "" {
 947				firstAccountID = itm.accountID
 948			}
 949			if itm.accountID != firstAccountID {
 950				// Don't add emails from different accounts
 951				continue
 952			}
 953
 954			if _, exists := m.selectedUIDs[itm.uid]; !exists {
 955				m.selectedUIDs[itm.uid] = itm.accountID
 956				m.selectionOrder = append(m.selectionOrder, itm.uid)
 957			}
 958		}
 959	}
 960
 961	m.updateListTitle()
 962}
 963
 964// updateListTitle updates the title to show selection count when in visual mode
 965func (m *Inbox) updateListTitle() {
 966	if m.visualMode && len(m.selectedUIDs) > 0 {
 967		baseTitle := m.getBaseTitle()
 968		m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
 969	} else {
 970		m.list.Title = m.getTitle()
 971	}
 972}
 973
 974// RemoveEmails removes multiple emails by UID and account ID (batch operation)
 975func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
 976	uidSet := make(map[uint32]bool)
 977	for _, uid := range uids {
 978		uidSet[uid] = true
 979	}
 980
 981	// Remove from account-specific list
 982	if emails, ok := m.emailsByAccount[accountID]; ok {
 983		var filtered []fetcher.Email
 984		for _, e := range emails {
 985			if !uidSet[e.UID] {
 986				filtered = append(filtered, e)
 987			}
 988		}
 989		m.emailsByAccount[accountID] = filtered
 990	}
 991
 992	// Remove from all emails list
 993	var filteredAll []fetcher.Email
 994	for _, e := range m.allEmails {
 995		if !(uidSet[e.UID] && e.AccountID == accountID) {
 996			filteredAll = append(filteredAll, e)
 997		}
 998	}
 999	m.allEmails = filteredAll
1000
1001	m.updateList()
1002}
1003
1004// RemoveEmail removes an email by UID and account ID
1005func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
1006	// Remove from account-specific list
1007	if emails, ok := m.emailsByAccount[accountID]; ok {
1008		var filtered []fetcher.Email
1009		for _, e := range emails {
1010			if e.UID != uid {
1011				filtered = append(filtered, e)
1012			}
1013		}
1014		m.emailsByAccount[accountID] = filtered
1015	}
1016
1017	// Remove from all emails list
1018	var filteredAll []fetcher.Email
1019	for _, e := range m.allEmails {
1020		if !(e.UID == uid && e.AccountID == accountID) {
1021			filteredAll = append(filteredAll, e)
1022		}
1023	}
1024	m.allEmails = filteredAll
1025
1026	m.updateList()
1027}
1028
1029// SetSize sets the width and height of the inbox, then updates the list.
1030func (m *Inbox) SetSize(width, height int) {
1031	m.width = width
1032	m.height = height
1033	m.list.SetWidth(width)
1034	m.list.SetHeight(height / 2)
1035}
1036
1037// SetFolderName sets a custom folder name for the inbox title.
1038func (m *Inbox) SetFolderName(name string) {
1039	m.folderName = name
1040	m.list.Title = m.getTitle()
1041}
1042
1043// SetPluginStatus sets a persistent status string from plugins, shown in the title.
1044func (m *Inbox) SetPluginStatus(status string) {
1045	m.pluginStatus = status
1046	m.list.Title = m.getTitle()
1047}
1048
1049// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1050func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1051	m.pluginKeyBindings = bindings
1052}
1053
1054// SetEmails updates all emails (used after fetch)
1055func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
1056	m.accounts = accounts
1057	m.allEmails = emails
1058	m.noMoreByAccount = make(map[string]bool)
1059
1060	// Rebuild tabs: empty for single account, "ALL" + accounts for multiple
1061	var tabs []AccountTab
1062	if len(accounts) <= 1 {
1063		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
1064	} else {
1065		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
1066		for _, acc := range accounts {
1067			tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
1068		}
1069	}
1070	m.tabs = tabs
1071
1072	// Re-group emails by account
1073	m.emailsByAccount = make(map[string][]fetcher.Email)
1074	for _, email := range emails {
1075		m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
1076	}
1077
1078	// Update email counts
1079	m.emailCountByAcct = make(map[string]int)
1080	for accID, accEmails := range m.emailsByAccount {
1081		m.emailCountByAcct[accID] = len(accEmails)
1082	}
1083
1084	m.updateList()
1085}