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