inbox.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strings"
  7	"time"
  8
  9	"charm.land/bubbles/v2/key"
 10	"charm.land/bubbles/v2/list"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/floatpane/matcha/config"
 14	"github.com/floatpane/matcha/fetcher"
 15	"github.com/floatpane/matcha/theme"
 16)
 17
 18var (
 19	// In bubbles v2, list.DefaultStyles() takes a boolean for hasDarkBackground
 20	paginationStyle = list.DefaultStyles(true).PaginationStyle.PaddingLeft(4)
 21	inboxHelpStyle  = list.DefaultStyles(true).HelpStyle.PaddingLeft(4).PaddingBottom(1)
 22	tabStyle        = lipgloss.NewStyle().Padding(0, 2)
 23	activeTabStyle  = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
 24	tabBarStyle     = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
 25)
 26
 27var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
 28var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 29var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 30
 31type item struct {
 32	title, desc   string
 33	originalIndex int
 34	uid           uint32
 35	accountID     string
 36	accountEmail  string
 37	date          time.Time
 38	isRead        bool
 39}
 40
 41func (i item) Title() string       { return i.title }
 42func (i item) Description() string { return i.desc }
 43func (i item) FilterValue() string { return i.title + " " + i.desc }
 44
 45type itemDelegate struct{}
 46
 47func (d itemDelegate) Height() int                               { return 1 }
 48func (d itemDelegate) Spacing() int                              { return 0 }
 49func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 50func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 51	i, ok := listItem.(item)
 52	if !ok {
 53		return
 54	}
 55
 56	prefix := fmt.Sprintf("%d. ", index+1)
 57	sender := parseSenderName(i.desc)
 58	statusStyle := unreadEmailStyle
 59	statusIcon := "\uf0e0"
 60	if i.isRead {
 61		statusStyle = readEmailStyle
 62		statusIcon = "\uf2b6"
 63	}
 64	styledIcon := statusStyle.Render(statusIcon)
 65	styledSender := statusStyle.Render(sender)
 66	separator := " · "
 67
 68	// For "ALL" view, show account indicator instead of number
 69	if i.accountEmail != "" {
 70		prefix = fmt.Sprintf("%d. [%s] ", index+1, truncateEmail(i.accountEmail))
 71	}
 72
 73	// Format and right-align date
 74	dateStr := formatRelativeDate(i.date)
 75	styledDate := dateStyle.Render(dateStr)
 76	if i.isRead {
 77		styledDate = readEmailStyle.Render(dateStr)
 78	} else {
 79		styledDate = statusStyle.Render(dateStr)
 80	}
 81	dateWidth := lipgloss.Width(styledDate)
 82
 83	listWidth := m.Width()
 84	isSelected := index == m.Index()
 85	cursorWidth := 0
 86	if isSelected {
 87		cursorWidth = 2 // "> " prefix
 88	}
 89
 90	// Available width for the whole left side (prefix + sender + separator + subject)
 91	maxLeft := listWidth - dateWidth - 2 - cursorWidth // 2 for spacing
 92	if maxLeft < 10 {
 93		maxLeft = 10
 94	}
 95
 96	prefixWidth := lipgloss.Width(prefix)
 97	iconWidth := lipgloss.Width(styledIcon) + 1
 98	senderWidth := lipgloss.Width(styledSender)
 99	sepWidth := len(separator)
