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