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// MarkEmailAsUnread marks an email as unread by UID and account ID, updating it in all stores.
1298func (m *Inbox) MarkEmailAsUnread(uid uint32, accountID string) {
1299 for i := range m.allEmails {
1300 if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
1301 m.allEmails[i].IsRead = false
1302 break
1303 }
1304 }
1305 if emails, ok := m.emailsByAccount[accountID]; ok {
1306 for i := range emails {
1307 if emails[i].UID == uid {
1308 emails[i].IsRead = false
1309 break
1310 }
1311 }
1312 }
1313 m.updateList()
1314}
1315
1316// updateVisualSelection updates the selected UIDs based on anchor and current index
1317func (m *Inbox) updateVisualSelection() {
1318 if !m.visualMode {
1319 return
1320 }
1321
1322 currentIdx := m.list.Index()
1323 start := m.visualAnchor
1324 end := currentIdx
1325
1326 if start > end {
1327 start, end = end, start
1328 }
1329
1330 // Clear and rebuild selection
1331 m.selectedUIDs = make(map[uint32]string)
1332 m.selectionOrder = []uint32{}
1333
1334 items := m.list.Items()
1335 firstAccountID := ""
1336 for i := start; i <= end && i < len(items); i++ {
1337 if itm, ok := items[i].(item); ok {
1338 if itm.uid == 0 {
1339 continue
1340 }
1341 // Ensure all selected emails are from the same account (prevent cross-account batch ops)
1342 if firstAccountID == "" {
1343 firstAccountID = itm.accountID
1344 }
1345 if itm.accountID != firstAccountID {
1346 // Don't add emails from different accounts
1347 continue
1348 }
1349
1350 if _, exists := m.selectedUIDs[itm.uid]; !exists {
1351 m.selectedUIDs[itm.uid] = itm.accountID
1352 m.selectionOrder = append(m.selectionOrder, itm.uid)
1353 }
1354 }
1355 }
1356
1357 m.updateListTitle()
1358}
1359
1360// updateListTitle updates the title to show selection count when in visual mode
1361func (m *Inbox) updateListTitle() {
1362 if m.visualMode && len(m.selectedUIDs) > 0 {
1363 baseTitle := m.getBaseTitle()
1364 m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
1365 } else {
1366 m.list.Title = m.getTitle()
1367 }
1368}
1369
1370// RemoveEmails removes multiple emails by UID and account ID (batch operation)
1371func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
1372 uidSet := make(map[uint32]bool)
1373 for _, uid := range uids {
1374 uidSet[uid] = true
1375 }
1376
1377 // Remove from account-specific list
1378 if emails, ok := m.emailsByAccount[accountID]; ok {
1379 var filtered []fetcher.Email
1380 for _, e := range emails {
1381 if !uidSet[e.UID] {
1382 filtered = append(filtered, e)
1383 }
1384 }
1385 m.emailsByAccount[accountID] = filtered
1386 }
1387
1388 // Remove from all emails list
1389 var filteredAll []fetcher.Email
1390 for _, e := range m.allEmails {
1391 if !(uidSet[e.UID] && e.AccountID == accountID) {
1392 filteredAll = append(filteredAll, e)
1393 }
1394 }
1395 m.allEmails = filteredAll
1396
1397 m.updateList()
1398}
1399
1400// RemoveEmail removes an email by UID and account ID
1401func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
1402 // Remove from account-specific list
1403 if emails, ok := m.emailsByAccount[accountID]; ok {
1404 var filtered []fetcher.Email
1405 for _, e := range emails {
1406 if e.UID != uid {
1407 filtered = append(filtered, e)
1408 }
1409 }
1410 m.emailsByAccount[accountID] = filtered
1411 }
1412
1413 // Remove from all emails list
1414 var filteredAll []fetcher.Email
1415 for _, e := range m.allEmails {
1416 if !(e.UID == uid && e.AccountID == accountID) {
1417 filteredAll = append(filteredAll, e)
1418 }
1419 }
1420 m.allEmails = filteredAll
1421
1422 m.updateList()
1423}
1424
1425// SetSize sets the width and height of the inbox, then updates the list.
1426func (m *Inbox) SetSize(width, height int) {
1427 m.width = width
1428 m.height = height
1429 m.list.SetWidth(width)
1430 m.list.SetHeight(height / 2)
1431}
1432
1433// SetFolderName sets a custom folder name for the inbox title.
1434func (m *Inbox) SetFolderName(name string) {
1435 m.folderName = name
1436 m.updateList()
1437}
1438
1439// SetPluginStatus sets a persistent status string from plugins, shown in the title.
1440func (m *Inbox) SetPluginStatus(status string) {
1441 m.pluginStatus = status
1442 m.list.Title = m.getTitle()
1443}
1444
1445// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1446func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1447 m.pluginKeyBindings = bindings
1448}
1449
1450// SetEmails updates all emails (used after fetch)
1451func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
1452 m.accounts = accounts
1453 m.allEmails = dedupeEmailsForAccounts(emails, accounts)
1454 m.noMoreByAccount = make(map[string]bool)
1455
1456 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
1457 var tabs []AccountTab
1458 if len(accounts) <= 1 {
1459 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
1460 } else {
1461 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
1462 for _, acc := range accounts {
1463 displayEmail := accountDisplayEmail(acc)
1464 tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
1465 }
1466 }
1467 m.tabs = tabs
1468
1469 // Re-group emails by account
1470 m.emailsByAccount = make(map[string][]fetcher.Email)
1471 for _, email := range emails {
1472 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
1473 }
1474
1475 // Update email counts
1476 m.emailCountByAcct = make(map[string]int)
1477 for accID, accEmails := range m.emailsByAccount {
1478 m.emailCountByAcct[accID] = len(accEmails)
1479 }
1480
1481 m.updateList()
1482}
1483
1484func inboxEmailID(email fetcher.Email) string {
1485 return fmt.Sprintf("%s:%d", email.AccountID, email.UID)
1486}
1487
1488func threadItemKey(node *threading.ThreadNode) string {
1489 if node == nil {
1490 return ""
1491 }
1492 if node.EmailID != "" {
1493 return node.EmailID
1494 }
1495 for _, child := range node.Children {
1496 if key := threadItemKey(child); key != "" {
1497 return key
1498 }
1499 }
1500 return ""
1501}
1502
1503func firstEmailNode(node *threading.ThreadNode) *threading.ThreadNode {
1504 if node == nil {
1505 return nil
1506 }
1507 if node.EmailID != "" {
1508 return node
1509 }
1510 for _, child := range node.Children {
1511 if first := firstEmailNode(child); first != nil {
1512 return first
1513 }
1514 }
1515 return nil
1516}
1517
1518func latestEmailNode(node *threading.ThreadNode) *threading.ThreadNode {
1519 if node == nil {
1520 return nil
1521 }
1522 var latest *threading.ThreadNode
1523 if node.EmailID != "" {
1524 latest = node
1525 }
1526 for _, child := range node.Children {
1527 candidate := latestEmailNode(child)
1528 if candidate == nil {
1529 continue
1530 }
1531 if latest == nil || candidate.Date.After(latest.Date) ||
1532 (candidate.Date.Equal(latest.Date) && candidate.EmailID < latest.EmailID) {
1533 latest = candidate
1534 }
1535 }
1536 return latest
1537}
1538
1539func threadRead(emails []fetcher.Email, emailIndex map[string]int, node *threading.ThreadNode) bool {
1540 if node == nil {
1541 return true
1542 }
1543 read := true
1544 if node.EmailID != "" {
1545 if idx, ok := emailIndex[node.EmailID]; ok && !emails[idx].IsRead {
1546 read = false
1547 }
1548 }
1549 for _, child := range node.Children {
1550 if !threadRead(emails, emailIndex, child) {
1551 read = false
1552 }
1553 }
1554 return read
1555}
1556
1557func firstNonEmpty(values ...string) string {
1558 for _, value := range values {
1559 if value != "" {
1560 return value
1561 }
1562 }
1563 return ""
1564}