100	subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth
101
102	subject := i.title
103	if subjectBudget < 4 {
104		subjectBudget = 4
105	}
106	if lipgloss.Width(subject) > subjectBudget {
107		for lipgloss.Width(subject) > subjectBudget-1 && len(subject) > 0 {
108			subject = subject[:len(subject)-1]
109		}
110		subject += "…"
111	}
112	styledSubject := statusStyle.Render(subject)
113
114	str := prefix + styledIcon + " " + styledSender + separator + styledSubject
115
116	// Pad to push date to the right
117	padding := listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
118	if padding < 1 {
119		padding = 1
120	}
121
122	fn := itemStyle.Render
123	if index == m.Index() {
124		fn = func(s ...string) string {
125			return selectedItemStyle.Render("> " + s[0])
126		}
127	}
128
129	fmt.Fprint(w, fn(str+strings.Repeat(" ", padding)+styledDate))
130}
131
132// formatRelativeDate formats a time as relative if within the last week,
133// otherwise as an absolute date.
134func formatRelativeDate(t time.Time) string {
135	if t.IsZero() {
136		return ""
137	}
138	now := time.Now()
139	d := now.Sub(t)
140
141	switch {
142	case d < time.Minute:
143		return "just now"
144	case d < time.Hour:
145		mins := int(d.Minutes())
146		if mins == 1 {
147			return "1 min ago"
148		}
149		return fmt.Sprintf("%d min ago", mins)
150	case d < 24*time.Hour:
151		hours := int(d.Hours())
152		if hours == 1 {
153			return "1 hour ago"
154		}
155		return fmt.Sprintf("%d hours ago", hours)
156	case d < 7*24*time.Hour:
157		days := int(d.Hours() / 24)
158		if days == 1 {
159			return "1 day ago"
160		}
161		return fmt.Sprintf("%d days ago", days)
162	default:
163		if t.Year() == now.Year() {
164			return t.Format("Jan 02")
165		}
166		return t.Format("Jan 02, 2006")
167	}
168}
169
170// parseSenderName extracts the display name from a "Name <email>" string,
171// falling back to the local part of the email address.
172func parseSenderName(from string) string {
173	if idx := strings.Index(from, " <"); idx > 0 {
174		return strings.TrimSpace(from[:idx])
175	}
176	// No display name — use local part of email
177	if idx := strings.Index(from, "@"); idx > 0 {
178		return from[:idx]
179	}
180	return from
181}
182
183// truncateEmail shortens an email for display
184func truncateEmail(email string) string {
185	parts := strings.Split(email, "@")
186	if len(parts) >= 1 && len(parts[0]) > 8 {
187		return parts[0][:8] + "..."
188	}
189	if len(parts) >= 1 {
190		return parts[0]
191	}
192	return email
193}
194
195// AccountTab represents a tab for an account
196type AccountTab struct {
197	ID    string
198	Label string
199	Email string
200}
201
202type Inbox struct {
203	list               list.Model
204	isFetching         bool
205	isRefreshing       bool
206	emailsCount        int
207	accounts           []config.Account
208	emailsByAccount    map[string][]fetcher.Email
209	allEmails          []fetcher.Email
210	tabs               []AccountTab
211	activeTabIndex     int
212	width              int
213	height             int
214	currentAccountID   string // Empty means "ALL"
215	emailCountByAcct   map[string]int
216	mailbox            MailboxKind
217	folderName         string          // Custom folder name override for title
218	noMoreByAccount    map[string]bool // Per-account: true when pagination returns 0 results
219	extraShortHelpKeys []key.Binding
220	pluginStatus       string // Persistent status text set by plugins
221	pluginKeyBindings  []PluginKeyBinding
222}
223
224func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
225	return NewInboxWithMailbox(emails, accounts, MailboxInbox)
226}
227
228func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
229	return NewInboxWithMailbox(emails, accounts, MailboxSent)
230}
231
232func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
233	return NewInboxWithMailbox(emails, accounts, MailboxTrash)
234}
235
236func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
237	return NewInboxWithMailbox(emails, accounts, MailboxArchive)
238}
239
240func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
241	// Build tabs: empty for single account, "ALL" + accounts for multiple
242	var tabs []AccountTab
243	if len(accounts) <= 1 {
244		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
245	} else {
246		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
247		for _, acc := range accounts {
248			// Use FetchEmail for display, fall back to Email if not set
249			displayEmail := acc.FetchEmail
250			if displayEmail == "" {
251				displayEmail = acc.Email
252			}
253			tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
254		}
255	}
256
257	// Group emails by account
258	emailsByAccount := make(map[string][]fetcher.Email)
259	for _, email := range emails {
260		emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
261	}
262
263	// Track email counts per account
264	emailCountByAcct := make(map[string]int)
265	for accID, accEmails := range emailsByAccount {
266		emailCountByAcct[accID] = len(accEmails)
267	}
268
269	inbox := &Inbox{
270		accounts:         accounts,
271		emailsByAccount:  emailsByAccount,
272		allEmails:        emails,
273		tabs:             tabs,
274		activeTabIndex:   0,
275		currentAccountID: "",
276		emailCountByAcct: emailCountByAcct,
277		mailbox:          mailbox,
278	}
279
280	inbox.updateList()
281	return inbox
282}
283
284// NewInboxSingleAccount creates an inbox for a single account (legacy support)
285func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
286	return NewInbox(emails, nil)
287}
288
289func (m *Inbox) updateList() {
290	// Capture current index to restore later
291	currentIndex := m.list.Index()
292
293	var displayEmails []fetcher.Email
294	var showAccountLabel bool
295
296	if m.currentAccountID == "" {
297		// "ALL" view - show all emails sorted by date
298		displayEmails = m.allEmails
299		showAccountLabel = !(len(m.accounts) <= 1)
300	} else {
301		// Specific account view
302		displayEmails = m.emailsByAccount[m.currentAccountID]
303		showAccountLabel = false
304	}
305
306	m.emailsCount = len(displayEmails)
307
308	items := make([]list.Item, len(displayEmails))
309	for i, email := range displayEmails {
310		accountEmail := ""
311		if showAccountLabel {
312			// Find the account email for display
313			for _, acc := range m.accounts {
314				if acc.ID == email.AccountID {
315					accountEmail = acc.FetchEmail
316					break
317				}
318			}
319		}
320
321		items[i] = item{
322			title:         email.Subject,
323			desc:          email.From,
324			originalIndex: i,
325			uid:           email.UID,
326			accountID:     email.AccountID,
327			accountEmail:  accountEmail,
328			date:          email.Date,
329			isRead:        email.IsRead,
330		}
331	}
332
333	l := list.New(items, itemDelegate{}, 20, 14)
334	l.Title = m.getTitle()
335	l.SetShowStatusBar(true)
336	l.SetFilteringEnabled(true)
337	l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
338	l.Styles.PaginationStyle = paginationStyle
339	l.Styles.HelpStyle = inboxHelpStyle
340	l.SetStatusBarItemName("email", "emails")
341	l.AdditionalShortHelpKeys = func() []key.Binding {
342		bindings := []key.Binding{
343			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
344			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
345			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
346		}
347		if len(m.tabs) > 1 {
348			bindings = append(bindings,
349				key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
350				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
351			)
352		}
353		bindings = append(bindings, m.extraShortHelpKeys...)
354		for _, pk := range m.pluginKeyBindings {
355			bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
356		}
357		return bindings
358	}
359
360	l.KeyMap.Quit.SetEnabled(false)
361
362	// Disable default help to render it manually at the bottom
363	l.SetShowHelp(false)
364
365	if m.width > 0 {
366		l.SetWidth(m.width)
367	}
368	if m.height > 0 {
369		l.SetHeight(m.height / 2)
370	}
371
372	// Restore index
373	// If index is out of bounds (e.g. list shrank), clamp it.
374	if currentIndex >= len(items) {
375		currentIndex = len(items) - 1
376	}
377	if currentIndex < 0 {
378		currentIndex = 0
379	}
380	l.Select(currentIndex)
381
382	m.list = l
383}
384
385func (m *Inbox) getTitle() string {
386	var title string
387	if m.currentAccountID == "" {
388		title = m.getBaseTitle() + " - All Accounts"
389	} else {
390		title = m.getBaseTitle()
391		for _, acc := range m.accounts {
392			if acc.ID == m.currentAccountID {
393				if acc.Name != "" {
394					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
395				} else {
396					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
397				}
398				break
399			}
400		}
401	}
402	if m.isRefreshing {
403		title += " (refreshing...)"
404	}
405	if m.isFetching {
406		title += " (loading more...)"
407	}
408	if m.pluginStatus != "" {
409		title += " (" + m.pluginStatus + ")"
410	}
411	return title
412}
413
414func (m *Inbox) getBaseTitle() string {
415	if m.folderName != "" {
416		return m.folderName
417	}
418	switch m.mailbox {
419	case MailboxSent:
420		return "Sent"
421	case MailboxTrash:
422		return "Trash"
423	case MailboxArchive:
424		return "Archive"
425	default:
426		return "Inbox"
427	}
428}
429
430func (m *Inbox) Init() tea.Cmd {
431	return nil
432}
433
434func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
435	var cmds []tea.Cmd
436
437	switch msg := msg.(type) {
438	case tea.KeyPressMsg:
439		if m.list.FilterState() == list.Filtering {
440			break
441		}
442		switch keypress := msg.String(); keypress {
443		case "left", "h":
444			if len(m.tabs) > 1 {
445				m.activeTabIndex--
446				if m.activeTabIndex < 0 {
447					m.activeTabIndex = len(m.tabs) - 1
448				}
449				m.currentAccountID = m.tabs[m.activeTabIndex].ID
450				m.updateList()
451				return m, nil
452			}
453		case "right", "l":
454			if len(m.tabs) > 1 {
455				m.activeTabIndex++
456				if m.activeTabIndex >= len(m.tabs) {
457					m.activeTabIndex = 0
458				}
459				m.currentAccountID = m.tabs[m.activeTabIndex].ID
460				m.updateList()
461				return m, nil
462			}
463		case "d":
464			selectedItem, ok := m.list.SelectedItem().(item)
465			if ok {
466				return m, func() tea.Msg {
467					return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
468				}
469			}
470		case "a":
471			selectedItem, ok := m.list.SelectedItem().(item)
472			if ok {
473				return m, func() tea.Msg {
474					return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
475				}
476			}
477		case "r":
478			m.isRefreshing = true
479			m.list.Title = m.getTitle()
480			// Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
481			counts := make(map[string]int)
482			for k, v := range m.emailCountByAcct {
483				counts[k] = v
484			}
485			return m, func() tea.Msg {
486				return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
487			}
488		case "enter":
489			selectedItem, ok := m.list.SelectedItem().(item)
490			if ok {
491				idx := selectedItem.originalIndex
492				uid := selectedItem.uid
493				accountID := selectedItem.accountID
494				return m, func() tea.Msg {
495					return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
496				}
497			}
498		}
499	case tea.WindowSizeMsg:
500		m.width = msg.Width
501		m.height = msg.Height
502		m.list.SetWidth(msg.Width)
503		m.list.SetHeight(msg.Height / 2)
504		if m.shouldFetchMore() {
505			return m, tea.Batch(m.fetchMoreCmds()...)
506		}
507		return m, nil
508
509	case FetchingMoreEmailsMsg:
510		m.isFetching = true
511		m.list.Title = m.getTitle()
512		return m, nil
513
514	case EmailsAppendedMsg:
515		if msg.Mailbox != m.mailbox {
516			return m, nil
517		}
518		m.isFetching = false
519		m.list.Title = m.getTitle()
520
521		if len(msg.Emails) == 0 {
522			if m.noMoreByAccount == nil {
523				m.noMoreByAccount = make(map[string]bool)
524			}
525			m.noMoreByAccount[msg.AccountID] = true
526			return m, nil
527		}
528
529		// Add emails to the appropriate account
530		for _, email := range msg.Emails {
531			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
532			m.allEmails = append(m.allEmails, email)
533		}
534		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
535
536		m.updateList()
537		return m, nil
538
539	case RefreshingEmailsMsg:
540		if msg.Mailbox != m.mailbox {
541			return m, nil
542		}
543		m.isRefreshing = true
544		m.list.Title = m.getTitle()
545		return m, nil
546
547	case EmailsRefreshedMsg:
548		if msg.Mailbox != m.mailbox {
549			return m, nil
550		}
551		// Only clear the refreshing indicator. The actual email data is
552		// merged by the main model (preserving paginated emails) and
553		// pushed to us via SetEmails, so we must not overwrite it here.
554		m.isRefreshing = false
555		m.list.Title = m.getTitle()
556		return m, nil
557	}
558
559	var cmd tea.Cmd
560	m.list, cmd = m.list.Update(msg)
561	cmds = append(cmds, cmd)
562
563	if m.shouldFetchMore() {
564		cmds = append(cmds, m.fetchMoreCmds()...)
565	}
566	return m, tea.Batch(cmds...)
567}
568
569func (m *Inbox) shouldFetchMore() bool {
570	if m.isFetching || m.isRefreshing {
571		return false
572	}
573	if m.allAccountsExhausted() {
574		return false
575	}
576	if len(m.list.Items()) == 0 {
577		return false
578	}
579	if m.list.FilterState() == list.Filtering {
580		return false
581	}
582	// Fetch if we've reached the bottom OR if we don't have enough items to fill the view
583	return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
584}
585
586// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
587func (m *Inbox) allAccountsExhausted() bool {
588	if len(m.noMoreByAccount) == 0 {
589		return false
590	}
591	if m.currentAccountID != "" {
592		return m.noMoreByAccount[m.currentAccountID]
593	}
594	// "ALL" view: all accounts must be exhausted
595	for _, acc := range m.accounts {
596		if !m.noMoreByAccount[acc.ID] {
597			return false
598		}
599	}
600	return len(m.accounts) > 0
601}
602
603func (m *Inbox) fetchMoreCmds() []tea.Cmd {
604	var cmds []tea.Cmd
605	limit := uint32(m.list.Height())
606	if limit < 20 {
607		limit = 20
608	}
609
610	if m.currentAccountID == "" {
611		if len(m.accounts) == 0 {
612			return nil
613		}
614		for _, acc := range m.accounts {
615			accountID := acc.ID
616			if m.noMoreByAccount[accountID] {
617				continue
618			}
619			offset := uint32(len(m.emailsByAccount[accountID]))
620			cmds = append(cmds, func(id string, off uint32) tea.Cmd {
621				return func() tea.Msg {
622					return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
623				}
624			}(accountID, offset))
625		}
626		return cmds
627	}
628
629	if m.noMoreByAccount[m.currentAccountID] {
630		return nil
631	}
632	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
633	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
634		return func() tea.Msg {
635			return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
636		}
637	}(m.currentAccountID, offset))
638	return cmds
639}
640
641func (m *Inbox) View() tea.View {
642	var b strings.Builder
643
644	// Render tabs if there are multiple accounts
645	if len(m.tabs) > 1 {
646		var tabViews []string
647		for i, tab := range m.tabs {
648			label := tab.Label
649			if tab.ID == "" {
650				label = "ALL"
651			}
652
653			if i == m.activeTabIndex {
654				tabViews = append(tabViews, activeTabStyle.Render(label))
655			} else {
656				tabViews = append(tabViews, tabStyle.Render(label))
657			}
658		}
659		tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
660		b.WriteString(tabBar)
661		b.WriteString("\n")
662	}
663
664	b.WriteString(m.list.View())
665
666	// Ensure we don't start gap calculation on the same line as the list
667	if !strings.HasSuffix(b.String(), "\n") {
668		b.WriteString("\n")
669	}
670
671	helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
672
673	if m.height > 0 {
674		usedHeight := lipgloss.Height(b.String())
675		helpHeight := lipgloss.Height(helpView)
676
677		gap := m.height - usedHeight - helpHeight
678		if gap > 0 {
679			b.WriteString(strings.Repeat("\n", gap))
680		}
681	} else {
682		b.WriteString("\n")
683	}
684
685	b.WriteString(helpView)
686
687	return tea.NewView(b.String())
688}
689
690// GetCurrentAccountID returns the currently selected account ID
691func (m *Inbox) GetCurrentAccountID() string {
692	return m.currentAccountID
693}
694
695// GetEmailAtIndex returns the email at the given index for the current view
696func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
697	var displayEmails []fetcher.Email
698	if m.currentAccountID == "" {
699		displayEmails = m.allEmails
700	} else {
701		displayEmails = m.emailsByAccount[m.currentAccountID]
702	}
703
704	if index >= 0 && index < len(displayEmails) {
705		return &displayEmails[index]
706	}
707	return nil
708}
709
710func (m *Inbox) GetMailbox() MailboxKind {
711	return m.mailbox
712}
713
714// GetSelectedEmail returns the currently selected email, or nil if none is selected.
715func (m *Inbox) GetSelectedEmail() *fetcher.Email {
716	selectedItem, ok := m.list.SelectedItem().(item)
717	if !ok {
718		return nil
719	}
720	return m.GetEmailAtIndex(selectedItem.originalIndex)
721}
722
723// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
724func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
725	for i := range m.allEmails {
726		if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
727			m.allEmails[i].IsRead = true
728			break
729		}
730	}
731	if emails, ok := m.emailsByAccount[accountID]; ok {
732		for i := range emails {
733			if emails[i].UID == uid {
734				emails[i].IsRead = true
735				break
736			}
737		}
738	}
739	m.updateList()
740}
741
742// RemoveEmail removes an email by UID and account ID
743func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
744	// Remove from account-specific list
745	if emails, ok := m.emailsByAccount[accountID]; ok {
746		var filtered []fetcher.Email
747		for _, e := range emails {
748			if e.UID != uid {
749				filtered = append(filtered, e)
750			}
751		}
752		m.emailsByAccount[accountID] = filtered
753	}
754
755	// Remove from all emails list
756	var filteredAll []fetcher.Email
757	for _, e := range m.allEmails {
758		if !(e.UID == uid && e.AccountID == accountID) {
759			filteredAll = append(filteredAll, e)
760		}
761	}
762	m.allEmails = filteredAll
763
764	m.updateList()
765}
766
767// SetSize sets the width and height of the inbox, then updates the list.
768func (m *Inbox) SetSize(width, height int) {
769	m.width = width
770	m.height = height
771	m.list.SetWidth(width)
772	m.list.SetHeight(height / 2)
773}
774
775// SetFolderName sets a custom folder name for the inbox title.
776func (m *Inbox) SetFolderName(name string) {
777	m.folderName = name
778	m.list.Title = m.getTitle()
779}
780
781// SetPluginStatus sets a persistent status string from plugins, shown in the title.
782func (m *Inbox) SetPluginStatus(status string) {
783	m.pluginStatus = status
784	m.list.Title = m.getTitle()
785}
786
787// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
788func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
789	m.pluginKeyBindings = bindings
790}
791
792// SetEmails updates all emails (used after fetch)
793func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
794	m.accounts = accounts
795	m.allEmails = emails
796	m.noMoreByAccount = make(map[string]bool)
797
798	// Rebuild tabs: empty for single account, "ALL" + accounts for multiple
799	var tabs []AccountTab
800	if len(accounts) <= 1 {
801		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
802	} else {
803		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
804		for _, acc := range accounts {
805			tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
806		}
807	}
808	m.tabs = tabs
809
810	// Re-group emails by account
811	m.emailsByAccount = make(map[string][]fetcher.Email)
812	for _, email := range emails {
813		m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
814	}
815
816	// Update email counts
817	m.emailCountByAcct = make(map[string]int)
818	for accID, accEmails := range m.emailsByAccount {
819		m.emailCountByAcct[accID] = len(accEmails)
820	}
821
822	m.updateList()
823}