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