inbox.go

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