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