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