inbox.go

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