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