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