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