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 parts := strings.Split(email, "@")
215 if len(parts) >= 1 && len(parts[0]) > 8 {
216 return parts[0][:8] + "..."
217 }
218 if len(parts) >= 1 {
219 return parts[0]
220 }
221 return email
222}
223
224// AccountTab represents a tab for an account
225type AccountTab struct {
226 ID string
227 Label string
228 Email string
229}
230
231type Inbox struct {
232 list list.Model
233 isFetching bool
234 isRefreshing bool
235 emailsCount int
236 accounts []config.Account
237 emailsByAccount map[string][]fetcher.Email
238 allEmails []fetcher.Email
239 tabs []AccountTab
240 activeTabIndex int
241 width int
242 height int
243 currentAccountID string // Empty means "ALL"
244 emailCountByAcct map[string]int
245 mailbox MailboxKind
246 folderName string // Custom folder name override for title
247 noMoreByAccount map[string]bool // Per-account: true when pagination returns 0 results
248 extraShortHelpKeys []key.Binding
249 pluginStatus string // Persistent status text set by plugins
250 pluginKeyBindings []PluginKeyBinding
251
252 // Visual mode state (Vim-style multi-select)
253 visualMode bool // Whether visual mode is active
254 visualAnchor int // Index where visual selection started
255 selectedUIDs map[uint32]string // map[uid]accountID for selected emails
256 selectionOrder []uint32 // Ordered list of UIDs for display
257}
258
259func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
260 return NewInboxWithMailbox(emails, accounts, MailboxInbox)
261}
262
263func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
264 return NewInboxWithMailbox(emails, accounts, MailboxSent)
265}
266
267func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
268 return NewInboxWithMailbox(emails, accounts, MailboxTrash)
269}
270
271func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
272 return NewInboxWithMailbox(emails, accounts, MailboxArchive)
273}
274
275func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
276 // Build tabs: empty for single account, "ALL" + accounts for multiple
277 var tabs []AccountTab
278 if len(accounts) <= 1 {
279 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
280 } else {
281 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
282 for _, acc := range accounts {
283 // Use FetchEmail for display, fall back to Email if not set
284 displayEmail := acc.FetchEmail
285 if displayEmail == "" {
286 displayEmail = acc.Email
287 }
288 tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
289 }
290 }
291
292 // Group emails by account
293 emailsByAccount := make(map[string][]fetcher.Email)
294 for _, email := range emails {
295 emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
296 }
297
298 // Track email counts per account
299 emailCountByAcct := make(map[string]int)
300 for accID, accEmails := range emailsByAccount {
301 emailCountByAcct[accID] = len(accEmails)
302 }
303
304 inbox := &Inbox{
305 accounts: accounts,
306 emailsByAccount: emailsByAccount,
307 allEmails: emails,
308 tabs: tabs,
309 activeTabIndex: 0,
310 currentAccountID: "",
311 emailCountByAcct: emailCountByAcct,
312 mailbox: mailbox,
313 visualMode: false,
314 selectedUIDs: make(map[uint32]string),
315 selectionOrder: []uint32{},
316 }
317
318 inbox.updateList()
319 return inbox
320}
321
322// NewInboxSingleAccount creates an inbox for a single account (legacy support)
323func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
324 return NewInbox(emails, nil)
325}
326
327func (m *Inbox) updateList() {
328 // Capture current index to restore later
329 currentIndex := m.list.Index()
330
331 var displayEmails []fetcher.Email
332 var showAccountLabel bool
333
334 if m.currentAccountID == "" {
335 // "ALL" view - show all emails sorted by date
336 displayEmails = m.allEmails
337 showAccountLabel = !(len(m.accounts) <= 1)
338 } else {
339 // Specific account view
340 displayEmails = m.emailsByAccount[m.currentAccountID]
341 showAccountLabel = false
342 }
343
344 m.emailsCount = len(displayEmails)
345
346 items := make([]list.Item, len(displayEmails))
347 for i, email := range displayEmails {
348 accountEmail := ""
349 if showAccountLabel {
350 // Find the account email for display
351 for _, acc := range m.accounts {
352 if acc.ID == email.AccountID {
353 accountEmail = acc.FetchEmail
354 break
355 }
356 }
357 }
358
359 items[i] = item{
360 title: email.Subject,
361 desc: email.From,
362 originalIndex: i,
363 uid: email.UID,
364 accountID: email.AccountID,
365 accountEmail: accountEmail,
366 date: email.Date,
367 isRead: email.IsRead,
368 }
369 }
370
371 l := list.New(items, itemDelegate{inbox: m}, 20, 14)
372 l.Title = m.getTitle()
373 l.SetShowStatusBar(true)
374 l.SetFilteringEnabled(true)
375 l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
376 l.Styles.PaginationStyle = paginationStyle
377 l.Styles.HelpStyle = inboxHelpStyle
378 l.SetStatusBarItemName("email", "emails")
379 l.AdditionalShortHelpKeys = func() []key.Binding {
380 bindings := []key.Binding{
381 key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual mode")),
382 key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
383 key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
384 key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
385 }
386 if len(m.tabs) > 1 {
387 bindings = append(bindings,
388 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
389 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
390 )
391 }
392 bindings = append(bindings, m.extraShortHelpKeys...)
393 for _, pk := range m.pluginKeyBindings {
394 bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
395 }
396 return bindings
397 }
398
399 l.KeyMap.Quit.SetEnabled(false)
400
401 // Disable default help to render it manually at the bottom
402 l.SetShowHelp(false)
403
404 if m.width > 0 {
405 l.SetWidth(m.width)
406 }
407 if m.height > 0 {
408 l.SetHeight(m.height / 2)
409 }
410
411 // Restore index
412 // If index is out of bounds (e.g. list shrank), clamp it.
413 if currentIndex >= len(items) {
414 currentIndex = len(items) - 1
415 }
416 if currentIndex < 0 {
417 currentIndex = 0
418 }
419 l.Select(currentIndex)
420
421 m.list = l
422}
423
424func (m *Inbox) getTitle() string {
425 var title string
426 if m.currentAccountID == "" {
427 title = m.getBaseTitle() + " - All Accounts"
428 } else {
429 title = m.getBaseTitle()
430 for _, acc := range m.accounts {
431 if acc.ID == m.currentAccountID {
432 if acc.Name != "" {
433 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
434 } else {
435 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
436 }
437 break
438 }
439 }
440 }
441 if m.isRefreshing {
442 title += " (refreshing...)"
443 }
444 if m.isFetching {
445 title += " (loading more...)"
446 }
447 if m.pluginStatus != "" {
448 title += " (" + m.pluginStatus + ")"
449 }
450 return title
451}
452
453func (m *Inbox) getBaseTitle() string {
454 if m.folderName != "" {
455 return m.folderName
456 }
457 switch m.mailbox {
458 case MailboxSent:
459 return "Sent"
460 case MailboxTrash:
461 return "Trash"
462 case MailboxArchive:
463 return "Archive"
464 default:
465 return "Inbox"
466 }
467}
468
469func (m *Inbox) Init() tea.Cmd {
470 return nil
471}
472
473func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
474 var cmds []tea.Cmd
475
476 switch msg := msg.(type) {
477 case tea.KeyPressMsg:
478 if m.list.FilterState() == list.Filtering {
479 // Don't allow visual mode while filtering
480 if m.visualMode {
481 m.visualMode = false
482 m.selectedUIDs = make(map[uint32]string)
483 m.selectionOrder = []uint32{}
484 m.updateListTitle()
485 }
486 break
487 }
488 switch keypress := msg.String(); keypress {
489 case "v":
490 if !m.visualMode {
491 // Enter visual mode
492 m.visualMode = true
493 m.visualAnchor = m.list.Index()
494 selectedItem, ok := m.list.SelectedItem().(item)
495 if ok {
496 m.selectedUIDs = make(map[uint32]string)
497 m.selectionOrder = []uint32{}
498 m.selectedUIDs[selectedItem.uid] = selectedItem.accountID
499 m.selectionOrder = append(m.selectionOrder, selectedItem.uid)
500 }
501 m.updateListTitle()
502 } else {
503 // Exit visual mode
504 m.visualMode = false
505 m.selectedUIDs = make(map[uint32]string)
506 m.selectionOrder = []uint32{}
507 m.updateListTitle()
508 }
509 return m, nil
510 case "esc":
511 if m.visualMode {
512 // Exit visual mode on ESC
513 m.visualMode = false
514 m.selectedUIDs = make(map[uint32]string)
515 m.selectionOrder = []uint32{}
516 m.updateListTitle()
517 return m, nil
518 }
519 case "j", "down", "k", "up":
520 if m.visualMode {
521 // Let the list handle navigation first
522 var cmd tea.Cmd
523 m.list, cmd = m.list.Update(msg)
524 // Then update selection
525 m.updateVisualSelection()
526 return m, cmd
527 }
528 case "left", "h":
529 if len(m.tabs) > 1 {
530 m.activeTabIndex--
531 if m.activeTabIndex < 0 {
532 m.activeTabIndex = len(m.tabs) - 1
533 }
534 m.currentAccountID = m.tabs[m.activeTabIndex].ID
535 // Exit visual mode when switching tabs
536 m.visualMode = false
537 m.selectedUIDs = make(map[uint32]string)
538 m.selectionOrder = []uint32{}
539 m.updateList()
540 return m, nil
541 }
542 case "right", "l":
543 if len(m.tabs) > 1 {
544 m.activeTabIndex++
545 if m.activeTabIndex >= len(m.tabs) {
546 m.activeTabIndex = 0
547 }
548 m.currentAccountID = m.tabs[m.activeTabIndex].ID
549 // Exit visual mode when switching tabs
550 m.visualMode = false
551 m.selectedUIDs = make(map[uint32]string)
552 m.selectionOrder = []uint32{}
553 m.updateList()
554 return m, nil
555 }
556 case "d":
557 if m.visualMode && len(m.selectedUIDs) > 0 {
558 // Batch delete
559 uids := make([]uint32, len(m.selectionOrder))
560 copy(uids, m.selectionOrder)
561 accountID := ""
562 for _, aid := range m.selectedUIDs {
563 accountID = aid // Get any account (all should be same in single-account selection)
564 break
565 }
566
567 // Exit visual mode
568 m.visualMode = false
569 m.selectedUIDs = make(map[uint32]string)
570 m.selectionOrder = []uint32{}
571 m.updateListTitle()
572
573 return m, func() tea.Msg {
574 return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
575 }
576 } else {
577 // Single delete
578 selectedItem, ok := m.list.SelectedItem().(item)
579 if ok {
580 return m, func() tea.Msg {
581 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
582 }
583 }
584 }
585 case "a":
586 if m.visualMode && len(m.selectedUIDs) > 0 {
587 // Batch archive
588 uids := make([]uint32, len(m.selectionOrder))
589 copy(uids, m.selectionOrder)
590 accountID := ""
591 for _, aid := range m.selectedUIDs {
592 accountID = aid
593 break
594 }
595
596 // Exit visual mode
597 m.visualMode = false
598 m.selectedUIDs = make(map[uint32]string)
599 m.selectionOrder = []uint32{}
600 m.updateListTitle()
601
602 return m, func() tea.Msg {
603 return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
604 }
605 } else {
606 // Single archive
607 selectedItem, ok := m.list.SelectedItem().(item)
608 if ok {
609 return m, func() tea.Msg {
610 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
611 }
612 }
613 }
614 case "r":
615 m.isRefreshing = true
616 m.list.Title = m.getTitle()
617 // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
618 counts := make(map[string]int)
619 for k, v := range m.emailCountByAcct {
620 counts[k] = v
621 }
622 return m, func() tea.Msg {
623 return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
624 }
625 case "enter":
626 selectedItem, ok := m.list.SelectedItem().(item)
627 if ok {
628 idx := selectedItem.originalIndex
629 uid := selectedItem.uid
630 accountID := selectedItem.accountID
631 return m, func() tea.Msg {
632 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
633 }
634 }
635 }
636 case tea.WindowSizeMsg:
637 m.width = msg.Width
638 m.height = msg.Height
639 m.list.SetWidth(msg.Width)
640 m.list.SetHeight(msg.Height / 2)
641 if m.shouldFetchMore() {
642 return m, tea.Batch(m.fetchMoreCmds()...)
643 }
644 return m, nil
645
646 case FetchingMoreEmailsMsg:
647 m.isFetching = true
648 m.list.Title = m.getTitle()
649 return m, nil
650
651 case EmailsAppendedMsg:
652 if msg.Mailbox != m.mailbox {
653 return m, nil
654 }
655 m.isFetching = false
656 m.list.Title = m.getTitle()
657
658 if len(msg.Emails) == 0 {
659 if m.noMoreByAccount == nil {
660 m.noMoreByAccount = make(map[string]bool)
661 }
662 m.noMoreByAccount[msg.AccountID] = true
663 return m, nil
664 }
665
666 // Add emails to the appropriate account
667 for _, email := range msg.Emails {
668 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
669 m.allEmails = append(m.allEmails, email)
670 }
671 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
672
673 m.updateList()
674 return m, nil
675
676 case RefreshingEmailsMsg:
677 if msg.Mailbox != m.mailbox {
678 return m, nil
679 }
680 m.isRefreshing = true
681 m.list.Title = m.getTitle()
682 return m, nil
683
684 case EmailsRefreshedMsg:
685 if msg.Mailbox != m.mailbox {
686 return m, nil
687 }
688 // Only clear the refreshing indicator. The actual email data is
689 // merged by the main model (preserving paginated emails) and
690 // pushed to us via SetEmails, so we must not overwrite it here.
691 m.isRefreshing = false
692 m.list.Title = m.getTitle()
693 return m, nil
694 }
695
696 var cmd tea.Cmd
697 m.list, cmd = m.list.Update(msg)
698 cmds = append(cmds, cmd)
699
700 if m.shouldFetchMore() {
701 cmds = append(cmds, m.fetchMoreCmds()...)
702 }
703 return m, tea.Batch(cmds...)
704}
705
706func (m *Inbox) shouldFetchMore() bool {
707 if m.isFetching || m.isRefreshing {
708 return false
709 }
710 if m.allAccountsExhausted() {
711 return false
712 }
713 if len(m.list.Items()) == 0 {
714 return false
715 }
716 if m.list.FilterState() == list.Filtering {
717 return false
718 }
719 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
720 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
721}
722
723// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
724func (m *Inbox) allAccountsExhausted() bool {
725 if len(m.noMoreByAccount) == 0 {
726 return false
727 }
728 if m.currentAccountID != "" {
729 return m.noMoreByAccount[m.currentAccountID]
730 }
731 // "ALL" view: all accounts must be exhausted
732 for _, acc := range m.accounts {
733 if !m.noMoreByAccount[acc.ID] {
734 return false
735 }
736 }
737 return len(m.accounts) > 0
738}
739
740func (m *Inbox) fetchMoreCmds() []tea.Cmd {
741 var cmds []tea.Cmd
742 limit := uint32(m.list.Height())
743 if limit < 20 {
744 limit = 20
745 }
746
747 if m.currentAccountID == "" {
748 if len(m.accounts) == 0 {
749 return nil
750 }
751 for _, acc := range m.accounts {
752 accountID := acc.ID
753 if m.noMoreByAccount[accountID] {
754 continue
755 }
756 offset := uint32(len(m.emailsByAccount[accountID]))
757 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
758 return func() tea.Msg {
759 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
760 }
761 }(accountID, offset))
762 }
763 return cmds
764 }
765
766 if m.noMoreByAccount[m.currentAccountID] {
767 return nil
768 }
769 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
770 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
771 return func() tea.Msg {
772 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
773 }
774 }(m.currentAccountID, offset))
775 return cmds
776}
777
778func (m *Inbox) View() tea.View {
779 var b strings.Builder
780
781 // Render tabs if there are multiple accounts
782 if len(m.tabs) > 1 {
783 var tabViews []string
784 for i, tab := range m.tabs {
785 label := tab.Label
786 if tab.ID == "" {
787 label = "ALL"
788 }
789
790 if i == m.activeTabIndex {
791 tabViews = append(tabViews, activeTabStyle.Render(label))
792 } else {
793 tabViews = append(tabViews, tabStyle.Render(label))
794 }
795 }
796 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
797 b.WriteString(tabBar)
798 b.WriteString("\n")
799 }
800
801 b.WriteString(m.list.View())
802
803 // Ensure we don't start gap calculation on the same line as the list
804 if !strings.HasSuffix(b.String(), "\n") {
805 b.WriteString("\n")
806 }
807
808 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
809
810 if m.height > 0 {
811 usedHeight := lipgloss.Height(b.String())
812 helpHeight := lipgloss.Height(helpView)
813
814 gap := m.height - usedHeight - helpHeight
815 if gap > 0 {
816 b.WriteString(strings.Repeat("\n", gap))
817 }
818 } else {
819 b.WriteString("\n")
820 }
821
822 b.WriteString(helpView)
823
824 return tea.NewView(b.String())
825}
826
827// GetCurrentAccountID returns the currently selected account ID
828func (m *Inbox) GetCurrentAccountID() string {
829 return m.currentAccountID
830}
831
832// GetEmailAtIndex returns the email at the given index for the current view
833func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
834 var displayEmails []fetcher.Email
835 if m.currentAccountID == "" {
836 displayEmails = m.allEmails
837 } else {
838 displayEmails = m.emailsByAccount[m.currentAccountID]
839 }
840
841 if index >= 0 && index < len(displayEmails) {
842 return &displayEmails[index]
843 }
844 return nil
845}
846
847func (m *Inbox) GetMailbox() MailboxKind {
848 return m.mailbox
849}
850
851// GetSelectedEmail returns the currently selected email, or nil if none is selected.
852func (m *Inbox) GetSelectedEmail() *fetcher.Email {
853 selectedItem, ok := m.list.SelectedItem().(item)
854 if !ok {
855 return nil
856 }
857 return m.GetEmailAtIndex(selectedItem.originalIndex)
858}
859
860// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
861func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
862 for i := range m.allEmails {
863 if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
864 m.allEmails[i].IsRead = true
865 break
866 }
867 }
868 if emails, ok := m.emailsByAccount[accountID]; ok {
869 for i := range emails {
870 if emails[i].UID == uid {
871 emails[i].IsRead = true
872 break
873 }
874 }
875 }
876 m.updateList()
877}
878
879// updateVisualSelection updates the selected UIDs based on anchor and current index
880func (m *Inbox) updateVisualSelection() {
881 if !m.visualMode {
882 return
883 }
884
885 currentIdx := m.list.Index()
886 start := m.visualAnchor
887 end := currentIdx
888
889 if start > end {
890 start, end = end, start
891 }
892
893 // Clear and rebuild selection
894 m.selectedUIDs = make(map[uint32]string)
895 m.selectionOrder = []uint32{}
896
897 items := m.list.Items()
898 firstAccountID := ""
899 for i := start; i <= end && i < len(items); i++ {
900 if itm, ok := items[i].(item); ok {
901 // Ensure all selected emails are from the same account (prevent cross-account batch ops)
902 if firstAccountID == "" {
903 firstAccountID = itm.accountID
904 }
905 if itm.accountID != firstAccountID {
906 // Don't add emails from different accounts
907 continue
908 }
909
910 if _, exists := m.selectedUIDs[itm.uid]; !exists {
911 m.selectedUIDs[itm.uid] = itm.accountID
912 m.selectionOrder = append(m.selectionOrder, itm.uid)
913 }
914 }
915 }
916
917 m.updateListTitle()
918}
919
920// updateListTitle updates the title to show selection count when in visual mode
921func (m *Inbox) updateListTitle() {
922 if m.visualMode && len(m.selectedUIDs) > 0 {
923 baseTitle := m.getBaseTitle()
924 m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
925 } else {
926 m.list.Title = m.getTitle()
927 }
928}
929
930// RemoveEmails removes multiple emails by UID and account ID (batch operation)
931func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
932 uidSet := make(map[uint32]bool)
933 for _, uid := range uids {
934 uidSet[uid] = true
935 }
936
937 // Remove from account-specific list
938 if emails, ok := m.emailsByAccount[accountID]; ok {
939 var filtered []fetcher.Email
940 for _, e := range emails {
941 if !uidSet[e.UID] {
942 filtered = append(filtered, e)
943 }
944 }
945 m.emailsByAccount[accountID] = filtered
946 }
947
948 // Remove from all emails list
949 var filteredAll []fetcher.Email
950 for _, e := range m.allEmails {
951 if !(uidSet[e.UID] && e.AccountID == accountID) {
952 filteredAll = append(filteredAll, e)
953 }
954 }
955 m.allEmails = filteredAll
956
957 m.updateList()
958}
959
960// RemoveEmail removes an email by UID and account ID
961func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
962 // Remove from account-specific list
963 if emails, ok := m.emailsByAccount[accountID]; ok {
964 var filtered []fetcher.Email
965 for _, e := range emails {
966 if e.UID != uid {
967 filtered = append(filtered, e)
968 }
969 }
970 m.emailsByAccount[accountID] = filtered
971 }
972
973 // Remove from all emails list
974 var filteredAll []fetcher.Email
975 for _, e := range m.allEmails {
976 if !(e.UID == uid && e.AccountID == accountID) {
977 filteredAll = append(filteredAll, e)
978 }
979 }
980 m.allEmails = filteredAll
981
982 m.updateList()
983}
984
985// SetSize sets the width and height of the inbox, then updates the list.
986func (m *Inbox) SetSize(width, height int) {
987 m.width = width
988 m.height = height
989 m.list.SetWidth(width)
990 m.list.SetHeight(height / 2)
991}
992
993// SetFolderName sets a custom folder name for the inbox title.
994func (m *Inbox) SetFolderName(name string) {
995 m.folderName = name
996 m.list.Title = m.getTitle()
997}
998
999// SetPluginStatus sets a persistent status string from plugins, shown in the title.
1000func (m *Inbox) SetPluginStatus(status string) {
1001 m.pluginStatus = status
1002 m.list.Title = m.getTitle()
1003}
1004
1005// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1006func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1007 m.pluginKeyBindings = bindings
1008}
1009
1010// SetEmails updates all emails (used after fetch)
1011func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
1012 m.accounts = accounts
1013 m.allEmails = emails
1014 m.noMoreByAccount = make(map[string]bool)
1015
1016 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
1017 var tabs []AccountTab
1018 if len(accounts) <= 1 {
1019 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
1020 } else {
1021 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
1022 for _, acc := range accounts {
1023 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
1024 }
1025 }
1026 m.tabs = tabs
1027
1028 // Re-group emails by account
1029 m.emailsByAccount = make(map[string][]fetcher.Email)
1030 for _, email := range emails {
1031 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
1032 }
1033
1034 // Update email counts
1035 m.emailCountByAcct = make(map[string]int)
1036 for accID, accEmails := range m.emailsByAccount {
1037 m.emailCountByAcct[accID] = len(accEmails)
1038 }
1039
1040 m.updateList()
1041}