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