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