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