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