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}
222
223func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
224	return NewInboxWithMailbox(emails, accounts, MailboxInbox)
225}
226
227func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
228	return NewInboxWithMailbox(emails, accounts, MailboxSent)
229}
230
231func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
232	return NewInboxWithMailbox(emails, accounts, MailboxTrash)
233}
234
235func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
236	return NewInboxWithMailbox(emails, accounts, MailboxArchive)
237}
238
239func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
240	// Build tabs: empty for single account, "ALL" + accounts for multiple
241	var tabs []AccountTab
242	if len(accounts) <= 1 {
243		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
244	} else {
245		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
246		for _, acc := range accounts {
247			// Use FetchEmail for display, fall back to Email if not set
248			displayEmail := acc.FetchEmail
249			if displayEmail == "" {
250				displayEmail = acc.Email
251			}
252			tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
253		}
254	}
255
256	// Group emails by account
257	emailsByAccount := make(map[string][]fetcher.Email)
258	for _, email := range emails {
259		emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
260	}
261
262	// Track email counts per account
263	emailCountByAcct := make(map[string]int)
264	for accID, accEmails := range emailsByAccount {
265		emailCountByAcct[accID] = len(accEmails)
266	}
267
268	inbox := &Inbox{
269		accounts:         accounts,
270		emailsByAccount:  emailsByAccount,
271		allEmails:        emails,
272		tabs:             tabs,
273		activeTabIndex:   0,
274		currentAccountID: "",
275		emailCountByAcct: emailCountByAcct,
276		mailbox:          mailbox,
277	}
278
279	inbox.updateList()
280	return inbox
281}
282
283// NewInboxSingleAccount creates an inbox for a single account (legacy support)
284func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
285	return NewInbox(emails, nil)
286}
287
288func (m *Inbox) updateList() {
289	// Capture current index to restore later
290	currentIndex := m.list.Index()
291
292	var displayEmails []fetcher.Email
293	var showAccountLabel bool
294
295	if m.currentAccountID == "" {
296		// "ALL" view - show all emails sorted by date
297		displayEmails = m.allEmails
298		showAccountLabel = !(len(m.accounts) <= 1)
299	} else {
300		// Specific account view
301		displayEmails = m.emailsByAccount[m.currentAccountID]
302		showAccountLabel = false
303	}
304
305	m.emailsCount = len(displayEmails)
306
307	items := make([]list.Item, len(displayEmails))
308	for i, email := range displayEmails {
309		accountEmail := ""
310		if showAccountLabel {
311			// Find the account email for display
312			for _, acc := range m.accounts {
313				if acc.ID == email.AccountID {
314					accountEmail = acc.FetchEmail
315					break
316				}
317			}
318		}
319
320		items[i] = item{
321			title:         email.Subject,
322			desc:          email.From,
323			originalIndex: i,
324			uid:           email.UID,
325			accountID:     email.AccountID,
326			accountEmail:  accountEmail,
327			date:          email.Date,
328			isRead:        email.IsRead,
329		}
330	}
331
332	l := list.New(items, itemDelegate{}, 20, 14)
333	l.Title = m.getTitle()
334	l.SetShowStatusBar(true)
335	l.SetFilteringEnabled(true)
336	l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
337	l.Styles.PaginationStyle = paginationStyle
338	l.Styles.HelpStyle = inboxHelpStyle
339	l.SetStatusBarItemName("email", "emails")
340	l.AdditionalShortHelpKeys = func() []key.Binding {
341		bindings := []key.Binding{
342			key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
343			key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
344			key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
345		}
346		if len(m.tabs) > 1 {
347			bindings = append(bindings,
348				key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
349				key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
350			)
351		}
352		bindings = append(bindings, m.extraShortHelpKeys...)
353		return bindings
354	}
355
356	l.KeyMap.Quit.SetEnabled(false)
357
358	// Disable default help to render it manually at the bottom
359	l.SetShowHelp(false)
360
361	if m.width > 0 {
362		l.SetWidth(m.width)
363	}
364	if m.height > 0 {
365		l.SetHeight(m.height / 2)
366	}
367
368	// Restore index
369	// If index is out of bounds (e.g. list shrank), clamp it.
370	if currentIndex >= len(items) {
371		currentIndex = len(items) - 1
372	}
373	if currentIndex < 0 {
374		currentIndex = 0
375	}
376	l.Select(currentIndex)
377
378	m.list = l
379}
380
381func (m *Inbox) getTitle() string {
382	var title string
383	if m.currentAccountID == "" {
384		title = m.getBaseTitle() + " - All Accounts"
385	} else {
386		title = m.getBaseTitle()
387		for _, acc := range m.accounts {
388			if acc.ID == m.currentAccountID {
389				if acc.Name != "" {
390					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
391				} else {
392					title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
393				}
394				break
395			}
396		}
397	}
398	if m.isRefreshing {
399		title += " (refreshing...)"
400	}
401	if m.isFetching {
402		title += " (loading more...)"
403	}
404	if m.pluginStatus != "" {
405		title += " (" + m.pluginStatus + ")"
406	}
407	return title
408}
409
410func (m *Inbox) getBaseTitle() string {
411	if m.folderName != "" {
412		return m.folderName
413	}
414	switch m.mailbox {
415	case MailboxSent:
416		return "Sent"
417	case MailboxTrash:
418		return "Trash"
419	case MailboxArchive:
420		return "Archive"
421	default:
422		return "Inbox"
423	}
424}
425
426func (m *Inbox) Init() tea.Cmd {
427	return nil
428}
429
430func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
431	var cmds []tea.Cmd
432
433	switch msg := msg.(type) {
434	case tea.KeyPressMsg:
435		if m.list.FilterState() == list.Filtering {
436			break
437		}
438		switch keypress := msg.String(); keypress {
439		case "left", "h":
440			if len(m.tabs) > 1 {
441				m.activeTabIndex--
442				if m.activeTabIndex < 0 {
443					m.activeTabIndex = len(m.tabs) - 1
444				}
445				m.currentAccountID = m.tabs[m.activeTabIndex].ID
446				m.updateList()
447				return m, nil
448			}
449		case "right", "l":
450			if len(m.tabs) > 1 {
451				m.activeTabIndex++
452				if m.activeTabIndex >= len(m.tabs) {
453					m.activeTabIndex = 0
454				}
455				m.currentAccountID = m.tabs[m.activeTabIndex].ID
456				m.updateList()
457				return m, nil
458			}
459		case "d":
460			selectedItem, ok := m.list.SelectedItem().(item)
461			if ok {
462				return m, func() tea.Msg {
463					return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
464				}
465			}
466		case "a":
467			selectedItem, ok := m.list.SelectedItem().(item)
468			if ok {
469				return m, func() tea.Msg {
470					return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
471				}
472			}
473		case "r":
474			m.isRefreshing = true
475			m.list.Title = m.getTitle()
476			// Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
477			counts := make(map[string]int)
478			for k, v := range m.emailCountByAcct {
479				counts[k] = v
480			}
481			return m, func() tea.Msg {
482				return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
483			}
484		case "enter":
485			selectedItem, ok := m.list.SelectedItem().(item)
486			if ok {
487				idx := selectedItem.originalIndex
488				uid := selectedItem.uid
489				accountID := selectedItem.accountID
490				return m, func() tea.Msg {
491					return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
492				}
493			}
494		}
495	case tea.WindowSizeMsg:
496		m.width = msg.Width
497		m.height = msg.Height
498		m.list.SetWidth(msg.Width)
499		m.list.SetHeight(msg.Height / 2)
500		if m.shouldFetchMore() {
501			return m, tea.Batch(m.fetchMoreCmds()...)
502		}
503		return m, nil
504
505	case FetchingMoreEmailsMsg:
506		m.isFetching = true
507		m.list.Title = m.getTitle()
508		return m, nil
509
510	case EmailsAppendedMsg:
511		if msg.Mailbox != m.mailbox {
512			return m, nil
513		}
514		m.isFetching = false
515		m.list.Title = m.getTitle()
516
517		if len(msg.Emails) == 0 {
518			if m.noMoreByAccount == nil {
519				m.noMoreByAccount = make(map[string]bool)
520			}
521			m.noMoreByAccount[msg.AccountID] = true
522			return m, nil
523		}
524
525		// Add emails to the appropriate account
526		for _, email := range msg.Emails {
527			m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
528			m.allEmails = append(m.allEmails, email)
529		}
530		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
531
532		m.updateList()
533		return m, nil
534
535	case RefreshingEmailsMsg:
536		if msg.Mailbox != m.mailbox {
537			return m, nil
538		}
539		m.isRefreshing = true
540		m.list.Title = m.getTitle()
541		return m, nil
542
543	case EmailsRefreshedMsg:
544		if msg.Mailbox != m.mailbox {
545			return m, nil
546		}
547		// Only clear the refreshing indicator. The actual email data is
548		// merged by the main model (preserving paginated emails) and
549		// pushed to us via SetEmails, so we must not overwrite it here.
550		m.isRefreshing = false
551		m.list.Title = m.getTitle()
552		return m, nil
553	}
554
555	var cmd tea.Cmd
556	m.list, cmd = m.list.Update(msg)
557	cmds = append(cmds, cmd)
558
559	if m.shouldFetchMore() {
560		cmds = append(cmds, m.fetchMoreCmds()...)
561	}
562	return m, tea.Batch(cmds...)
563}
564
565func (m *Inbox) shouldFetchMore() bool {
566	if m.isFetching || m.isRefreshing {
567		return false
568	}
569	if m.allAccountsExhausted() {
570		return false
571	}
572	if len(m.list.Items()) == 0 {
573		return false
574	}
575	if m.list.FilterState() == list.Filtering {
576		return false
577	}
578	// Fetch if we've reached the bottom OR if we don't have enough items to fill the view
579	return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
580}
581
582// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
583func (m *Inbox) allAccountsExhausted() bool {
584	if len(m.noMoreByAccount) == 0 {
585		return false
586	}
587	if m.currentAccountID != "" {
588		return m.noMoreByAccount[m.currentAccountID]
589	}
590	// "ALL" view: all accounts must be exhausted
591	for _, acc := range m.accounts {
592		if !m.noMoreByAccount[acc.ID] {
593			return false
594		}
595	}
596	return len(m.accounts) > 0
597}
598
599func (m *Inbox) fetchMoreCmds() []tea.Cmd {
600	var cmds []tea.Cmd
601	limit := uint32(m.list.Height())
602	if limit < 20 {
603		limit = 20
604	}
605
606	if m.currentAccountID == "" {
607		if len(m.accounts) == 0 {
608			return nil
609		}
610		for _, acc := range m.accounts {
611			accountID := acc.ID
612			if m.noMoreByAccount[accountID] {
613				continue
614			}
615			offset := uint32(len(m.emailsByAccount[accountID]))
616			cmds = append(cmds, func(id string, off uint32) tea.Cmd {
617				return func() tea.Msg {
618					return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
619				}
620			}(accountID, offset))
621		}
622		return cmds
623	}
624
625	if m.noMoreByAccount[m.currentAccountID] {
626		return nil
627	}
628	offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
629	cmds = append(cmds, func(id string, off uint32) tea.Cmd {
630		return func() tea.Msg {
631			return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
632		}
633	}(m.currentAccountID, offset))
634	return cmds
635}
636
637func (m *Inbox) View() tea.View {
638	var b strings.Builder
639
640	// Render tabs if there are multiple accounts
641	if len(m.tabs) > 1 {
642		var tabViews []string
643		for i, tab := range m.tabs {
644			label := tab.Label
645			if tab.ID == "" {
646				label = "ALL"
647			}
648
649			if i == m.activeTabIndex {
650				tabViews = append(tabViews, activeTabStyle.Render(label))
651			} else {
652				tabViews = append(tabViews, tabStyle.Render(label))
653			}
654		}
655		tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
656		b.WriteString(tabBar)
657		b.WriteString("\n")
658	}
659
660	b.WriteString(m.list.View())
661
662	// Ensure we don't start gap calculation on the same line as the list
663	if !strings.HasSuffix(b.String(), "\n") {
664		b.WriteString("\n")
665	}
666
667	helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
668
669	if m.height > 0 {
670		usedHeight := lipgloss.Height(b.String())
671		helpHeight := lipgloss.Height(helpView)
672
673		gap := m.height - usedHeight - helpHeight
674		if gap > 0 {
675			b.WriteString(strings.Repeat("\n", gap))
676		}
677	} else {
678		b.WriteString("\n")
679	}
680
681	b.WriteString(helpView)
682
683	return tea.NewView(b.String())
684}
685
686// GetCurrentAccountID returns the currently selected account ID
687func (m *Inbox) GetCurrentAccountID() string {
688	return m.currentAccountID
689}
690
691// GetEmailAtIndex returns the email at the given index for the current view
692func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
693	var displayEmails []fetcher.Email
694	if m.currentAccountID == "" {
695		displayEmails = m.allEmails
696	} else {
697		displayEmails = m.emailsByAccount[m.currentAccountID]
698	}
699
700	if index >= 0 && index < len(displayEmails) {
701		return &displayEmails[index]
702	}
703	return nil
704}
705
706func (m *Inbox) GetMailbox() MailboxKind {
707	return m.mailbox
708}
709
710// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
711func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
712	for i := range m.allEmails {
713		if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
714			m.allEmails[i].IsRead = true
715			break
716		}
717	}
718	if emails, ok := m.emailsByAccount[accountID]; ok {
719		for i := range emails {
720			if emails[i].UID == uid {
721				emails[i].IsRead = true
722				break
723			}
724		}
725	}
726	m.updateList()
727}
728
729// RemoveEmail removes an email by UID and account ID
730func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
731	// Remove from account-specific list
732	if emails, ok := m.emailsByAccount[accountID]; ok {
733		var filtered []fetcher.Email
734		for _, e := range emails {
735			if e.UID != uid {
736				filtered = append(filtered, e)
737			}
738		}
739		m.emailsByAccount[accountID] = filtered
740	}
741
742	// Remove from all emails list
743	var filteredAll []fetcher.Email
744	for _, e := range m.allEmails {
745		if !(e.UID == uid && e.AccountID == accountID) {
746			filteredAll = append(filteredAll, e)
747		}
748	}
749	m.allEmails = filteredAll
750
751	m.updateList()
752}
753
754// SetSize sets the width and height of the inbox, then updates the list.
755func (m *Inbox) SetSize(width, height int) {
756	m.width = width
757	m.height = height
758	m.list.SetWidth(width)
759	m.list.SetHeight(height / 2)
760}
761
762// SetFolderName sets a custom folder name for the inbox title.
763func (m *Inbox) SetFolderName(name string) {
764	m.folderName = name
765	m.list.Title = m.getTitle()
766}
767
768// SetPluginStatus sets a persistent status string from plugins, shown in the title.
769func (m *Inbox) SetPluginStatus(status string) {
770	m.pluginStatus = status
771	m.list.Title = m.getTitle()
772}
773
774// SetEmails updates all emails (used after fetch)
775func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
776	m.accounts = accounts
777	m.allEmails = emails
778	m.noMoreByAccount = make(map[string]bool)
779
780	// Rebuild tabs: empty for single account, "ALL" + accounts for multiple
781	var tabs []AccountTab
782	if len(accounts) <= 1 {
783		tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
784	} else {
785		tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
786		for _, acc := range accounts {
787			tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
788		}
789	}
790	m.tabs = tabs
791
792	// Re-group emails by account
793	m.emailsByAccount = make(map[string][]fetcher.Email)
794	for _, email := range emails {
795		m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
796	}
797
798	// Update email counts
799	m.emailCountByAcct = make(map[string]int)
800	for accID, accEmails := range m.emailsByAccount {
801		m.emailCountByAcct[accID] = len(accEmails)
802	}
803
804	m.updateList()
805}