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