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