1package tui
2
3import (
4 "fmt"
5 "io"
6 "strings"
7 "time"
8
9 "charm.land/bubbles/v2/key"
10 "charm.land/bubbles/v2/list"
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13 "github.com/floatpane/matcha/config"
14 "github.com/floatpane/matcha/fetcher"
15 "github.com/floatpane/matcha/theme"
16)
17
18var (
19 // In bubbles v2, list.DefaultStyles() takes a boolean for hasDarkBackground
20 paginationStyle = list.DefaultStyles(true).PaginationStyle.PaddingLeft(4)
21 inboxHelpStyle = list.DefaultStyles(true).HelpStyle.PaddingLeft(4).PaddingBottom(1)
22 tabStyle = lipgloss.NewStyle().Padding(0, 2)
23 activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
24 tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
25)
26
27var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
28var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
29var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
30var visualSelectedStyle lipgloss.Style
31
32type item struct {
33 title, desc string
34 originalIndex int
35 uid uint32
36 accountID string
37 accountEmail string
38 date time.Time
39 isRead bool
40}
41
42func (i item) Title() string { return i.title }
43func (i item) Description() string { return i.desc }
44func (i item) FilterValue() string { return i.title + " " + i.desc }
45
46type itemDelegate struct {
47 inbox *Inbox
48}
49
50func (d itemDelegate) Height() int { return 1 }
51func (d itemDelegate) Spacing() int { return 0 }
52func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
53func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
54 i, ok := listItem.(item)
55 if !ok {
56 return
57 }
58
59 prefix := fmt.Sprintf("%d. ", index+1)
60 sender := parseSenderName(i.desc)
61 statusStyle := unreadEmailStyle
62 statusIcon := "\uf0e0"
63 if i.isRead {
64 statusStyle = readEmailStyle
65 statusIcon = "\uf2b6"
66 }
67 styledIcon := statusStyle.Render(statusIcon)
68 styledSender := statusStyle.Render(sender)
69 separator := " · "
70
71 // For "ALL" view, show account indicator instead of number
72 if i.accountEmail != "" {
73 prefix = fmt.Sprintf("%d. [%s] ", index+1, truncateEmail(i.accountEmail))
74 }
75
76 // Format and right-align date
77 dateStr := formatRelativeDate(i.date)
78 styledDate := dateStyle.Render(dateStr)
79 if i.isRead {
80 styledDate = readEmailStyle.Render(dateStr)
81 } else {
82 styledDate = statusStyle.Render(dateStr)
83 }
84 dateWidth := lipgloss.Width(styledDate)
85
86 listWidth := m.Width()
87 isSelected := index == m.Index()
88 cursorWidth := 0
89 if isSelected {
90 cursorWidth = 2 // "> " prefix
91 }
92
93 // Available width for the whole left side (prefix + sender + separator + subject)
94 maxLeft := listWidth - dateWidth - 2 - cursorWidth // 2 for spacing
95 if maxLeft < 10 {
96 maxLeft = 10
97 }
98
99 prefixWidth := lipgloss.Width(prefix)
100 iconWidth := lipgloss.Width(styledIcon) + 1
101 senderWidth := lipgloss.Width(styledSender)
102 sepWidth := len(separator)
103 subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth
104
105 subject := i.title
106 if subjectBudget < 4 {
107 subjectBudget = 4
108 }
109 if lipgloss.Width(subject) > subjectBudget {
110 for lipgloss.Width(subject) > subjectBudget-1 && len(subject) > 0 {
111 subject = subject[:len(subject)-1]
112 }
113 subject += "…"
114 }
115 styledSubject := statusStyle.Render(subject)
116
117 str := prefix + styledIcon + " " + styledSender + separator + styledSubject
118
119 // Pad to push date to the right
120 padding := listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
121 if padding < 1 {
122 padding = 1
123 }
124
125 // Check if this item is in visual selection
126 inVisualSelection := false
127 if d.inbox != nil && d.inbox.visualMode {
128 _, inVisualSelection = d.inbox.selectedUIDs[i.uid]
129 }
130
131 fn := itemStyle.Render
132 if inVisualSelection && !isSelected {
133 // Item is in visual selection but not the cursor
134 fn = func(s ...string) string {
135 return visualSelectedStyle.Render("* " + s[0])
136 }
137 cursorWidth = 2 // "* " prefix
138 padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
139 if padding < 1 {
140 padding = 1
141 }
142 } else if isSelected {
143 // Cursor position (may also be in selection)
144 prefix := "> "
145 if inVisualSelection {
146 prefix = ">*"
147 }
148 fn = func(s ...string) string {
149 return selectedItemStyle.Render(prefix + s[0])
150 }
151 cursorWidth = len(prefix)
152 padding = listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
153 if padding < 1 {
154 padding = 1
155 }
156 }
157
158 fmt.Fprint(w, fn(str+strings.Repeat(" ", padding)+styledDate))
159}
160
161// formatRelativeDate formats a time as relative if within the last week,
162// otherwise as an absolute date.
163func formatRelativeDate(t time.Time) string {
164 if t.IsZero() {
165 return ""
166 }
167 now := time.Now()
168 d := now.Sub(t)
169
170 switch {
171 case d < time.Minute:
172 return "just now"
173 case d < time.Hour:
174 mins := int(d.Minutes())
175 if mins == 1 {
176 return "1 min ago"
177 }
178 return fmt.Sprintf("%d min ago", mins)
179 case d < 24*time.Hour:
180 hours := int(d.Hours())
181 if hours == 1 {
182 return "1 hour ago"
183 }
184 return fmt.Sprintf("%d hours ago", hours)
185 case d < 7*24*time.Hour:
186 days := int(d.Hours() / 24)
187 if days == 1 {
188 return "1 day ago"
189 }
190 return fmt.Sprintf("%d days ago", days)
191 default:
192 if t.Year() == now.Year() {
193 return t.Format("Jan 02")
194 }
195 return t.Format("Jan 02, 2006")
196 }
197}
198
199// parseSenderName extracts the display name from a "Name <email>" string,
200// falling back to the local part of the email address.
201func parseSenderName(from string) string {
202 if idx := strings.Index(from, " <"); idx > 0 {
203 return strings.TrimSpace(from[:idx])
204 }
205 // No display name — use local part of email
206 if idx := strings.Index(from, "@"); idx > 0 {
207 return from[:idx]
208 }
209 return from
210}
211
212// truncateEmail shortens an email for display
213func truncateEmail(email string) string {
214 maxLength := 18
215
216 if len(email) <= maxLength {
217 return email
218 }
219
220 parts := strings.SplitN(email, "@", 2)
221 if len(parts) != 2 {
222 if len(email) > maxLength {
223 return email[:maxLength-3] + "..."
224 }
225 return email
226 }
227
228 local := parts[0]
229 domain := parts[1]
230
231 // Keep full domain visible (e.g. ...@gmail.com) and truncate local part first.
232 if len(local) > 8 {
233 return local[:8] + "...@" + domain
234 }
235
236 return local + "@" + domain
237}
238
239// AccountTab represents a tab for an account
240type AccountTab struct {
241 ID string
242 Label string
243 Email string
244}
245
246type Inbox struct {
247 list list.Model
248 isFetching bool
249 isRefreshing bool
250 emailsCount int
251 accounts []config.Account
252 emailsByAccount map[string][]fetcher.Email
253 allEmails []fetcher.Email
254 tabs []AccountTab
255 activeTabIndex int
256 width int
257 height int
258 currentAccountID string // Empty means "ALL"
259 emailCountByAcct map[string]int
260 mailbox MailboxKind
261 folderName string // Custom folder name override for title
262 noMoreByAccount map[string]bool // Per-account: true when pagination returns 0 results
263 extraShortHelpKeys []key.Binding
264 pluginStatus string // Persistent status text set by plugins
265 pluginKeyBindings []PluginKeyBinding
266
267 // Visual mode state (Vim-style multi-select)
268 visualMode bool // Whether visual mode is active
269 visualAnchor int // Index where visual selection started
270 selectedUIDs map[uint32]string // map[uid]accountID for selected emails
271 selectionOrder []uint32 // Ordered list of UIDs for display
272}
273
274func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
275 return NewInboxWithMailbox(emails, accounts, MailboxInbox)
276}
277
278func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
279 return NewInboxWithMailbox(emails, accounts, MailboxSent)
280}
281
282func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
283 return NewInboxWithMailbox(emails, accounts, MailboxTrash)
284}
285
286func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
287 return NewInboxWithMailbox(emails, accounts, MailboxArchive)
288}
289
290func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
291 // Build tabs: empty for single account, "ALL" + accounts for multiple
292 var tabs []AccountTab
293 if len(accounts) <= 1 {
294 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
295 } else {
296 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
297 for _, acc := range accounts {
298 // Use FetchEmail for display, fall back to Email if not set
299 displayEmail := acc.FetchEmail
300 if displayEmail == "" {
301 displayEmail = acc.Email
302 }
303 tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
304 }
305 }
306
307 // Group emails by account
308 emailsByAccount := make(map[string][]fetcher.Email)
309 for _, email := range emails {
310 emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
311 }
312
313 // Track email counts per account
314 emailCountByAcct := make(map[string]int)
315 for accID, accEmails := range emailsByAccount {
316 emailCountByAcct[accID] = len(accEmails)
317 }
318
319 inbox := &Inbox{
320 accounts: accounts,
321 emailsByAccount: emailsByAccount,
322 allEmails: emails,
323 tabs: tabs,
324 activeTabIndex: 0,
325 currentAccountID: "",
326 emailCountByAcct: emailCountByAcct,
327 mailbox: mailbox,
328 visualMode: false,
329 selectedUIDs: make(map[uint32]string),
330 selectionOrder: []uint32{},
331 }
332
333 inbox.updateList()
334 return inbox
335}
336
337// NewInboxSingleAccount creates an inbox for a single account (legacy support)
338func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
339 return NewInbox(emails, nil)
340}
341
342func (m *Inbox) updateList() {
343 // Capture current index to restore later
344 currentIndex := m.list.Index()
345
346 var displayEmails []fetcher.Email
347 var showAccountLabel bool
348
349 if m.currentAccountID == "" {
350 // "ALL" view - show all emails sorted by date
351 displayEmails = m.allEmails
352 showAccountLabel = !(len(m.accounts) <= 1)
353 } else {
354 // Specific account view
355 displayEmails = m.emailsByAccount[m.currentAccountID]
356 showAccountLabel = false
357 }
358
359 m.emailsCount = len(displayEmails)
360
361 items := make([]list.Item, len(displayEmails))
362 for i, email := range displayEmails {
363 accountEmail := ""
364 if showAccountLabel {
365 // Find the account email for display
366 for _, acc := range m.accounts {
367 if acc.ID == email.AccountID {
368 accountEmail = acc.FetchEmail
369 break
370 }
371 }
372 }
373
374 items[i] = item{
375 title: email.Subject,
376 desc: email.From,
377 originalIndex: i,
378 uid: email.UID,
379 accountID: email.AccountID,
380 accountEmail: accountEmail,
381 date: email.Date,
382 isRead: email.IsRead,
383 }
384 }
385
386 l := list.New(items, itemDelegate{inbox: m}, 20, 14)
387 l.Title = m.getTitle()
388 l.SetShowStatusBar(true)
389 l.SetFilteringEnabled(true)
390 l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
391 l.Styles.PaginationStyle = paginationStyle
392 l.Styles.HelpStyle = inboxHelpStyle
393 l.SetStatusBarItemName("email", "emails")
394 l.AdditionalShortHelpKeys = func() []key.Binding {
395 bindings := []key.Binding{
396 key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual mode")),
397 key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
398 key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
399 key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
400 }
401 if len(m.tabs) > 1 {
402 bindings = append(bindings,
403 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
404 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
405 )
406 }
407 bindings = append(bindings, m.extraShortHelpKeys...)
408 for _, pk := range m.pluginKeyBindings {
409 bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
410 }
411 return bindings
412 }
413
414 l.KeyMap.Quit.SetEnabled(false)
415
416 // Disable default help to render it manually at the bottom
417 l.SetShowHelp(false)
418
419 if m.width > 0 {
420 l.SetWidth(m.width)
421 }
422 if m.height > 0 {
423 l.SetHeight(m.height / 2)
424 }
425
426 // Restore index
427 // If index is out of bounds (e.g. list shrank), clamp it.
428 if currentIndex >= len(items) {
429 currentIndex = len(items) - 1
430 }
431 if currentIndex < 0 {
432 currentIndex = 0
433 }
434 l.Select(currentIndex)
435
436 m.list = l
437}
438
439func (m *Inbox) getTitle() string {
440 var title string
441 if m.currentAccountID == "" {
442 title = m.getBaseTitle() + " - All Accounts"
443 } else {
444 title = m.getBaseTitle()
445 for _, acc := range m.accounts {
446 if acc.ID == m.currentAccountID {
447 if acc.Name != "" {
448 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
449 } else {
450 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
451 }
452 break
453 }
454 }
455 }
456 if m.isRefreshing {
457 title += " (refreshing...)"
458 }
459 if m.isFetching {
460 title += " (loading more...)"
461 }
462 if m.pluginStatus != "" {
463 title += " (" + m.pluginStatus + ")"
464 }
465 return title
466}
467
468func (m *Inbox) getBaseTitle() string {
469 if m.folderName != "" {
470 return m.folderName
471 }
472 switch m.mailbox {
473 case MailboxSent:
474 return "Sent"
475 case MailboxTrash:
476 return "Trash"
477 case MailboxArchive:
478 return "Archive"
479 default:
480 return "Inbox"
481 }
482}
483
484func (m *Inbox) Init() tea.Cmd {
485 return nil
486}
487
488func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
489 var cmds []tea.Cmd
490
491 switch msg := msg.(type) {
492 case tea.KeyPressMsg:
493 if m.list.FilterState() == list.Filtering {
494 // Don't allow visual mode while filtering
495 if m.visualMode {
496 m.visualMode = false
497 m.selectedUIDs = make(map[uint32]string)
498 m.selectionOrder = []uint32{}
499 m.updateListTitle()
500 }
501 break
502 }
503 switch keypress := msg.String(); keypress {
504 case "v":
505 if !m.visualMode {
506 // Enter visual mode
507 m.visualMode = true
508 m.visualAnchor = m.list.Index()
509 selectedItem, ok := m.list.SelectedItem().(item)
510 if ok {
511 m.selectedUIDs = make(map[uint32]string)
512 m.selectionOrder = []uint32{}
513 m.selectedUIDs[selectedItem.uid] = selectedItem.accountID
514 m.selectionOrder = append(m.selectionOrder, selectedItem.uid)
515 }
516 m.updateListTitle()
517 } else {
518 // Exit visual mode
519 m.visualMode = false
520 m.selectedUIDs = make(map[uint32]string)
521 m.selectionOrder = []uint32{}
522 m.updateListTitle()
523 }
524 return m, nil
525 case "esc":
526 if m.visualMode {
527 // Exit visual mode on ESC
528 m.visualMode = false
529 m.selectedUIDs = make(map[uint32]string)
530 m.selectionOrder = []uint32{}
531 m.updateListTitle()
532 return m, nil
533 }
534 case "j", "down", "k", "up":
535 if m.visualMode {
536 // Let the list handle navigation first
537 var cmd tea.Cmd
538 m.list, cmd = m.list.Update(msg)
539 // Then update selection
540 m.updateVisualSelection()
541 return m, cmd
542 }
543 case "left", "h":
544 if len(m.tabs) > 1 {
545 m.activeTabIndex--
546 if m.activeTabIndex < 0 {
547 m.activeTabIndex = len(m.tabs) - 1
548 }
549 m.currentAccountID = m.tabs[m.activeTabIndex].ID
550 // Exit visual mode when switching tabs
551 m.visualMode = false
552 m.selectedUIDs = make(map[uint32]string)
553 m.selectionOrder = []uint32{}
554 m.updateList()
555 return m, nil
556 }
557 case "right", "l":
558 if len(m.tabs) > 1 {
559 m.activeTabIndex++
560 if m.activeTabIndex >= len(m.tabs) {
561 m.activeTabIndex = 0
562 }
563 m.currentAccountID = m.tabs[m.activeTabIndex].ID
564 // Exit visual mode when switching tabs
565 m.visualMode = false
566 m.selectedUIDs = make(map[uint32]string)
567 m.selectionOrder = []uint32{}
568 m.updateList()
569 return m, nil
570 }
571 case "d":
572 if m.visualMode && len(m.selectedUIDs) > 0 {
573 // Batch delete
574 uids := make([]uint32, len(m.selectionOrder))
575 copy(uids, m.selectionOrder)
576 accountID := ""
577 for _, aid := range m.selectedUIDs {
578 accountID = aid // Get any account (all should be same in single-account selection)
579 break
580 }
581
582 // Exit visual mode
583 m.visualMode = false
584 m.selectedUIDs = make(map[uint32]string)
585 m.selectionOrder = []uint32{}
586 m.updateListTitle()
587
588 return m, func() tea.Msg {
589 return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
590 }
591 } else {
592 // Single delete
593 selectedItem, ok := m.list.SelectedItem().(item)
594 if ok {
595 return m, func() tea.Msg {
596 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
597 }
598 }
599 }
600 case "a":
601 if m.visualMode && len(m.selectedUIDs) > 0 {
602 // Batch archive
603 uids := make([]uint32, len(m.selectionOrder))
604 copy(uids, m.selectionOrder)
605 accountID := ""
606 for _, aid := range m.selectedUIDs {
607 accountID = aid
608 break
609 }
610
611 // Exit visual mode
612 m.visualMode = false
613 m.selectedUIDs = make(map[uint32]string)
614 m.selectionOrder = []uint32{}
615 m.updateListTitle()
616
617 return m, func() tea.Msg {
618 return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
619 }
620 } else {
621 // Single archive
622 selectedItem, ok := m.list.SelectedItem().(item)
623 if ok {
624 return m, func() tea.Msg {
625 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
626 }
627 }
628 }
629 case "r":
630 m.isRefreshing = true
631 m.list.Title = m.getTitle()
632 // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
633 counts := make(map[string]int)
634 for k, v := range m.emailCountByAcct {
635 counts[k] = v
636 }
637 return m, func() tea.Msg {
638 return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
639 }
640 case "enter":
641 selectedItem, ok := m.list.SelectedItem().(item)
642 if ok {
643 idx := selectedItem.originalIndex
644 uid := selectedItem.uid
645 accountID := selectedItem.accountID
646 return m, func() tea.Msg {
647 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
648 }
649 }
650 }
651 case tea.WindowSizeMsg:
652 m.width = msg.Width
653 m.height = msg.Height
654 m.list.SetWidth(msg.Width)
655 m.list.SetHeight(msg.Height / 2)
656 if m.shouldFetchMore() {
657 return m, tea.Batch(m.fetchMoreCmds()...)
658 }
659 return m, nil
660
661 case FetchingMoreEmailsMsg:
662 m.isFetching = true
663 m.list.Title = m.getTitle()
664 return m, nil
665
666 case EmailsAppendedMsg:
667 if msg.Mailbox != m.mailbox {
668 return m, nil
669 }
670 m.isFetching = false
671 m.list.Title = m.getTitle()
672
673 if len(msg.Emails) == 0 {
674 if m.noMoreByAccount == nil {
675 m.noMoreByAccount = make(map[string]bool)
676 }
677 m.noMoreByAccount[msg.AccountID] = true
678 return m, nil
679 }
680
681 // Add emails to the appropriate account
682 for _, email := range msg.Emails {
683 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
684 m.allEmails = append(m.allEmails, email)
685 }
686 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
687
688 m.updateList()
689 return m, nil
690
691 case RefreshingEmailsMsg:
692 if msg.Mailbox != m.mailbox {
693 return m, nil
694 }
695 m.isRefreshing = true
696 m.list.Title = m.getTitle()
697 return m, nil
698
699 case EmailsRefreshedMsg:
700 if msg.Mailbox != m.mailbox {
701 return m, nil
702 }
703 // Only clear the refreshing indicator. The actual email data is
704 // merged by the main model (preserving paginated emails) and
705 // pushed to us via SetEmails, so we must not overwrite it here.
706 m.isRefreshing = false
707 m.list.Title = m.getTitle()
708 return m, nil
709 }
710
711 var cmd tea.Cmd
712 m.list, cmd = m.list.Update(msg)
713 cmds = append(cmds, cmd)
714
715 if m.shouldFetchMore() {
716 cmds = append(cmds, m.fetchMoreCmds()...)
717 }
718 return m, tea.Batch(cmds...)
719}
720
721func (m *Inbox) shouldFetchMore() bool {
722 if m.isFetching || m.isRefreshing {
723 return false
724 }
725 if m.allAccountsExhausted() {
726 return false
727 }
728 if len(m.list.Items()) == 0 {
729 return false
730 }
731 if m.list.FilterState() == list.Filtering {
732 return false
733 }
734 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
735 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
736}
737
738// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
739func (m *Inbox) allAccountsExhausted() bool {
740 if len(m.noMoreByAccount) == 0 {
741 return false
742 }
743 if m.currentAccountID != "" {
744 return m.noMoreByAccount[m.currentAccountID]
745 }
746 // "ALL" view: all accounts must be exhausted
747 for _, acc := range m.accounts {
748 if !m.noMoreByAccount[acc.ID] {
749 return false
750 }
751 }
752 return len(m.accounts) > 0
753}
754
755func (m *Inbox) fetchMoreCmds() []tea.Cmd {
756 var cmds []tea.Cmd
757 limit := uint32(m.list.Height())
758 if limit < 20 {
759 limit = 20
760 }
761
762 if m.currentAccountID == "" {
763 if len(m.accounts) == 0 {
764 return nil
765 }
766 for _, acc := range m.accounts {
767 accountID := acc.ID
768 if m.noMoreByAccount[accountID] {
769 continue
770 }
771 offset := uint32(len(m.emailsByAccount[accountID]))
772 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
773 return func() tea.Msg {
774 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
775 }
776 }(accountID, offset))
777 }
778 return cmds
779 }
780
781 if m.noMoreByAccount[m.currentAccountID] {
782 return nil
783 }
784 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
785 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
786 return func() tea.Msg {
787 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
788 }
789 }(m.currentAccountID, offset))
790 return cmds
791}
792
793func (m *Inbox) View() tea.View {
794 var b strings.Builder
795
796 // Render tabs if there are multiple accounts
797 if len(m.tabs) > 1 {
798 var tabViews []string
799 for i, tab := range m.tabs {
800 label := tab.Label
801 if tab.ID == "" {
802 label = "ALL"
803 }
804
805 if i == m.activeTabIndex {
806 tabViews = append(tabViews, activeTabStyle.Render(label))
807 } else {
808 tabViews = append(tabViews, tabStyle.Render(label))
809 }
810 }
811 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
812 b.WriteString(tabBar)
813 b.WriteString("\n")
814 }
815
816 b.WriteString(m.list.View())
817
818 // Ensure we don't start gap calculation on the same line as the list
819 if !strings.HasSuffix(b.String(), "\n") {
820 b.WriteString("\n")
821 }
822
823 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
824
825 if m.height > 0 {
826 usedHeight := lipgloss.Height(b.String())
827 helpHeight := lipgloss.Height(helpView)
828
829 gap := m.height - usedHeight - helpHeight
830 if gap > 0 {
831 b.WriteString(strings.Repeat("\n", gap))
832 }
833 } else {
834 b.WriteString("\n")
835 }
836
837 b.WriteString(helpView)
838
839 return tea.NewView(b.String())
840}
841
842// GetCurrentAccountID returns the currently selected account ID
843func (m *Inbox) GetCurrentAccountID() string {
844 return m.currentAccountID
845}
846
847// GetEmailAtIndex returns the email at the given index for the current view
848func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
849 var displayEmails []fetcher.Email
850 if m.currentAccountID == "" {
851 displayEmails = m.allEmails
852 } else {
853 displayEmails = m.emailsByAccount[m.currentAccountID]
854 }
855
856 if index >= 0 && index < len(displayEmails) {
857 return &displayEmails[index]
858 }
859 return nil
860}
861
862func (m *Inbox) GetMailbox() MailboxKind {
863 return m.mailbox
864}
865
866// GetSelectedEmail returns the currently selected email, or nil if none is selected.
867func (m *Inbox) GetSelectedEmail() *fetcher.Email {
868 selectedItem, ok := m.list.SelectedItem().(item)
869 if !ok {
870 return nil
871 }
872 return m.GetEmailAtIndex(selectedItem.originalIndex)
873}
874
875// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
876func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
877 for i := range m.allEmails {
878 if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
879 m.allEmails[i].IsRead = true
880 break
881 }
882 }
883 if emails, ok := m.emailsByAccount[accountID]; ok {
884 for i := range emails {
885 if emails[i].UID == uid {
886 emails[i].IsRead = true
887 break
888 }
889 }
890 }
891 m.updateList()
892}
893
894// updateVisualSelection updates the selected UIDs based on anchor and current index
895func (m *Inbox) updateVisualSelection() {
896 if !m.visualMode {
897 return
898 }
899
900 currentIdx := m.list.Index()
901 start := m.visualAnchor
902 end := currentIdx
903
904 if start > end {
905 start, end = end, start
906 }
907
908 // Clear and rebuild selection
909 m.selectedUIDs = make(map[uint32]string)
910 m.selectionOrder = []uint32{}
911
912 items := m.list.Items()
913 firstAccountID := ""
914 for i := start; i <= end && i < len(items); i++ {
915 if itm, ok := items[i].(item); ok {
916 // Ensure all selected emails are from the same account (prevent cross-account batch ops)
917 if firstAccountID == "" {
918 firstAccountID = itm.accountID
919 }
920 if itm.accountID != firstAccountID {
921 // Don't add emails from different accounts
922 continue
923 }
924
925 if _, exists := m.selectedUIDs[itm.uid]; !exists {
926 m.selectedUIDs[itm.uid] = itm.accountID
927 m.selectionOrder = append(m.selectionOrder, itm.uid)
928 }
929 }
930 }
931
932 m.updateListTitle()
933}
934
935// updateListTitle updates the title to show selection count when in visual mode
936func (m *Inbox) updateListTitle() {
937 if m.visualMode && len(m.selectedUIDs) > 0 {
938 baseTitle := m.getBaseTitle()
939 m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
940 } else {
941 m.list.Title = m.getTitle()
942 }
943}
944
945// RemoveEmails removes multiple emails by UID and account ID (batch operation)
946func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
947 uidSet := make(map[uint32]bool)
948 for _, uid := range uids {
949 uidSet[uid] = true
950 }
951
952 // Remove from account-specific list
953 if emails, ok := m.emailsByAccount[accountID]; ok {
954 var filtered []fetcher.Email
955 for _, e := range emails {
956 if !uidSet[e.UID] {
957 filtered = append(filtered, e)
958 }
959 }
960 m.emailsByAccount[accountID] = filtered
961 }
962
963 // Remove from all emails list
964 var filteredAll []fetcher.Email
965 for _, e := range m.allEmails {
966 if !(uidSet[e.UID] && e.AccountID == accountID) {
967 filteredAll = append(filteredAll, e)
968 }
969 }
970 m.allEmails = filteredAll
971
972 m.updateList()
973}
974
975// RemoveEmail removes an email by UID and account ID
976func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
977 // Remove from account-specific list
978 if emails, ok := m.emailsByAccount[accountID]; ok {
979 var filtered []fetcher.Email
980 for _, e := range emails {
981 if e.UID != uid {
982 filtered = append(filtered, e)
983 }
984 }
985 m.emailsByAccount[accountID] = filtered
986 }
987
988 // Remove from all emails list
989 var filteredAll []fetcher.Email
990 for _, e := range m.allEmails {
991 if !(e.UID == uid && e.AccountID == accountID) {
992 filteredAll = append(filteredAll, e)
993 }
994 }
995 m.allEmails = filteredAll
996
997 m.updateList()
998}
999
1000// SetSize sets the width and height of the inbox, then updates the list.
1001func (m *Inbox) SetSize(width, height int) {
1002 m.width = width
1003 m.height = height
1004 m.list.SetWidth(width)
1005 m.list.SetHeight(height / 2)
1006}
1007
1008// SetFolderName sets a custom folder name for the inbox title.
1009func (m *Inbox) SetFolderName(name string) {
1010 m.folderName = name
1011 m.list.Title = m.getTitle()
1012}
1013
1014// SetPluginStatus sets a persistent status string from plugins, shown in the title.
1015func (m *Inbox) SetPluginStatus(status string) {
1016 m.pluginStatus = status
1017 m.list.Title = m.getTitle()
1018}
1019
1020// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1021func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1022 m.pluginKeyBindings = bindings
1023}
1024
1025// SetEmails updates all emails (used after fetch)
1026func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
1027 m.accounts = accounts
1028 m.allEmails = emails
1029 m.noMoreByAccount = make(map[string]bool)
1030
1031 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
1032 var tabs []AccountTab
1033 if len(accounts) <= 1 {
1034 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
1035 } else {
1036 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
1037 for _, acc := range accounts {
1038 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
1039 }
1040 }
1041 m.tabs = tabs
1042
1043 // Re-group emails by account
1044 m.emailsByAccount = make(map[string][]fetcher.Email)
1045 for _, email := range emails {
1046 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
1047 }
1048
1049 // Update email counts
1050 m.emailCountByAcct = make(map[string]int)
1051 for accID, accEmails := range m.emailsByAccount {
1052 m.emailCountByAcct[accID] = len(accEmails)
1053 }
1054
1055 m.updateList()
1056}