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