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	m.emailsCount = len(displayEmails)
 393
 394	var showAccountLabel bool
 395	if m.searchActive {
 396		showAccountLabel = len(m.accounts) > 1
 397	} else if m.currentAccountID == "" {
 398		showAccountLabel = len(m.accounts) > 1
 399	}
 400
 401	if !showAccountLabel && len(m.accounts) == 1 && m.accounts[0].CatchAll {
 402		showAccountLabel = true
 403	}
 404
 405	items := make([]list.Item, len(displayEmails))
 406	for i, email := range displayEmails {
 407		accountEmail := ""
 408		if showAccountLabel {
 409			accountEmail = m.accountLabelForEmail(email)
 410		}
 411
 412		items[i] = item{
 413			title:         email.Subject,
 414			desc:          email.From,
 415			originalIndex: i,
 416			uid:           email.UID,
 417			accountID:     email.AccountID,
 418			accountEmail:  accountEmail,
 419			date:          email.Date,
 420			isRead:        email.IsRead,
 421		}
 422	}
 423
 424	l := list.New(items, itemDelegate{inbox: m}, 20, 14)
 425	l.Title = m.getTitle()
 426	l.SetShowStatusBar(true)
 427	l.SetFilteringEnabled(true)
 428	l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
 429	l.Styles.PaginationStyle = paginationStyle
 430	l.Styles.HelpStyle = inboxHelpStyle
 431	l.SetStatusBarItemName("email", "emails")
 432	l.AdditionalShortHelpKeys = func() []key.Binding {
 433		bindings := []key.Binding{
 434			key.NewBinding(key.WithKeys("v"), key.WithHelp("v", t("inbox.visual_mode"))),
 435			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))),
 436			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))),
 437			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))),
 438			key.NewBinding(key.WithKeys(searchKey()), key.WithHelp(searchKey(), t("inbox.search"))),
 439		}
 440		if len(m.tabs) > 1 {
 441			bindings = append(bindings,
 442				key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
 443				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
 444			)
 445		}
 446		bindings = append(bindings, m.extraShortHelpKeys...)
 447		for _, pk := range m.pluginKeyBindings {
 448			bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
 449		}
 450		return bindings
 451	}
 452
 453	l.KeyMap.Quit.SetEnabled(false)
 454	l.KeyMap.Filter = key.NewBinding(key.WithKeys(filterKey()), key.WithHelp(filterKey(), t("inbox.filter")))
 455	l.KeyMap.NextPage = key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "next page"))
 456	l.KeyMap.PrevPage = key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "prev page"))
 457
 458	// Disable default help to render it manually at the bottom
 459	l.SetShowHelp(false)
 460
 461	if m.width > 0 {
 462		l.SetWidth(m.width)
 463	}
 464	if m.height > 0 {
 465		l.SetHeight(m.height / 2)
 466	}
 467
 468	// Restore index
 469	// If index is out of bounds (e.g. list shrank), clamp it.
 470	if currentIndex >= len(items) {
 471		currentIndex = len(items) - 1
 472	}
 473	if currentIndex < 0 {
 474		currentIndex = 0
 475	}
 476	l.Select(currentIndex)
 477
 478	m.list = l
 479}
 480
 481func (m *Inbox) displayEmails() []fetcher.Email {
 482	if m.searchActive {
 483		return m.filteredSearchResults()
 484	}
 485	if m.currentAccountID == "" {
 486		return m.allEmails
 487	}
 488	return m.emailsByAccount[m.currentAccountID]
 489}
 490
 491func (m *Inbox) filteredSearchResults() []fetcher.Email {
 492	if m.currentAccountID == "" {
 493		return m.searchResults
 494	}
 495	filtered := make([]fetcher.Email, 0, len(m.searchResults))
 496	for _, email := range m.searchResults {
 497		if email.AccountID == m.currentAccountID {
 498			filtered = append(filtered, email)
 499		}
 500	}
 501	return filtered
 502}
 503
 504func (m *Inbox) accountLabelForEmail(email fetcher.Email) string {
 505	var owningAcc *config.Account
 506	for i := range m.accounts {
 507		if m.accounts[i].ID == email.AccountID {
 508			owningAcc = &m.accounts[i]
 509			break
 510		}
 511	}
 512
 513	if owningAcc != nil && owningAcc.CatchAll && len(email.To) > 0 {
 514		return extractEmailAddress(email.To[0])
 515	}
 516
 517	for _, acc := range m.accounts {
 518		fetchEmail := accountDisplayEmail(acc)
 519		for _, recipient := range email.To {
 520			if sameEmailAddress(recipient, fetchEmail) {
 521				return extractEmailAddress(recipient)
 522			}
 523		}
 524	}
 525
 526	if owningAcc != nil {
 527		return accountDisplayEmail(*owningAcc)
 528	}
 529	return ""
 530}
 531
 532func dedupeEmailsForAccounts(emails []fetcher.Email, accounts []config.Account) []fetcher.Email {
 533	if len(emails) <= 1 {
 534		return emails
 535	}
 536
 537	accountByID := make(map[string]config.Account, len(accounts))
 538	for _, acc := range accounts {
 539		accountByID[acc.ID] = acc
 540	}
 541
 542	deduped := make([]fetcher.Email, 0, len(emails))
 543	indexByKey := make(map[string]int, len(emails))
 544	for _, email := range emails {
 545		key := emailDedupKey(email)
 546		if existingIndex, ok := indexByKey[key]; ok {
 547			existing := deduped[existingIndex]
 548			if !emailMatchesOwningAccount(existing, accountByID) && emailMatchesOwningAccount(email, accountByID) {
 549				deduped[existingIndex] = email
 550			}
 551			continue
 552		}
 553		indexByKey[key] = len(deduped)
 554		deduped = append(deduped, email)
 555	}
 556	return deduped
 557}
 558
 559func emailDedupKey(email fetcher.Email) string {
 560	if email.MessageID != "" {
 561		return email.MessageID
 562	}
 563	// Malformed messages can omit Message-ID, so fall back to stable visible metadata.
 564	return fmt.Sprintf("%s|%s|%d", email.From, email.Subject, email.Date.UnixNano())
 565}
 566
 567func emailMatchesOwningAccount(email fetcher.Email, accountByID map[string]config.Account) bool {
 568	acc, ok := accountByID[email.AccountID]
 569	if !ok {
 570		return false
 571	}
 572	fetchEmail := accountDisplayEmail(acc)
 573	for _, recipient := range email.To {
 574		if sameEmailAddress(recipient, fetchEmail) {
 575			return true
 576		}
 577	}
 578	return false
 579}
 580
 581func accountDisplayEmail(acc config.Account) string {
 582	if acc.FetchEmail != "" {
 583		return acc.FetchEmail
 584	}
 585	return acc.Email
 586}
 587
 588func sameEmailAddress(a, b string) bool {
 589	return strings.EqualFold(extractEmailAddress(a), extractEmailAddress(b))
 590}
 591
 592func extractEmailAddress(value string) string {
 593	value = strings.TrimSpace(value)
 594	if value == "" {
 595		return ""
 596	}
 597	if addr, err := mail.ParseAddress(value); err == nil {
 598		return strings.TrimSpace(addr.Address)
 599	}
 600	return strings.Trim(value, "<>")
 601}
 602
 603func (m *Inbox) getTitle() string {
 604	var title string
 605	if m.searchActive {
 606		title = fmt.Sprintf("Search Results - %s", m.searchQuery)
 607	} else if m.currentAccountID == "" {
 608		title = m.getBaseTitle() + " - " + t("inbox.all_accounts")
 609	} else {
 610		title = m.getBaseTitle()
 611		for _, acc := range m.accounts {
 612			if acc.ID == m.currentAccountID {
 613				if acc.Name != "" {
 614					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
 615				} else {
 616					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), accountDisplayEmail(acc))
 617				}
 618				break
 619			}
 620		}
 621	}
 622	if m.isRefreshing {
 623		title += " (refreshing...)"
 624	}
 625	if m.isFetching {
 626		title += " (loading more...)"
 627	}
 628	if m.pluginStatus != "" {
 629		title += " (" + m.pluginStatus + ")"
 630	}
 631	return title
 632}
 633
 634func (m *Inbox) getBaseTitle() string {
 635	if m.folderName != "" {
 636		return m.folderName
 637	}
 638	switch m.mailbox {
 639	case MailboxSent:
 640		return "Sent"
 641	case MailboxTrash:
 642		return "Trash"
 643	case MailboxArchive:
 644		return "Archive"
 645	default:
 646		return "Inbox"
 647	}
 648}
 649
 650func (m *Inbox) Init() tea.Cmd {
 651	return nil
 652}
 653
 654func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 655	var cmds []tea.Cmd
 656
 657	switch msg := msg.(type) {
 658	case tea.KeyPressMsg:
 659		if m.searchOverlay != nil {
 660			if msg.String() == config.Keybinds.Global.Cancel {
 661				m.searchOverlay = nil
 662				return m, nil
 663			}
 664			cmd := m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID)
 665			return m, cmd
 666		}
 667		if m.list.FilterState() == list.Filtering {
 668			// Don't allow visual mode while filtering
 669			if m.visualMode {
 670				m.visualMode = false
 671				m.selectedUIDs = make(map[uint32]string)
 672				m.selectionOrder = []uint32{}
 673				m.updateListTitle()
 674			}
 675			break
 676		}
 677		kb := config.Keybinds
 678		searchBinding := searchKey()
 679		switch keypress := msg.String(); keypress {
 680		case searchBinding:
 681			m.searchOverlay = NewSearchOverlay(m.width, m.height)
 682			return m, m.searchOverlay.Init()
 683		case kb.Inbox.VisualMode:
 684			if !m.visualMode {
 685				// Enter visual mode
 686				m.visualMode = true
 687				m.visualAnchor = m.list.Index()
 688				selectedItem, ok := m.list.SelectedItem().(item)
 689				if ok {
 690					m.selectedUIDs = make(map[uint32]string)
 691					m.selectionOrder = []uint32{}
 692					m.selectedUIDs[selectedItem.uid] = selectedItem.accountID
 693					m.selectionOrder = append(m.selectionOrder, selectedItem.uid)
 694				}
 695				m.updateListTitle()
 696			} else {
 697				// Exit visual mode
 698				m.visualMode = false
 699				m.selectedUIDs = make(map[uint32]string)
 700				m.selectionOrder = []uint32{}
 701				m.updateListTitle()
 702			}
 703			return m, nil
 704		case kb.Global.Cancel:
 705			if m.searchActive {
 706				m.searchActive = false
 707				m.searchQuery = ""
 708				m.searchResults = nil
 709				m.updateList()
 710				return m, nil
 711			}
 712			if m.visualMode {
 713				// Exit visual mode on cancel key
 714				m.visualMode = false
 715				m.selectedUIDs = make(map[uint32]string)
 716				m.selectionOrder = []uint32{}
 717				m.updateListTitle()
 718				return m, nil
 719			}
 720		case kb.Global.NavDown, "down", kb.Global.NavUp, "up":
 721			if m.visualMode {
 722				// Let the list handle navigation first
 723				var cmd tea.Cmd
 724				m.list, cmd = m.list.Update(msg)
 725				// Then update selection
 726				m.updateVisualSelection()
 727				return m, cmd
 728			}
 729		case "left", kb.Inbox.PrevTab:
 730			if len(m.tabs) > 1 {
 731				m.activeTabIndex--
 732				if m.activeTabIndex < 0 {
 733					m.activeTabIndex = len(m.tabs) - 1
 734				}
 735				m.currentAccountID = m.tabs[m.activeTabIndex].ID
 736				// Exit visual mode when switching tabs
 737				m.visualMode = false
 738				m.selectedUIDs = make(map[uint32]string)
 739				m.selectionOrder = []uint32{}
 740				m.updateList()
 741				return m, nil
 742			}
 743		case "right", kb.Inbox.NextTab:
 744			if len(m.tabs) > 1 {
 745				m.activeTabIndex++
 746				if m.activeTabIndex >= len(m.tabs) {
 747					m.activeTabIndex = 0
 748				}
 749				m.currentAccountID = m.tabs[m.activeTabIndex].ID
 750				// Exit visual mode when switching tabs
 751				m.visualMode = false
 752				m.selectedUIDs = make(map[uint32]string)
 753				m.selectionOrder = []uint32{}
 754				m.updateList()
 755				return m, nil
 756			}
 757		case kb.Inbox.Delete:
 758			if m.visualMode && len(m.selectedUIDs) > 0 {
 759				// Batch delete
 760				uids := make([]uint32, len(m.selectionOrder))
 761				copy(uids, m.selectionOrder)
 762				accountID := ""
 763				for _, aid := range m.selectedUIDs {
 764					accountID = aid // Get any account (all should be same in single-account selection)
 765					break
 766				}
 767
 768				// Exit visual mode
 769				m.visualMode = false
 770				m.selectedUIDs = make(map[uint32]string)
 771				m.selectionOrder = []uint32{}
 772				m.updateListTitle()
 773
 774				return m, func() tea.Msg {
 775					return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
 776				}
 777			} else {
 778				// Single delete
 779				selectedItem, ok := m.list.SelectedItem().(item)
 780				if ok {
 781					return m, func() tea.Msg {
 782						return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
 783					}
 784				}
 785			}
 786		case kb.Inbox.Archive:
 787			if m.visualMode && len(m.selectedUIDs) > 0 {
 788				// Batch archive
 789				uids := make([]uint32, len(m.selectionOrder))
 790				copy(uids, m.selectionOrder)
 791				accountID := ""
 792				for _, aid := range m.selectedUIDs {
 793					accountID = aid
 794					break
 795				}
 796
 797				// Exit visual mode
 798				m.visualMode = false
 799				m.selectedUIDs = make(map[uint32]string)
 800				m.selectionOrder = []uint32{}
 801				m.updateListTitle()
 802
 803				return m, func() tea.Msg {
 804					return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
 805				}
 806			} else {
 807				// Single archive
 808				selectedItem, ok := m.list.SelectedItem().(item)
 809				if ok {
 810					return m, func() tea.Msg {
 811						return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
 812					}
 813				}
 814			}
 815		case kb.Inbox.Refresh:
 816			m.isRefreshing = true
 817			m.list.Title = m.getTitle()
 818			// Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
 819			counts := make(map[string]int)
 820			for k, v := range m.emailCountByAcct {
 821				counts[k] = v
 822			}
 823			return m, func() tea.Msg {
 824				return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
 825			}
 826		case kb.Inbox.Open:
 827			selectedItem, ok := m.list.SelectedItem().(item)
 828			if ok {
 829				idx := selectedItem.originalIndex
 830				uid := selectedItem.uid
 831				accountID := selectedItem.accountID
 832				var email *fetcher.Email
 833				if m.searchActive {
 834					email = m.GetEmailAtIndex(idx)
 835				}
 836				return m, func() tea.Msg {
 837					return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox, Email: email}
 838				}
 839			}
 840		}
 841	case tea.WindowSizeMsg:
 842		m.width = msg.Width
 843		m.height = msg.Height
 844		m.list.SetWidth(msg.Width)
 845		m.list.SetHeight(msg.Height / 2)
 846		if m.searchOverlay != nil {
 847			return m, m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID)
 848		}
 849		if m.shouldFetchMore() {
 850			return m, tea.Batch(m.fetchMoreCmds()...)
 851		}
 852		return m, nil
 853
 854	case SearchResultsMsg:
 855		if m.searchOverlay == nil {
 856			return m, nil
 857		}
 858		return m, m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID)
 859
 860	case ApplySearchResultsMsg:
 861		m.searchOverlay = nil
 862		m.searchActive = true
 863		m.searchQuery = msg.Query.Raw
 864		m.searchResults = dedupeEmailsForAccounts(msg.Emails, m.accounts)
 865		m.visualMode = false
 866		m.selectedUIDs = make(map[uint32]string)
 867		m.selectionOrder = []uint32{}
 868		m.updateList()
 869		return m, nil
 870
 871	case FetchingMoreEmailsMsg:
 872		m.isFetching = true
 873		m.list.Title = m.getTitle()
 874		return m, nil
 875
 876	case EmailsAppendedMsg:
 877		if msg.Mailbox != m.mailbox {
 878			return m, nil
 879		}
 880		m.isFetching = false
 881		m.list.Title = m.getTitle()
 882
 883		if len(msg.Emails) == 0 {
 884			if m.noMoreByAccount == nil {
 885				m.noMoreByAccount = make(map[string]bool)
 886			}
 887			m.noMoreByAccount[msg.AccountID] = true
 888			return m, nil
 889		}
 890
 891		// Add emails to the appropriate account
 892		for _, email := range msg.Emails {
 893			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
 894			m.allEmails = append(m.allEmails, email)
 895		}
 896		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
 897
 898		m.updateList()
 899		return m, nil
 900
 901	case RefreshingEmailsMsg:
 902		if msg.Mailbox != m.mailbox {
 903			return m, nil
 904		}
 905		m.isRefreshing = true
 906		m.list.Title = m.getTitle()
 907		return m, nil
 908
 909	case EmailsRefreshedMsg:
 910		if msg.Mailbox != m.mailbox {
 911			return m, nil
 912		}
 913		// Only clear the refreshing indicator. The actual email data is
 914		// merged by the main model (preserving paginated emails) and
 915		// pushed to us via SetEmails, so we must not overwrite it here.
 916		m.isRefreshing = false
 917		m.list.Title = m.getTitle()
 918		return m, nil
 919	}
 920
 921	var cmd tea.Cmd
 922	m.list, cmd = m.list.Update(msg)
 923	cmds = append(cmds, cmd)
 924
 925	if m.shouldFetchMore() {
 926		cmds = append(cmds, m.fetchMoreCmds()...)
 927	}
 928	return m, tea.Batch(cmds...)
 929}
 930
 931func (m *Inbox) shouldFetchMore() bool {
 932	if m.isFetching || m.isRefreshing {
 933		return false
 934	}
 935	if m.searchActive {
 936		return false
 937	}
 938	if m.allAccountsExhausted() {
 939		return false
 940	}
 941	if len(m.list.Items()) == 0 {
 942		return false
 943	}
 944	if m.list.FilterState() == list.Filtering {
 945		return false
 946	}
 947	// Fetch if we've reached the bottom OR if we don't have enough items to fill the view
 948	return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
 949}
 950
 951// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
 952func (m *Inbox) allAccountsExhausted() bool {
 953	if len(m.noMoreByAccount) == 0 {
 954		return false
 955	}
 956	if m.currentAccountID != "" {
 957		return m.noMoreByAccount[m.currentAccountID]
 958	}
 959	// "ALL" view: all accounts must be exhausted
 960	for _, acc := range m.accounts {
 961		if !m.noMoreByAccount[acc.ID] {
 962			return false
 963		}
 964	}
 965	return len(m.accounts) > 0
 966}
 967
 968func (m *Inbox) fetchMoreCmds() []tea.Cmd {
 969	var cmds []tea.Cmd
 970	limit := uint32(m.list.Height())
 971	if limit < 20 {
 972		limit = 20
 973	}
 974
 975	if m.currentAccountID == "" {
 976		if len(m.accounts) == 0 {
 977			return nil
 978		}
 979		for _, acc := range m.accounts {
 980			accountID := acc.ID
 981			if m.noMoreByAccount[accountID] {
 982				continue
 983			}
 984			offset := uint32(len(m.emailsByAccount[accountID]))
 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			}(accountID, offset))
 990		}
 991		return cmds
 992	}
 993
 994	if m.noMoreByAccount[m.currentAccountID] {
 995		return nil
 996	}
 997	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
 998	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
 999		return func() tea.Msg {
1000			return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
1001		}
1002	}(m.currentAccountID, offset))
1003	return cmds
1004}
1005
1006func (m *Inbox) View() tea.View {
1007	var b strings.Builder
1008
1009	// Render tabs if there are multiple accounts
1010	if len(m.tabs) > 1 {
1011		var tabViews []string
1012		for i, tab := range m.tabs {
1013			label := tab.Label
1014			if tab.ID == "" {
1015				label = "ALL"
1016			}
1017
1018			if i == m.activeTabIndex {
1019				tabViews = append(tabViews, activeTabStyle.Render(label))
1020			} else {
1021				tabViews = append(tabViews, tabStyle.Render(label))
1022			}
1023		}
1024		tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
1025		b.WriteString(tabBar)
1026		b.WriteString("\n")
1027	}
1028
1029	b.WriteString(m.list.View())
1030
1031	if m.searchOverlay != nil {
1032		b.WriteString("\n")
1033		b.WriteString(m.searchOverlay.View())
1034	}
1035
1036	// Ensure we don't start gap calculation on the same line as the list
1037	if !strings.HasSuffix(b.String(), "\n") {
1038		b.WriteString("\n")
1039	}
1040
1041	helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
1042
1043	if m.height > 0 {
1044		usedHeight := lipgloss.Height(b.String())
1045		helpHeight := lipgloss.Height(helpView)
1046
1047		gap := m.height - usedHeight - helpHeight
1048		if gap > 0 {
1049			b.WriteString(strings.Repeat("\n", gap))
1050		}
1051	} else {
1052		b.WriteString("\n")
1053	}
1054
1055	b.WriteString(helpView)
1056
1057	return tea.NewView(b.String())
1058}
1059
1060// GetCurrentAccountID returns the currently selected account ID
1061func (m *Inbox) GetCurrentAccountID() string {
1062	return m.currentAccountID
1063}
1064
1065func (m *Inbox) IsSearchActive() bool {
1066	return m != nil && (m.searchOverlay != nil || m.searchActive)
1067}
1068
1069func (m *Inbox) IsFilterActive() bool {
1070	return m != nil && (m.list.FilterState() == list.Filtering || m.list.FilterState() == list.FilterApplied)
1071}
1072
1073// GetEmailAtIndex returns the email at the given index for the current view
1074func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
1075	displayEmails := m.displayEmails()
1076
1077	if index >= 0 && index < len(displayEmails) {
1078		return &displayEmails[index]
1079	}
1080	return nil
1081}
1082
1083func (m *Inbox) GetMailbox() MailboxKind {
1084	return m.mailbox
1085}
1086
1087// GetSelectedEmail returns the currently selected email, or nil if none is selected.
1088func (m *Inbox) GetSelectedEmail() *fetcher.Email {
1089	selectedItem, ok := m.list.SelectedItem().(item)
1090	if !ok {
1091		return nil
1092	}
1093	return m.GetEmailAtIndex(selectedItem.originalIndex)
1094}
1095
1096// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
1097func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
1098	for i := range m.allEmails {
1099		if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
1100			m.allEmails[i].IsRead = true
1101			break
1102		}
1103	}
1104	if emails, ok := m.emailsByAccount[accountID]; ok {
1105		for i := range emails {
1106			if emails[i].UID == uid {
1107				emails[i].IsRead = true
1108				break
1109			}
1110		}
1111	}
1112	m.updateList()
1113}
1114
1115// updateVisualSelection updates the selected UIDs based on anchor and current index
1116func (m *Inbox) updateVisualSelection() {
1117	if !m.visualMode {
1118		return
1119	}
1120
1121	currentIdx := m.list.Index()
1122	start := m.visualAnchor
1123	end := currentIdx
1124
1125	if start > end {
1126		start, end = end, start
1127	}
1128
1129	// Clear and rebuild selection
1130	m.selectedUIDs = make(map[uint32]string)
1131	m.selectionOrder = []uint32{}
1132
1133	items := m.list.Items()
1134	firstAccountID := ""
1135	for i := start; i <= end && i < len(items); i++ {
1136		if itm, ok := items[i].(item); ok {
1137			// Ensure all selected emails are from the same account (prevent cross-account batch ops)
1138			if firstAccountID == "" {
1139				firstAccountID = itm.accountID
1140			}
1141			if itm.accountID != firstAccountID {
1142				// Don't add emails from different accounts
1143				continue
1144			}
1145
1146			if _, exists := m.selectedUIDs[itm.uid]; !exists {
1147				m.selectedUIDs[itm.uid] = itm.accountID
1148				m.selectionOrder = append(m.selectionOrder, itm.uid)
1149			}
1150		}
1151	}
1152
1153	m.updateListTitle()
1154}
1155
1156// updateListTitle updates the title to show selection count when in visual mode
1157func (m *Inbox) updateListTitle() {
1158	if m.visualMode && len(m.selectedUIDs) > 0 {
1159		baseTitle := m.getBaseTitle()
1160		m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
1161	} else {
1162		m.list.Title = m.getTitle()
1163	}
1164}
1165
1166// RemoveEmails removes multiple emails by UID and account ID (batch operation)
1167func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
1168	uidSet := make(map[uint32]bool)
1169	for _, uid := range uids {
1170		uidSet[uid] = true
1171	}
1172
1173	// Remove from account-specific list
1174	if emails, ok := m.emailsByAccount[accountID]; ok {
1175		var filtered []fetcher.Email
1176		for _, e := range emails {
1177			if !uidSet[e.UID] {
1178				filtered = append(filtered, e)
1179			}
1180		}
1181		m.emailsByAccount[accountID] = filtered
1182	}
1183
1184	// Remove from all emails list
1185	var filteredAll []fetcher.Email
1186	for _, e := range m.allEmails {
1187		if !(uidSet[e.UID] && e.AccountID == accountID) {
1188			filteredAll = append(filteredAll, e)
1189		}
1190	}
1191	m.allEmails = filteredAll
1192
1193	m.updateList()
1194}
1195
1196// RemoveEmail removes an email by UID and account ID
1197func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
1198	// Remove from account-specific list
1199	if emails, ok := m.emailsByAccount[accountID]; ok {
1200		var filtered []fetcher.Email
1201		for _, e := range emails {
1202			if e.UID != uid {
1203				filtered = append(filtered, e)
1204			}
1205		}
1206		m.emailsByAccount[accountID] = filtered
1207	}
1208
1209	// Remove from all emails list
1210	var filteredAll []fetcher.Email
1211	for _, e := range m.allEmails {
1212		if !(e.UID == uid && e.AccountID == accountID) {
1213			filteredAll = append(filteredAll, e)
1214		}
1215	}
1216	m.allEmails = filteredAll
1217
1218	m.updateList()
1219}
1220
1221// SetSize sets the width and height of the inbox, then updates the list.
1222func (m *Inbox) SetSize(width, height int) {
1223	m.width = width
1224	m.height = height
1225	m.list.SetWidth(width)
1226	m.list.SetHeight(height / 2)
1227}
1228
1229// SetFolderName sets a custom folder name for the inbox title.
1230func (m *Inbox) SetFolderName(name string) {
1231	m.folderName = name
1232	m.list.Title = m.getTitle()
1233}
1234
1235// SetPluginStatus sets a persistent status string from plugins, shown in the title.
1236func (m *Inbox) SetPluginStatus(status string) {
1237	m.pluginStatus = status
1238	m.list.Title = m.getTitle()
1239}
1240
1241// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1242func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1243	m.pluginKeyBindings = bindings
1244}
1245
1246// SetEmails updates all emails (used after fetch)
1247func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
1248	m.accounts = accounts
1249	m.allEmails = dedupeEmailsForAccounts(emails, accounts)
1250	m.noMoreByAccount = make(map[string]bool)
1251
1252	// Rebuild tabs: empty for single account, "ALL" + accounts for multiple
1253	var tabs []AccountTab
1254	if len(accounts) <= 1 {
1255		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
1256	} else {
1257		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
1258		for _, acc := range accounts {
1259			displayEmail := accountDisplayEmail(acc)
1260			tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
1261		}
1262	}
1263	m.tabs = tabs
1264
1265	// Re-group emails by account
1266	m.emailsByAccount = make(map[string][]fetcher.Email)
1267	for _, email := range emails {
1268		m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
1269	}
1270
1271	// Update email counts
1272	m.emailCountByAcct = make(map[string]int)
1273	for accID, accEmails := range m.emailsByAccount {
1274		m.emailCountByAcct[accID] = len(accEmails)
1275	}
1276
1277	m.updateList()
1278}