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