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