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