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 kb := config.Keybinds
533 switch keypress := msg.String(); keypress {
534 case kb.Inbox.VisualMode:
535 if !m.visualMode {
536 // Enter visual mode
537 m.visualMode = true
538 m.visualAnchor = m.list.Index()
539 selectedItem, ok := m.list.SelectedItem().(item)
540 if ok {
541 m.selectedUIDs = make(map[uint32]string)
542 m.selectionOrder = []uint32{}
543 m.selectedUIDs[selectedItem.uid] = selectedItem.accountID
544 m.selectionOrder = append(m.selectionOrder, selectedItem.uid)
545 }
546 m.updateListTitle()
547 } else {
548 // Exit visual mode
549 m.visualMode = false
550 m.selectedUIDs = make(map[uint32]string)
551 m.selectionOrder = []uint32{}
552 m.updateListTitle()
553 }
554 return m, nil
555 case kb.Global.Cancel:
556 if m.visualMode {
557 // Exit visual mode on cancel key
558 m.visualMode = false
559 m.selectedUIDs = make(map[uint32]string)
560 m.selectionOrder = []uint32{}
561 m.updateListTitle()
562 return m, nil
563 }
564 case kb.Global.NavDown, "down", kb.Global.NavUp, "up":
565 if m.visualMode {
566 // Let the list handle navigation first
567 var cmd tea.Cmd
568 m.list, cmd = m.list.Update(msg)
569 // Then update selection
570 m.updateVisualSelection()
571 return m, cmd
572 }
573 case "left", kb.Inbox.PrevTab:
574 if len(m.tabs) > 1 {
575 m.activeTabIndex--
576 if m.activeTabIndex < 0 {
577 m.activeTabIndex = len(m.tabs) - 1
578 }
579 m.currentAccountID = m.tabs[m.activeTabIndex].ID
580 // Exit visual mode when switching tabs
581 m.visualMode = false
582 m.selectedUIDs = make(map[uint32]string)
583 m.selectionOrder = []uint32{}
584 m.updateList()
585 return m, nil
586 }
587 case "right", kb.Inbox.NextTab:
588 if len(m.tabs) > 1 {
589 m.activeTabIndex++
590 if m.activeTabIndex >= len(m.tabs) {
591 m.activeTabIndex = 0
592 }
593 m.currentAccountID = m.tabs[m.activeTabIndex].ID
594 // Exit visual mode when switching tabs
595 m.visualMode = false
596 m.selectedUIDs = make(map[uint32]string)
597 m.selectionOrder = []uint32{}
598 m.updateList()
599 return m, nil
600 }
601 case kb.Inbox.Delete:
602 if m.visualMode && len(m.selectedUIDs) > 0 {
603 // Batch delete
604 uids := make([]uint32, len(m.selectionOrder))
605 copy(uids, m.selectionOrder)
606 accountID := ""
607 for _, aid := range m.selectedUIDs {
608 accountID = aid // Get any account (all should be same in single-account selection)
609 break
610 }
611
612 // Exit visual mode
613 m.visualMode = false
614 m.selectedUIDs = make(map[uint32]string)
615 m.selectionOrder = []uint32{}
616 m.updateListTitle()
617
618 return m, func() tea.Msg {
619 return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
620 }
621 } else {
622 // Single delete
623 selectedItem, ok := m.list.SelectedItem().(item)
624 if ok {
625 return m, func() tea.Msg {
626 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
627 }
628 }
629 }
630 case kb.Inbox.Archive:
631 if m.visualMode && len(m.selectedUIDs) > 0 {
632 // Batch archive
633 uids := make([]uint32, len(m.selectionOrder))
634 copy(uids, m.selectionOrder)
635 accountID := ""
636 for _, aid := range m.selectedUIDs {
637 accountID = aid
638 break
639 }
640
641 // Exit visual mode
642 m.visualMode = false
643 m.selectedUIDs = make(map[uint32]string)
644 m.selectionOrder = []uint32{}
645 m.updateListTitle()
646
647 return m, func() tea.Msg {
648 return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
649 }
650 } else {
651 // Single archive
652 selectedItem, ok := m.list.SelectedItem().(item)
653 if ok {
654 return m, func() tea.Msg {
655 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
656 }
657 }
658 }
659 case kb.Inbox.Refresh:
660 m.isRefreshing = true
661 m.list.Title = m.getTitle()
662 // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
663 counts := make(map[string]int)
664 for k, v := range m.emailCountByAcct {
665 counts[k] = v
666 }
667 return m, func() tea.Msg {
668 return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
669 }
670 case kb.Inbox.Open:
671 selectedItem, ok := m.list.SelectedItem().(item)
672 if ok {
673 idx := selectedItem.originalIndex
674 uid := selectedItem.uid
675 accountID := selectedItem.accountID
676 return m, func() tea.Msg {
677 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
678 }
679 }
680 }
681 case tea.WindowSizeMsg:
682 m.width = msg.Width
683 m.height = msg.Height
684 m.list.SetWidth(msg.Width)
685 m.list.SetHeight(msg.Height / 2)
686 if m.shouldFetchMore() {
687 return m, tea.Batch(m.fetchMoreCmds()...)
688 }
689 return m, nil
690
691 case FetchingMoreEmailsMsg:
692 m.isFetching = true
693 m.list.Title = m.getTitle()
694 return m, nil
695
696 case EmailsAppendedMsg:
697 if msg.Mailbox != m.mailbox {
698 return m, nil
699 }
700 m.isFetching = false
701 m.list.Title = m.getTitle()
702
703 if len(msg.Emails) == 0 {
704 if m.noMoreByAccount == nil {
705 m.noMoreByAccount = make(map[string]bool)
706 }
707 m.noMoreByAccount[msg.AccountID] = true
708 return m, nil
709 }
710
711 // Add emails to the appropriate account
712 for _, email := range msg.Emails {
713 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
714 m.allEmails = append(m.allEmails, email)
715 }
716 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
717
718 m.updateList()
719 return m, nil
720
721 case RefreshingEmailsMsg:
722 if msg.Mailbox != m.mailbox {
723 return m, nil
724 }
725 m.isRefreshing = true
726 m.list.Title = m.getTitle()
727 return m, nil
728
729 case EmailsRefreshedMsg:
730 if msg.Mailbox != m.mailbox {
731 return m, nil
732 }
733 // Only clear the refreshing indicator. The actual email data is
734 // merged by the main model (preserving paginated emails) and
735 // pushed to us via SetEmails, so we must not overwrite it here.
736 m.isRefreshing = false
737 m.list.Title = m.getTitle()
738 return m, nil
739 }
740
741 var cmd tea.Cmd
742 m.list, cmd = m.list.Update(msg)
743 cmds = append(cmds, cmd)
744
745 if m.shouldFetchMore() {
746 cmds = append(cmds, m.fetchMoreCmds()...)
747 }
748 return m, tea.Batch(cmds...)
749}
750
751func (m *Inbox) shouldFetchMore() bool {
752 if m.isFetching || m.isRefreshing {
753 return false
754 }
755 if m.allAccountsExhausted() {
756 return false
757 }
758 if len(m.list.Items()) == 0 {
759 return false
760 }
761 if m.list.FilterState() == list.Filtering {
762 return false
763 }
764 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
765 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
766}
767
768// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
769func (m *Inbox) allAccountsExhausted() bool {
770 if len(m.noMoreByAccount) == 0 {
771 return false
772 }
773 if m.currentAccountID != "" {
774 return m.noMoreByAccount[m.currentAccountID]
775 }
776 // "ALL" view: all accounts must be exhausted
777 for _, acc := range m.accounts {
778 if !m.noMoreByAccount[acc.ID] {
779 return false
780 }
781 }
782 return len(m.accounts) > 0
783}
784
785func (m *Inbox) fetchMoreCmds() []tea.Cmd {
786 var cmds []tea.Cmd
787 limit := uint32(m.list.Height())
788 if limit < 20 {
789 limit = 20
790 }
791
792 if m.currentAccountID == "" {
793 if len(m.accounts) == 0 {
794 return nil
795 }
796 for _, acc := range m.accounts {
797 accountID := acc.ID
798 if m.noMoreByAccount[accountID] {
799 continue
800 }
801 offset := uint32(len(m.emailsByAccount[accountID]))
802 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
803 return func() tea.Msg {
804 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
805 }
806 }(accountID, offset))
807 }
808 return cmds
809 }
810
811 if m.noMoreByAccount[m.currentAccountID] {
812 return nil
813 }
814 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
815 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
816 return func() tea.Msg {
817 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
818 }
819 }(m.currentAccountID, offset))
820 return cmds
821}
822
823func (m *Inbox) View() tea.View {
824 var b strings.Builder
825
826 // Render tabs if there are multiple accounts
827 if len(m.tabs) > 1 {
828 var tabViews []string
829 for i, tab := range m.tabs {
830 label := tab.Label
831 if tab.ID == "" {
832 label = "ALL"
833 }
834
835 if i == m.activeTabIndex {
836 tabViews = append(tabViews, activeTabStyle.Render(label))
837 } else {
838 tabViews = append(tabViews, tabStyle.Render(label))
839 }
840 }
841 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
842 b.WriteString(tabBar)
843 b.WriteString("\n")
844 }
845
846 b.WriteString(m.list.View())
847
848 // Ensure we don't start gap calculation on the same line as the list
849 if !strings.HasSuffix(b.String(), "\n") {
850 b.WriteString("\n")
851 }
852
853 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
854
855 if m.height > 0 {
856 usedHeight := lipgloss.Height(b.String())
857 helpHeight := lipgloss.Height(helpView)
858
859 gap := m.height - usedHeight - helpHeight
860 if gap > 0 {
861 b.WriteString(strings.Repeat("\n", gap))
862 }
863 } else {
864 b.WriteString("\n")
865 }
866
867 b.WriteString(helpView)
868
869 return tea.NewView(b.String())
870}
871
872// GetCurrentAccountID returns the currently selected account ID
873func (m *Inbox) GetCurrentAccountID() string {
874 return m.currentAccountID
875}
876
877// GetEmailAtIndex returns the email at the given index for the current view
878func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
879 var displayEmails []fetcher.Email
880 if m.currentAccountID == "" {
881 displayEmails = m.allEmails
882 } else {
883 displayEmails = m.emailsByAccount[m.currentAccountID]
884 }
885
886 if index >= 0 && index < len(displayEmails) {
887 return &displayEmails[index]
888 }
889 return nil
890}
891
892func (m *Inbox) GetMailbox() MailboxKind {
893 return m.mailbox
894}
895
896// GetSelectedEmail returns the currently selected email, or nil if none is selected.
897func (m *Inbox) GetSelectedEmail() *fetcher.Email {
898 selectedItem, ok := m.list.SelectedItem().(item)
899 if !ok {
900 return nil
901 }
902 return m.GetEmailAtIndex(selectedItem.originalIndex)
903}
904
905// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
906func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
907 for i := range m.allEmails {
908 if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
909 m.allEmails[i].IsRead = true
910 break
911 }
912 }
913 if emails, ok := m.emailsByAccount[accountID]; ok {
914 for i := range emails {
915 if emails[i].UID == uid {
916 emails[i].IsRead = true
917 break
918 }
919 }
920 }
921 m.updateList()
922}
923
924// updateVisualSelection updates the selected UIDs based on anchor and current index
925func (m *Inbox) updateVisualSelection() {
926 if !m.visualMode {
927 return
928 }
929
930 currentIdx := m.list.Index()
931 start := m.visualAnchor
932 end := currentIdx
933
934 if start > end {
935 start, end = end, start
936 }
937
938 // Clear and rebuild selection
939 m.selectedUIDs = make(map[uint32]string)
940 m.selectionOrder = []uint32{}
941
942 items := m.list.Items()
943 firstAccountID := ""
944 for i := start; i <= end && i < len(items); i++ {
945 if itm, ok := items[i].(item); ok {
946 // Ensure all selected emails are from the same account (prevent cross-account batch ops)
947 if firstAccountID == "" {
948 firstAccountID = itm.accountID
949 }
950 if itm.accountID != firstAccountID {
951 // Don't add emails from different accounts
952 continue
953 }
954
955 if _, exists := m.selectedUIDs[itm.uid]; !exists {
956 m.selectedUIDs[itm.uid] = itm.accountID
957 m.selectionOrder = append(m.selectionOrder, itm.uid)
958 }
959 }
960 }
961
962 m.updateListTitle()
963}
964
965// updateListTitle updates the title to show selection count when in visual mode
966func (m *Inbox) updateListTitle() {
967 if m.visualMode && len(m.selectedUIDs) > 0 {
968 baseTitle := m.getBaseTitle()
969 m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
970 } else {
971 m.list.Title = m.getTitle()
972 }
973}
974
975// RemoveEmails removes multiple emails by UID and account ID (batch operation)
976func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
977 uidSet := make(map[uint32]bool)
978 for _, uid := range uids {
979 uidSet[uid] = true
980 }
981
982 // Remove from account-specific list
983 if emails, ok := m.emailsByAccount[accountID]; ok {
984 var filtered []fetcher.Email
985 for _, e := range emails {
986 if !uidSet[e.UID] {
987 filtered = append(filtered, e)
988 }
989 }
990 m.emailsByAccount[accountID] = filtered
991 }
992
993 // Remove from all emails list
994 var filteredAll []fetcher.Email
995 for _, e := range m.allEmails {
996 if !(uidSet[e.UID] && e.AccountID == accountID) {
997 filteredAll = append(filteredAll, e)
998 }
999 }
1000 m.allEmails = filteredAll
1001
1002 m.updateList()
1003}
1004
1005// RemoveEmail removes an email by UID and account ID
1006func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
1007 // Remove from account-specific list
1008 if emails, ok := m.emailsByAccount[accountID]; ok {
1009 var filtered []fetcher.Email
1010 for _, e := range emails {
1011 if e.UID != uid {
1012 filtered = append(filtered, e)
1013 }
1014 }
1015 m.emailsByAccount[accountID] = filtered
1016 }
1017
1018 // Remove from all emails list
1019 var filteredAll []fetcher.Email
1020 for _, e := range m.allEmails {
1021 if !(e.UID == uid && e.AccountID == accountID) {
1022 filteredAll = append(filteredAll, e)
1023 }
1024 }
1025 m.allEmails = filteredAll
1026
1027 m.updateList()
1028}
1029
1030// SetSize sets the width and height of the inbox, then updates the list.
1031func (m *Inbox) SetSize(width, height int) {
1032 m.width = width
1033 m.height = height
1034 m.list.SetWidth(width)
1035 m.list.SetHeight(height / 2)
1036}
1037
1038// SetFolderName sets a custom folder name for the inbox title.
1039func (m *Inbox) SetFolderName(name string) {
1040 m.folderName = name
1041 m.list.Title = m.getTitle()
1042}
1043
1044// SetPluginStatus sets a persistent status string from plugins, shown in the title.
1045func (m *Inbox) SetPluginStatus(status string) {
1046 m.pluginStatus = status
1047 m.list.Title = m.getTitle()
1048}
1049
1050// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1051func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1052 m.pluginKeyBindings = bindings
1053}
1054
1055// SetEmails updates all emails (used after fetch)
1056func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
1057 m.accounts = accounts
1058 m.allEmails = emails
1059 m.noMoreByAccount = make(map[string]bool)
1060
1061 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
1062 var tabs []AccountTab
1063 if len(accounts) <= 1 {
1064 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
1065 } else {
1066 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
1067 for _, acc := range accounts {
1068 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
1069 }
1070 }
1071 m.tabs = tabs
1072
1073 // Re-group emails by account
1074 m.emailsByAccount = make(map[string][]fetcher.Email)
1075 for _, email := range emails {
1076 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
1077 }
1078
1079 // Update email counts
1080 m.emailCountByAcct = make(map[string]int)
1081 for accID, accEmails := range m.emailsByAccount {
1082 m.emailCountByAcct[accID] = len(accEmails)
1083 }
1084
1085 m.updateList()
1086}