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 var showAccountLabel bool
393
394 if m.searchActive {
395 showAccountLabel = !(len(m.accounts) <= 1)
396 } else if m.currentAccountID == "" {
397 // "ALL" view - show all emails sorted by date
398 showAccountLabel = !(len(m.accounts) <= 1)
399 }
400
401 m.emailsCount = len(displayEmails)
402
403 items := make([]list.Item, len(displayEmails))
404 for i, email := range displayEmails {
405 accountEmail := ""
406 if showAccountLabel {
407 accountEmail = m.accountLabelForEmail(email)
408 }
409
410 items[i] = item{
411 title: email.Subject,
412 desc: email.From,
413 originalIndex: i,
414 uid: email.UID,
415 accountID: email.AccountID,
416 accountEmail: accountEmail,
417 date: email.Date,
418 isRead: email.IsRead,
419 }
420 }
421
422 l := list.New(items, itemDelegate{inbox: m}, 20, 14)
423 l.Title = m.getTitle()
424 l.SetShowStatusBar(true)
425 l.SetFilteringEnabled(true)
426 l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
427 l.Styles.PaginationStyle = paginationStyle
428 l.Styles.HelpStyle = inboxHelpStyle
429 l.SetStatusBarItemName("email", "emails")
430 l.AdditionalShortHelpKeys = func() []key.Binding {
431 bindings := []key.Binding{
432 key.NewBinding(key.WithKeys("v"), key.WithHelp("v", t("inbox.visual_mode"))),
433 key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))),
434 key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))),
435 key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))),
436 key.NewBinding(key.WithKeys(searchKey()), key.WithHelp(searchKey(), t("inbox.search"))),
437 }
438 if len(m.tabs) > 1 {
439 bindings = append(bindings,
440 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
441 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
442 )
443 }
444 bindings = append(bindings, m.extraShortHelpKeys...)
445 for _, pk := range m.pluginKeyBindings {
446 bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
447 }
448 return bindings
449 }
450
451 l.KeyMap.Quit.SetEnabled(false)
452 l.KeyMap.Filter = key.NewBinding(key.WithKeys(filterKey()), key.WithHelp(filterKey(), t("inbox.filter")))
453 l.KeyMap.NextPage = key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "next page"))
454 l.KeyMap.PrevPage = key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "prev page"))
455
456 // Disable default help to render it manually at the bottom
457 l.SetShowHelp(false)
458
459 if m.width > 0 {
460 l.SetWidth(m.width)
461 }
462 if m.height > 0 {
463 l.SetHeight(m.height / 2)
464 }
465
466 // Restore index
467 // If index is out of bounds (e.g. list shrank), clamp it.
468 if currentIndex >= len(items) {
469 currentIndex = len(items) - 1
470 }
471 if currentIndex < 0 {
472 currentIndex = 0
473 }
474 l.Select(currentIndex)
475
476 m.list = l
477}
478
479func (m *Inbox) displayEmails() []fetcher.Email {
480 if m.searchActive {
481 return m.filteredSearchResults()
482 }
483 if m.currentAccountID == "" {
484 return m.allEmails
485 }
486 return m.emailsByAccount[m.currentAccountID]
487}
488
489func (m *Inbox) filteredSearchResults() []fetcher.Email {
490 if m.currentAccountID == "" {
491 return m.searchResults
492 }
493 filtered := make([]fetcher.Email, 0, len(m.searchResults))
494 for _, email := range m.searchResults {
495 if email.AccountID == m.currentAccountID {
496 filtered = append(filtered, email)
497 }
498 }
499 return filtered
500}
501
502func (m *Inbox) accountLabelForEmail(email fetcher.Email) string {
503 for _, acc := range m.accounts {
504 fetchEmail := accountDisplayEmail(acc)
505 for _, recipient := range email.To {
506 if sameEmailAddress(recipient, fetchEmail) {
507 return extractEmailAddress(recipient)
508 }
509 }
510 }
511 for _, acc := range m.accounts {
512 if acc.ID == email.AccountID {
513 return accountDisplayEmail(acc)
514 }
515 }
516 return ""
517}
518
519func dedupeEmailsForAccounts(emails []fetcher.Email, accounts []config.Account) []fetcher.Email {
520 if len(emails) <= 1 {
521 return emails
522 }
523
524 accountByID := make(map[string]config.Account, len(accounts))
525 for _, acc := range accounts {
526 accountByID[acc.ID] = acc
527 }
528
529 deduped := make([]fetcher.Email, 0, len(emails))
530 indexByKey := make(map[string]int, len(emails))
531 for _, email := range emails {
532 key := emailDedupKey(email)
533 if existingIndex, ok := indexByKey[key]; ok {
534 existing := deduped[existingIndex]
535 if !emailMatchesOwningAccount(existing, accountByID) && emailMatchesOwningAccount(email, accountByID) {
536 deduped[existingIndex] = email
537 }
538 continue
539 }
540 indexByKey[key] = len(deduped)
541 deduped = append(deduped, email)
542 }
543 return deduped
544}
545
546func emailDedupKey(email fetcher.Email) string {
547 if email.MessageID != "" {
548 return email.MessageID
549 }
550 // Malformed messages can omit Message-ID, so fall back to stable visible metadata.
551 return fmt.Sprintf("%s|%s|%d", email.From, email.Subject, email.Date.UnixNano())
552}
553
554func emailMatchesOwningAccount(email fetcher.Email, accountByID map[string]config.Account) bool {
555 acc, ok := accountByID[email.AccountID]
556 if !ok {
557 return false
558 }
559 fetchEmail := accountDisplayEmail(acc)
560 for _, recipient := range email.To {
561 if sameEmailAddress(recipient, fetchEmail) {
562 return true
563 }
564 }
565 return false
566}
567
568func accountDisplayEmail(acc config.Account) string {
569 if acc.FetchEmail != "" {
570 return acc.FetchEmail
571 }
572 return acc.Email
573}
574
575func sameEmailAddress(a, b string) bool {
576 return strings.EqualFold(extractEmailAddress(a), extractEmailAddress(b))
577}
578
579func extractEmailAddress(value string) string {
580 value = strings.TrimSpace(value)
581 if value == "" {
582 return ""
583 }
584 if addr, err := mail.ParseAddress(value); err == nil {
585 return strings.TrimSpace(addr.Address)
586 }
587 return strings.Trim(value, "<>")
588}
589
590func (m *Inbox) getTitle() string {
591 var title string
592 if m.searchActive {
593 title = fmt.Sprintf("Search Results - %s", m.searchQuery)
594 } else if m.currentAccountID == "" {
595 title = m.getBaseTitle() + " - " + t("inbox.all_accounts")
596 } else {
597 title = m.getBaseTitle()
598 for _, acc := range m.accounts {
599 if acc.ID == m.currentAccountID {
600 if acc.Name != "" {
601 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
602 } else {
603 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), accountDisplayEmail(acc))
604 }
605 break
606 }
607 }
608 }
609 if m.isRefreshing {
610 title += " (refreshing...)"
611 }
612 if m.isFetching {
613 title += " (loading more...)"
614 }
615 if m.pluginStatus != "" {
616 title += " (" + m.pluginStatus + ")"
617 }
618 return title
619}
620
621func (m *Inbox) getBaseTitle() string {
622 if m.folderName != "" {
623 return m.folderName
624 }
625 switch m.mailbox {
626 case MailboxSent:
627 return "Sent"
628 case MailboxTrash:
629 return "Trash"
630 case MailboxArchive:
631 return "Archive"
632 default:
633 return "Inbox"
634 }
635}
636
637func (m *Inbox) Init() tea.Cmd {
638 return nil
639}
640
641func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
642 var cmds []tea.Cmd
643
644 switch msg := msg.(type) {
645 case tea.KeyPressMsg:
646 if m.searchOverlay != nil {
647 if msg.String() == config.Keybinds.Global.Cancel {
648 m.searchOverlay = nil
649 return m, nil
650 }
651 cmd := m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID)
652 return m, cmd
653 }
654 if m.list.FilterState() == list.Filtering {
655 // Don't allow visual mode while filtering
656 if m.visualMode {
657 m.visualMode = false
658 m.selectedUIDs = make(map[uint32]string)
659 m.selectionOrder = []uint32{}
660 m.updateListTitle()
661 }
662 break
663 }
664 kb := config.Keybinds
665 searchBinding := searchKey()
666 switch keypress := msg.String(); keypress {
667 case searchBinding:
668 m.searchOverlay = NewSearchOverlay(m.width, m.height)
669 return m, m.searchOverlay.Init()
670 case kb.Inbox.VisualMode:
671 if !m.visualMode {
672 // Enter visual mode
673 m.visualMode = true
674 m.visualAnchor = m.list.Index()
675 selectedItem, ok := m.list.SelectedItem().(item)
676 if ok {
677 m.selectedUIDs = make(map[uint32]string)
678 m.selectionOrder = []uint32{}
679 m.selectedUIDs[selectedItem.uid] = selectedItem.accountID
680 m.selectionOrder = append(m.selectionOrder, selectedItem.uid)
681 }
682 m.updateListTitle()
683 } else {
684 // Exit visual mode
685 m.visualMode = false
686 m.selectedUIDs = make(map[uint32]string)
687 m.selectionOrder = []uint32{}
688 m.updateListTitle()
689 }
690 return m, nil
691 case kb.Global.Cancel:
692 if m.searchActive {
693 m.searchActive = false
694 m.searchQuery = ""
695 m.searchResults = nil
696 m.updateList()
697 return m, nil
698 }
699 if m.visualMode {
700 // Exit visual mode on cancel key
701 m.visualMode = false
702 m.selectedUIDs = make(map[uint32]string)
703 m.selectionOrder = []uint32{}
704 m.updateListTitle()
705 return m, nil
706 }
707 case kb.Global.NavDown, "down", kb.Global.NavUp, "up":
708 if m.visualMode {
709 // Let the list handle navigation first
710 var cmd tea.Cmd
711 m.list, cmd = m.list.Update(msg)
712 // Then update selection
713 m.updateVisualSelection()
714 return m, cmd
715 }
716 case "left", kb.Inbox.PrevTab:
717 if len(m.tabs) > 1 {
718 m.activeTabIndex--
719 if m.activeTabIndex < 0 {
720 m.activeTabIndex = len(m.tabs) - 1
721 }
722 m.currentAccountID = m.tabs[m.activeTabIndex].ID
723 // Exit visual mode when switching tabs
724 m.visualMode = false
725 m.selectedUIDs = make(map[uint32]string)
726 m.selectionOrder = []uint32{}
727 m.updateList()
728 return m, nil
729 }
730 case "right", kb.Inbox.NextTab:
731 if len(m.tabs) > 1 {
732 m.activeTabIndex++
733 if m.activeTabIndex >= len(m.tabs) {
734 m.activeTabIndex = 0
735 }
736 m.currentAccountID = m.tabs[m.activeTabIndex].ID
737 // Exit visual mode when switching tabs
738 m.visualMode = false
739 m.selectedUIDs = make(map[uint32]string)
740 m.selectionOrder = []uint32{}
741 m.updateList()
742 return m, nil
743 }
744 case kb.Inbox.Delete:
745 if m.visualMode && len(m.selectedUIDs) > 0 {
746 // Batch delete
747 uids := make([]uint32, len(m.selectionOrder))
748 copy(uids, m.selectionOrder)
749 accountID := ""
750 for _, aid := range m.selectedUIDs {
751 accountID = aid // Get any account (all should be same in single-account selection)
752 break
753 }
754
755 // Exit visual mode
756 m.visualMode = false
757 m.selectedUIDs = make(map[uint32]string)
758 m.selectionOrder = []uint32{}
759 m.updateListTitle()
760
761 return m, func() tea.Msg {
762 return BatchDeleteEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
763 }
764 } else {
765 // Single delete
766 selectedItem, ok := m.list.SelectedItem().(item)
767 if ok {
768 return m, func() tea.Msg {
769 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
770 }
771 }
772 }
773 case kb.Inbox.Archive:
774 if m.visualMode && len(m.selectedUIDs) > 0 {
775 // Batch archive
776 uids := make([]uint32, len(m.selectionOrder))
777 copy(uids, m.selectionOrder)
778 accountID := ""
779 for _, aid := range m.selectedUIDs {
780 accountID = aid
781 break
782 }
783
784 // Exit visual mode
785 m.visualMode = false
786 m.selectedUIDs = make(map[uint32]string)
787 m.selectionOrder = []uint32{}
788 m.updateListTitle()
789
790 return m, func() tea.Msg {
791 return BatchArchiveEmailsMsg{UIDs: uids, AccountID: accountID, Mailbox: m.mailbox}
792 }
793 } else {
794 // Single archive
795 selectedItem, ok := m.list.SelectedItem().(item)
796 if ok {
797 return m, func() tea.Msg {
798 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
799 }
800 }
801 }
802 case kb.Inbox.Refresh:
803 m.isRefreshing = true
804 m.list.Title = m.getTitle()
805 // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
806 counts := make(map[string]int)
807 for k, v := range m.emailCountByAcct {
808 counts[k] = v
809 }
810 return m, func() tea.Msg {
811 return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
812 }
813 case kb.Inbox.Open:
814 selectedItem, ok := m.list.SelectedItem().(item)
815 if ok {
816 idx := selectedItem.originalIndex
817 uid := selectedItem.uid
818 accountID := selectedItem.accountID
819 var email *fetcher.Email
820 if m.searchActive {
821 email = m.GetEmailAtIndex(idx)
822 }
823 return m, func() tea.Msg {
824 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox, Email: email}
825 }
826 }
827 }
828 case tea.WindowSizeMsg:
829 m.width = msg.Width
830 m.height = msg.Height
831 m.list.SetWidth(msg.Width)
832 m.list.SetHeight(msg.Height / 2)
833 if m.searchOverlay != nil {
834 return m, m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID)
835 }
836 if m.shouldFetchMore() {
837 return m, tea.Batch(m.fetchMoreCmds()...)
838 }
839 return m, nil
840
841 case SearchResultsMsg:
842 if m.searchOverlay == nil {
843 return m, nil
844 }
845 return m, m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID)
846
847 case ApplySearchResultsMsg:
848 m.searchOverlay = nil
849 m.searchActive = true
850 m.searchQuery = msg.Query.Raw
851 m.searchResults = dedupeEmailsForAccounts(msg.Emails, m.accounts)
852 m.visualMode = false
853 m.selectedUIDs = make(map[uint32]string)
854 m.selectionOrder = []uint32{}
855 m.updateList()
856 return m, nil
857
858 case FetchingMoreEmailsMsg:
859 m.isFetching = true
860 m.list.Title = m.getTitle()
861 return m, nil
862
863 case EmailsAppendedMsg:
864 if msg.Mailbox != m.mailbox {
865 return m, nil
866 }
867 m.isFetching = false
868 m.list.Title = m.getTitle()
869
870 if len(msg.Emails) == 0 {
871 if m.noMoreByAccount == nil {
872 m.noMoreByAccount = make(map[string]bool)
873 }
874 m.noMoreByAccount[msg.AccountID] = true
875 return m, nil
876 }
877
878 // Add emails to the appropriate account
879 for _, email := range msg.Emails {
880 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
881 m.allEmails = append(m.allEmails, email)
882 }
883 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
884
885 m.updateList()
886 return m, nil
887
888 case RefreshingEmailsMsg:
889 if msg.Mailbox != m.mailbox {
890 return m, nil
891 }
892 m.isRefreshing = true
893 m.list.Title = m.getTitle()
894 return m, nil
895
896 case EmailsRefreshedMsg:
897 if msg.Mailbox != m.mailbox {
898 return m, nil
899 }
900 // Only clear the refreshing indicator. The actual email data is
901 // merged by the main model (preserving paginated emails) and
902 // pushed to us via SetEmails, so we must not overwrite it here.
903 m.isRefreshing = false
904 m.list.Title = m.getTitle()
905 return m, nil
906 }
907
908 var cmd tea.Cmd
909 m.list, cmd = m.list.Update(msg)
910 cmds = append(cmds, cmd)
911
912 if m.shouldFetchMore() {
913 cmds = append(cmds, m.fetchMoreCmds()...)
914 }
915 return m, tea.Batch(cmds...)
916}
917
918func (m *Inbox) shouldFetchMore() bool {
919 if m.isFetching || m.isRefreshing {
920 return false
921 }
922 if m.searchActive {
923 return false
924 }
925 if m.allAccountsExhausted() {
926 return false
927 }
928 if len(m.list.Items()) == 0 {
929 return false
930 }
931 if m.list.FilterState() == list.Filtering {
932 return false
933 }
934 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
935 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
936}
937
938// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
939func (m *Inbox) allAccountsExhausted() bool {
940 if len(m.noMoreByAccount) == 0 {
941 return false
942 }
943 if m.currentAccountID != "" {
944 return m.noMoreByAccount[m.currentAccountID]
945 }
946 // "ALL" view: all accounts must be exhausted
947 for _, acc := range m.accounts {
948 if !m.noMoreByAccount[acc.ID] {
949 return false
950 }
951 }
952 return len(m.accounts) > 0
953}
954
955func (m *Inbox) fetchMoreCmds() []tea.Cmd {
956 var cmds []tea.Cmd
957 limit := uint32(m.list.Height())
958 if limit < 20 {
959 limit = 20
960 }
961
962 if m.currentAccountID == "" {
963 if len(m.accounts) == 0 {
964 return nil
965 }
966 for _, acc := range m.accounts {
967 accountID := acc.ID
968 if m.noMoreByAccount[accountID] {
969 continue
970 }
971 offset := uint32(len(m.emailsByAccount[accountID]))
972 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
973 return func() tea.Msg {
974 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
975 }
976 }(accountID, offset))
977 }
978 return cmds
979 }
980
981 if m.noMoreByAccount[m.currentAccountID] {
982 return nil
983 }
984 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
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 }(m.currentAccountID, offset))
990 return cmds
991}
992
993func (m *Inbox) View() tea.View {
994 var b strings.Builder
995
996 // Render tabs if there are multiple accounts
997 if len(m.tabs) > 1 {
998 var tabViews []string
999 for i, tab := range m.tabs {
1000 label := tab.Label
1001 if tab.ID == "" {
1002 label = "ALL"
1003 }
1004
1005 if i == m.activeTabIndex {
1006 tabViews = append(tabViews, activeTabStyle.Render(label))
1007 } else {
1008 tabViews = append(tabViews, tabStyle.Render(label))
1009 }
1010 }
1011 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
1012 b.WriteString(tabBar)
1013 b.WriteString("\n")
1014 }
1015
1016 b.WriteString(m.list.View())
1017
1018 if m.searchOverlay != nil {
1019 b.WriteString("\n")
1020 b.WriteString(m.searchOverlay.View())
1021 }
1022
1023 // Ensure we don't start gap calculation on the same line as the list
1024 if !strings.HasSuffix(b.String(), "\n") {
1025 b.WriteString("\n")
1026 }
1027
1028 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
1029
1030 if m.height > 0 {
1031 usedHeight := lipgloss.Height(b.String())
1032 helpHeight := lipgloss.Height(helpView)
1033
1034 gap := m.height - usedHeight - helpHeight
1035 if gap > 0 {
1036 b.WriteString(strings.Repeat("\n", gap))
1037 }
1038 } else {
1039 b.WriteString("\n")
1040 }
1041
1042 b.WriteString(helpView)
1043
1044 return tea.NewView(b.String())
1045}
1046
1047// GetCurrentAccountID returns the currently selected account ID
1048func (m *Inbox) GetCurrentAccountID() string {
1049 return m.currentAccountID
1050}
1051
1052func (m *Inbox) IsSearchActive() bool {
1053 return m != nil && (m.searchOverlay != nil || m.searchActive)
1054}
1055
1056func (m *Inbox) IsFilterActive() bool {
1057 return m != nil && (m.list.FilterState() == list.Filtering || m.list.FilterState() == list.FilterApplied)
1058}
1059
1060// GetEmailAtIndex returns the email at the given index for the current view
1061func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
1062 displayEmails := m.displayEmails()
1063
1064 if index >= 0 && index < len(displayEmails) {
1065 return &displayEmails[index]
1066 }
1067 return nil
1068}
1069
1070func (m *Inbox) GetMailbox() MailboxKind {
1071 return m.mailbox
1072}
1073
1074// GetSelectedEmail returns the currently selected email, or nil if none is selected.
1075func (m *Inbox) GetSelectedEmail() *fetcher.Email {
1076 selectedItem, ok := m.list.SelectedItem().(item)
1077 if !ok {
1078 return nil
1079 }
1080 return m.GetEmailAtIndex(selectedItem.originalIndex)
1081}
1082
1083// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
1084func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
1085 for i := range m.allEmails {
1086 if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
1087 m.allEmails[i].IsRead = true
1088 break
1089 }
1090 }
1091 if emails, ok := m.emailsByAccount[accountID]; ok {
1092 for i := range emails {
1093 if emails[i].UID == uid {
1094 emails[i].IsRead = true
1095 break
1096 }
1097 }
1098 }
1099 m.updateList()
1100}
1101
1102// updateVisualSelection updates the selected UIDs based on anchor and current index
1103func (m *Inbox) updateVisualSelection() {
1104 if !m.visualMode {
1105 return
1106 }
1107
1108 currentIdx := m.list.Index()
1109 start := m.visualAnchor
1110 end := currentIdx
1111
1112 if start > end {
1113 start, end = end, start
1114 }
1115
1116 // Clear and rebuild selection
1117 m.selectedUIDs = make(map[uint32]string)
1118 m.selectionOrder = []uint32{}
1119
1120 items := m.list.Items()
1121 firstAccountID := ""
1122 for i := start; i <= end && i < len(items); i++ {
1123 if itm, ok := items[i].(item); ok {
1124 // Ensure all selected emails are from the same account (prevent cross-account batch ops)
1125 if firstAccountID == "" {
1126 firstAccountID = itm.accountID
1127 }
1128 if itm.accountID != firstAccountID {
1129 // Don't add emails from different accounts
1130 continue
1131 }
1132
1133 if _, exists := m.selectedUIDs[itm.uid]; !exists {
1134 m.selectedUIDs[itm.uid] = itm.accountID
1135 m.selectionOrder = append(m.selectionOrder, itm.uid)
1136 }
1137 }
1138 }
1139
1140 m.updateListTitle()
1141}
1142
1143// updateListTitle updates the title to show selection count when in visual mode
1144func (m *Inbox) updateListTitle() {
1145 if m.visualMode && len(m.selectedUIDs) > 0 {
1146 baseTitle := m.getBaseTitle()
1147 m.list.Title = fmt.Sprintf("%s - VISUAL (%d selected)", baseTitle, len(m.selectedUIDs))
1148 } else {
1149 m.list.Title = m.getTitle()
1150 }
1151}
1152
1153// RemoveEmails removes multiple emails by UID and account ID (batch operation)
1154func (m *Inbox) RemoveEmails(uids []uint32, accountID string) {
1155 uidSet := make(map[uint32]bool)
1156 for _, uid := range uids {
1157 uidSet[uid] = true
1158 }
1159
1160 // Remove from account-specific list
1161 if emails, ok := m.emailsByAccount[accountID]; ok {
1162 var filtered []fetcher.Email
1163 for _, e := range emails {
1164 if !uidSet[e.UID] {
1165 filtered = append(filtered, e)
1166 }
1167 }
1168 m.emailsByAccount[accountID] = filtered
1169 }
1170
1171 // Remove from all emails list
1172 var filteredAll []fetcher.Email
1173 for _, e := range m.allEmails {
1174 if !(uidSet[e.UID] && e.AccountID == accountID) {
1175 filteredAll = append(filteredAll, e)
1176 }
1177 }
1178 m.allEmails = filteredAll
1179
1180 m.updateList()
1181}
1182
1183// RemoveEmail removes an email by UID and account ID
1184func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
1185 // Remove from account-specific list
1186 if emails, ok := m.emailsByAccount[accountID]; ok {
1187 var filtered []fetcher.Email
1188 for _, e := range emails {
1189 if e.UID != uid {
1190 filtered = append(filtered, e)
1191 }
1192 }
1193 m.emailsByAccount[accountID] = filtered
1194 }
1195
1196 // Remove from all emails list
1197 var filteredAll []fetcher.Email
1198 for _, e := range m.allEmails {
1199 if !(e.UID == uid && e.AccountID == accountID) {
1200 filteredAll = append(filteredAll, e)
1201 }
1202 }
1203 m.allEmails = filteredAll
1204
1205 m.updateList()
1206}
1207
1208// SetSize sets the width and height of the inbox, then updates the list.
1209func (m *Inbox) SetSize(width, height int) {
1210 m.width = width
1211 m.height = height
1212 m.list.SetWidth(width)
1213 m.list.SetHeight(height / 2)
1214}
1215
1216// SetFolderName sets a custom folder name for the inbox title.
1217func (m *Inbox) SetFolderName(name string) {
1218 m.folderName = name
1219 m.list.Title = m.getTitle()
1220}
1221
1222// SetPluginStatus sets a persistent status string from plugins, shown in the title.
1223func (m *Inbox) SetPluginStatus(status string) {
1224 m.pluginStatus = status
1225 m.list.Title = m.getTitle()
1226}
1227
1228// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
1229func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
1230 m.pluginKeyBindings = bindings
1231}
1232
1233// SetEmails updates all emails (used after fetch)
1234func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
1235 m.accounts = accounts
1236 m.allEmails = dedupeEmailsForAccounts(emails, accounts)
1237 m.noMoreByAccount = make(map[string]bool)
1238
1239 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
1240 var tabs []AccountTab
1241 if len(accounts) <= 1 {
1242 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
1243 } else {
1244 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
1245 for _, acc := range accounts {
1246 displayEmail := accountDisplayEmail(acc)
1247 tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
1248 }
1249 }
1250 m.tabs = tabs
1251
1252 // Re-group emails by account
1253 m.emailsByAccount = make(map[string][]fetcher.Email)
1254 for _, email := range emails {
1255 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
1256 }
1257
1258 // Update email counts
1259 m.emailCountByAcct = make(map[string]int)
1260 for accID, accEmails := range m.emailsByAccount {
1261 m.emailCountByAcct[accID] = len(accEmails)
1262 }
1263
1264 m.updateList()
1265}