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