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