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"))
30
31type item struct {
32 title, desc string
33 originalIndex int
34 uid uint32
35 accountID string
36 accountEmail string
37 date time.Time
38 isRead bool
39}
40
41func (i item) Title() string { return i.title }
42func (i item) Description() string { return i.desc }
43func (i item) FilterValue() string { return i.title + " " + i.desc }
44
45type itemDelegate struct{}
46
47func (d itemDelegate) Height() int { return 1 }
48func (d itemDelegate) Spacing() int { return 0 }
49func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
50func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
51 i, ok := listItem.(item)
52 if !ok {
53 return
54 }
55
56 prefix := fmt.Sprintf("%d. ", index+1)
57 sender := parseSenderName(i.desc)
58 statusStyle := unreadEmailStyle
59 statusIcon := "\uf0e0"
60 if i.isRead {
61 statusStyle = readEmailStyle
62 statusIcon = "\uf2b6"
63 }
64 styledIcon := statusStyle.Render(statusIcon)
65 styledSender := statusStyle.Render(sender)
66 separator := " · "
67
68 // For "ALL" view, show account indicator instead of number
69 if i.accountEmail != "" {
70 prefix = fmt.Sprintf("%d. [%s] ", index+1, truncateEmail(i.accountEmail))
71 }
72
73 // Format and right-align date
74 dateStr := formatRelativeDate(i.date)
75 styledDate := dateStyle.Render(dateStr)
76 if i.isRead {
77 styledDate = readEmailStyle.Render(dateStr)
78 } else {
79 styledDate = statusStyle.Render(dateStr)
80 }
81 dateWidth := lipgloss.Width(styledDate)
82
83 listWidth := m.Width()
84 isSelected := index == m.Index()
85 cursorWidth := 0
86 if isSelected {
87 cursorWidth = 2 // "> " prefix
88 }
89
90 // Available width for the whole left side (prefix + sender + separator + subject)
91 maxLeft := listWidth - dateWidth - 2 - cursorWidth // 2 for spacing
92 if maxLeft < 10 {
93 maxLeft = 10
94 }
95
96 prefixWidth := lipgloss.Width(prefix)
97 iconWidth := lipgloss.Width(styledIcon) + 1
98 senderWidth := lipgloss.Width(styledSender)
99 sepWidth := len(separator)
100 subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth
101
102 subject := i.title
103 if subjectBudget < 4 {
104 subjectBudget = 4
105 }
106 if lipgloss.Width(subject) > subjectBudget {
107 for lipgloss.Width(subject) > subjectBudget-1 && len(subject) > 0 {
108 subject = subject[:len(subject)-1]
109 }
110 subject += "…"
111 }
112 styledSubject := statusStyle.Render(subject)
113
114 str := prefix + styledIcon + " " + styledSender + separator + styledSubject
115
116 // Pad to push date to the right
117 padding := listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
118 if padding < 1 {
119 padding = 1
120 }
121
122 fn := itemStyle.Render
123 if index == m.Index() {
124 fn = func(s ...string) string {
125 return selectedItemStyle.Render("> " + s[0])
126 }
127 }
128
129 fmt.Fprint(w, fn(str+strings.Repeat(" ", padding)+styledDate))
130}
131
132// formatRelativeDate formats a time as relative if within the last week,
133// otherwise as an absolute date.
134func formatRelativeDate(t time.Time) string {
135 if t.IsZero() {
136 return ""
137 }
138 now := time.Now()
139 d := now.Sub(t)
140
141 switch {
142 case d < time.Minute:
143 return "just now"
144 case d < time.Hour:
145 mins := int(d.Minutes())
146 if mins == 1 {
147 return "1 min ago"
148 }
149 return fmt.Sprintf("%d min ago", mins)
150 case d < 24*time.Hour:
151 hours := int(d.Hours())
152 if hours == 1 {
153 return "1 hour ago"
154 }
155 return fmt.Sprintf("%d hours ago", hours)
156 case d < 7*24*time.Hour:
157 days := int(d.Hours() / 24)
158 if days == 1 {
159 return "1 day ago"
160 }
161 return fmt.Sprintf("%d days ago", days)
162 default:
163 if t.Year() == now.Year() {
164 return t.Format("Jan 02")
165 }
166 return t.Format("Jan 02, 2006")
167 }
168}
169
170// parseSenderName extracts the display name from a "Name <email>" string,
171// falling back to the local part of the email address.
172func parseSenderName(from string) string {
173 if idx := strings.Index(from, " <"); idx > 0 {
174 return strings.TrimSpace(from[:idx])
175 }
176 // No display name — use local part of email
177 if idx := strings.Index(from, "@"); idx > 0 {
178 return from[:idx]
179 }
180 return from
181}
182
183// truncateEmail shortens an email for display
184func truncateEmail(email string) string {
185 parts := strings.Split(email, "@")
186 if len(parts) >= 1 && len(parts[0]) > 8 {
187 return parts[0][:8] + "..."
188 }
189 if len(parts) >= 1 {
190 return parts[0]
191 }
192 return email
193}
194
195// AccountTab represents a tab for an account
196type AccountTab struct {
197 ID string
198 Label string
199 Email string
200}
201
202type Inbox struct {
203 list list.Model
204 isFetching bool
205 isRefreshing bool
206 emailsCount int
207 accounts []config.Account
208 emailsByAccount map[string][]fetcher.Email
209 allEmails []fetcher.Email
210 tabs []AccountTab
211 activeTabIndex int
212 width int
213 height int
214 currentAccountID string // Empty means "ALL"
215 emailCountByAcct map[string]int
216 mailbox MailboxKind
217 folderName string // Custom folder name override for title
218 noMoreByAccount map[string]bool // Per-account: true when pagination returns 0 results
219 extraShortHelpKeys []key.Binding
220 pluginStatus string // Persistent status text set by plugins
221 pluginKeyBindings []PluginKeyBinding
222}
223
224func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
225 return NewInboxWithMailbox(emails, accounts, MailboxInbox)
226}
227
228func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
229 return NewInboxWithMailbox(emails, accounts, MailboxSent)
230}
231
232func NewTrashInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
233 return NewInboxWithMailbox(emails, accounts, MailboxTrash)
234}
235
236func NewArchiveInbox(emails []fetcher.Email, accounts []config.Account) *Inbox {
237 return NewInboxWithMailbox(emails, accounts, MailboxArchive)
238}
239
240func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox {
241 // Build tabs: empty for single account, "ALL" + accounts for multiple
242 var tabs []AccountTab
243 if len(accounts) <= 1 {
244 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
245 } else {
246 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
247 for _, acc := range accounts {
248 // Use FetchEmail for display, fall back to Email if not set
249 displayEmail := acc.FetchEmail
250 if displayEmail == "" {
251 displayEmail = acc.Email
252 }
253 tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail})
254 }
255 }
256
257 // Group emails by account
258 emailsByAccount := make(map[string][]fetcher.Email)
259 for _, email := range emails {
260 emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email)
261 }
262
263 // Track email counts per account
264 emailCountByAcct := make(map[string]int)
265 for accID, accEmails := range emailsByAccount {
266 emailCountByAcct[accID] = len(accEmails)
267 }
268
269 inbox := &Inbox{
270 accounts: accounts,
271 emailsByAccount: emailsByAccount,
272 allEmails: emails,
273 tabs: tabs,
274 activeTabIndex: 0,
275 currentAccountID: "",
276 emailCountByAcct: emailCountByAcct,
277 mailbox: mailbox,
278 }
279
280 inbox.updateList()
281 return inbox
282}
283
284// NewInboxSingleAccount creates an inbox for a single account (legacy support)
285func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
286 return NewInbox(emails, nil)
287}
288
289func (m *Inbox) updateList() {
290 // Capture current index to restore later
291 currentIndex := m.list.Index()
292
293 var displayEmails []fetcher.Email
294 var showAccountLabel bool
295
296 if m.currentAccountID == "" {
297 // "ALL" view - show all emails sorted by date
298 displayEmails = m.allEmails
299 showAccountLabel = !(len(m.accounts) <= 1)
300 } else {
301 // Specific account view
302 displayEmails = m.emailsByAccount[m.currentAccountID]
303 showAccountLabel = false
304 }
305
306 m.emailsCount = len(displayEmails)
307
308 items := make([]list.Item, len(displayEmails))
309 for i, email := range displayEmails {
310 accountEmail := ""
311 if showAccountLabel {
312 // Find the account email for display
313 for _, acc := range m.accounts {
314 if acc.ID == email.AccountID {
315 accountEmail = acc.FetchEmail
316 break
317 }
318 }
319 }
320
321 items[i] = item{
322 title: email.Subject,
323 desc: email.From,
324 originalIndex: i,
325 uid: email.UID,
326 accountID: email.AccountID,
327 accountEmail: accountEmail,
328 date: email.Date,
329 isRead: email.IsRead,
330 }
331 }
332
333 l := list.New(items, itemDelegate{}, 20, 14)
334 l.Title = m.getTitle()
335 l.SetShowStatusBar(true)
336 l.SetFilteringEnabled(true)
337 l.Styles.Title = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Bold(true)
338 l.Styles.PaginationStyle = paginationStyle
339 l.Styles.HelpStyle = inboxHelpStyle
340 l.SetStatusBarItemName("email", "emails")
341 l.AdditionalShortHelpKeys = func() []key.Binding {
342 bindings := []key.Binding{
343 key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", "delete")),
344 key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", "archive")),
345 key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", "refresh")),
346 }
347 if len(m.tabs) > 1 {
348 bindings = append(bindings,
349 key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")),
350 key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")),
351 )
352 }
353 bindings = append(bindings, m.extraShortHelpKeys...)
354 for _, pk := range m.pluginKeyBindings {
355 bindings = append(bindings, key.NewBinding(key.WithKeys(pk.Key), key.WithHelp(pk.Key, pk.Description)))
356 }
357 return bindings
358 }
359
360 l.KeyMap.Quit.SetEnabled(false)
361
362 // Disable default help to render it manually at the bottom
363 l.SetShowHelp(false)
364
365 if m.width > 0 {
366 l.SetWidth(m.width)
367 }
368 if m.height > 0 {
369 l.SetHeight(m.height / 2)
370 }
371
372 // Restore index
373 // If index is out of bounds (e.g. list shrank), clamp it.
374 if currentIndex >= len(items) {
375 currentIndex = len(items) - 1
376 }
377 if currentIndex < 0 {
378 currentIndex = 0
379 }
380 l.Select(currentIndex)
381
382 m.list = l
383}
384
385func (m *Inbox) getTitle() string {
386 var title string
387 if m.currentAccountID == "" {
388 title = m.getBaseTitle() + " - All Accounts"
389 } else {
390 title = m.getBaseTitle()
391 for _, acc := range m.accounts {
392 if acc.ID == m.currentAccountID {
393 if acc.Name != "" {
394 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name)
395 } else {
396 title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail)
397 }
398 break
399 }
400 }
401 }
402 if m.isRefreshing {
403 title += " (refreshing...)"
404 }
405 if m.isFetching {
406 title += " (loading more...)"
407 }
408 if m.pluginStatus != "" {
409 title += " (" + m.pluginStatus + ")"
410 }
411 return title
412}
413
414func (m *Inbox) getBaseTitle() string {
415 if m.folderName != "" {
416 return m.folderName
417 }
418 switch m.mailbox {
419 case MailboxSent:
420 return "Sent"
421 case MailboxTrash:
422 return "Trash"
423 case MailboxArchive:
424 return "Archive"
425 default:
426 return "Inbox"
427 }
428}
429
430func (m *Inbox) Init() tea.Cmd {
431 return nil
432}
433
434func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
435 var cmds []tea.Cmd
436
437 switch msg := msg.(type) {
438 case tea.KeyPressMsg:
439 if m.list.FilterState() == list.Filtering {
440 break
441 }
442 switch keypress := msg.String(); keypress {
443 case "left", "h":
444 if len(m.tabs) > 1 {
445 m.activeTabIndex--
446 if m.activeTabIndex < 0 {
447 m.activeTabIndex = len(m.tabs) - 1
448 }
449 m.currentAccountID = m.tabs[m.activeTabIndex].ID
450 m.updateList()
451 return m, nil
452 }
453 case "right", "l":
454 if len(m.tabs) > 1 {
455 m.activeTabIndex++
456 if m.activeTabIndex >= len(m.tabs) {
457 m.activeTabIndex = 0
458 }
459 m.currentAccountID = m.tabs[m.activeTabIndex].ID
460 m.updateList()
461 return m, nil
462 }
463 case "d":
464 selectedItem, ok := m.list.SelectedItem().(item)
465 if ok {
466 return m, func() tea.Msg {
467 return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
468 }
469 }
470 case "a":
471 selectedItem, ok := m.list.SelectedItem().(item)
472 if ok {
473 return m, func() tea.Msg {
474 return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox}
475 }
476 }
477 case "r":
478 m.isRefreshing = true
479 m.list.Title = m.getTitle()
480 // Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
481 counts := make(map[string]int)
482 for k, v := range m.emailCountByAcct {
483 counts[k] = v
484 }
485 return m, func() tea.Msg {
486 return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
487 }
488 case "enter":
489 selectedItem, ok := m.list.SelectedItem().(item)
490 if ok {
491 idx := selectedItem.originalIndex
492 uid := selectedItem.uid
493 accountID := selectedItem.accountID
494 return m, func() tea.Msg {
495 return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox}
496 }
497 }
498 }
499 case tea.WindowSizeMsg:
500 m.width = msg.Width
501 m.height = msg.Height
502 m.list.SetWidth(msg.Width)
503 m.list.SetHeight(msg.Height / 2)
504 if m.shouldFetchMore() {
505 return m, tea.Batch(m.fetchMoreCmds()...)
506 }
507 return m, nil
508
509 case FetchingMoreEmailsMsg:
510 m.isFetching = true
511 m.list.Title = m.getTitle()
512 return m, nil
513
514 case EmailsAppendedMsg:
515 if msg.Mailbox != m.mailbox {
516 return m, nil
517 }
518 m.isFetching = false
519 m.list.Title = m.getTitle()
520
521 if len(msg.Emails) == 0 {
522 if m.noMoreByAccount == nil {
523 m.noMoreByAccount = make(map[string]bool)
524 }
525 m.noMoreByAccount[msg.AccountID] = true
526 return m, nil
527 }
528
529 // Add emails to the appropriate account
530 for _, email := range msg.Emails {
531 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
532 m.allEmails = append(m.allEmails, email)
533 }
534 m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
535
536 m.updateList()
537 return m, nil
538
539 case RefreshingEmailsMsg:
540 if msg.Mailbox != m.mailbox {
541 return m, nil
542 }
543 m.isRefreshing = true
544 m.list.Title = m.getTitle()
545 return m, nil
546
547 case EmailsRefreshedMsg:
548 if msg.Mailbox != m.mailbox {
549 return m, nil
550 }
551 // Only clear the refreshing indicator. The actual email data is
552 // merged by the main model (preserving paginated emails) and
553 // pushed to us via SetEmails, so we must not overwrite it here.
554 m.isRefreshing = false
555 m.list.Title = m.getTitle()
556 return m, nil
557 }
558
559 var cmd tea.Cmd
560 m.list, cmd = m.list.Update(msg)
561 cmds = append(cmds, cmd)
562
563 if m.shouldFetchMore() {
564 cmds = append(cmds, m.fetchMoreCmds()...)
565 }
566 return m, tea.Batch(cmds...)
567}
568
569func (m *Inbox) shouldFetchMore() bool {
570 if m.isFetching || m.isRefreshing {
571 return false
572 }
573 if m.allAccountsExhausted() {
574 return false
575 }
576 if len(m.list.Items()) == 0 {
577 return false
578 }
579 if m.list.FilterState() == list.Filtering {
580 return false
581 }
582 // Fetch if we've reached the bottom OR if we don't have enough items to fill the view
583 return m.list.Index() >= len(m.list.Items())-1 || len(m.list.Items()) < m.list.Height()
584}
585
586// allAccountsExhausted returns true if all relevant accounts have no more emails to fetch.
587func (m *Inbox) allAccountsExhausted() bool {
588 if len(m.noMoreByAccount) == 0 {
589 return false
590 }
591 if m.currentAccountID != "" {
592 return m.noMoreByAccount[m.currentAccountID]
593 }
594 // "ALL" view: all accounts must be exhausted
595 for _, acc := range m.accounts {
596 if !m.noMoreByAccount[acc.ID] {
597 return false
598 }
599 }
600 return len(m.accounts) > 0
601}
602
603func (m *Inbox) fetchMoreCmds() []tea.Cmd {
604 var cmds []tea.Cmd
605 limit := uint32(m.list.Height())
606 if limit < 20 {
607 limit = 20
608 }
609
610 if m.currentAccountID == "" {
611 if len(m.accounts) == 0 {
612 return nil
613 }
614 for _, acc := range m.accounts {
615 accountID := acc.ID
616 if m.noMoreByAccount[accountID] {
617 continue
618 }
619 offset := uint32(len(m.emailsByAccount[accountID]))
620 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
621 return func() tea.Msg {
622 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
623 }
624 }(accountID, offset))
625 }
626 return cmds
627 }
628
629 if m.noMoreByAccount[m.currentAccountID] {
630 return nil
631 }
632 offset := uint32(len(m.emailsByAccount[m.currentAccountID]))
633 cmds = append(cmds, func(id string, off uint32) tea.Cmd {
634 return func() tea.Msg {
635 return FetchMoreEmailsMsg{Offset: off, AccountID: id, Mailbox: m.mailbox, Limit: limit}
636 }
637 }(m.currentAccountID, offset))
638 return cmds
639}
640
641func (m *Inbox) View() tea.View {
642 var b strings.Builder
643
644 // Render tabs if there are multiple accounts
645 if len(m.tabs) > 1 {
646 var tabViews []string
647 for i, tab := range m.tabs {
648 label := tab.Label
649 if tab.ID == "" {
650 label = "ALL"
651 }
652
653 if i == m.activeTabIndex {
654 tabViews = append(tabViews, activeTabStyle.Render(label))
655 } else {
656 tabViews = append(tabViews, tabStyle.Render(label))
657 }
658 }
659 tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
660 b.WriteString(tabBar)
661 b.WriteString("\n")
662 }
663
664 b.WriteString(m.list.View())
665
666 // Ensure we don't start gap calculation on the same line as the list
667 if !strings.HasSuffix(b.String(), "\n") {
668 b.WriteString("\n")
669 }
670
671 helpView := inboxHelpStyle.Render(m.list.Help.View(m.list))
672
673 if m.height > 0 {
674 usedHeight := lipgloss.Height(b.String())
675 helpHeight := lipgloss.Height(helpView)
676
677 gap := m.height - usedHeight - helpHeight
678 if gap > 0 {
679 b.WriteString(strings.Repeat("\n", gap))
680 }
681 } else {
682 b.WriteString("\n")
683 }
684
685 b.WriteString(helpView)
686
687 return tea.NewView(b.String())
688}
689
690// GetCurrentAccountID returns the currently selected account ID
691func (m *Inbox) GetCurrentAccountID() string {
692 return m.currentAccountID
693}
694
695// GetEmailAtIndex returns the email at the given index for the current view
696func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email {
697 var displayEmails []fetcher.Email
698 if m.currentAccountID == "" {
699 displayEmails = m.allEmails
700 } else {
701 displayEmails = m.emailsByAccount[m.currentAccountID]
702 }
703
704 if index >= 0 && index < len(displayEmails) {
705 return &displayEmails[index]
706 }
707 return nil
708}
709
710func (m *Inbox) GetMailbox() MailboxKind {
711 return m.mailbox
712}
713
714// GetSelectedEmail returns the currently selected email, or nil if none is selected.
715func (m *Inbox) GetSelectedEmail() *fetcher.Email {
716 selectedItem, ok := m.list.SelectedItem().(item)
717 if !ok {
718 return nil
719 }
720 return m.GetEmailAtIndex(selectedItem.originalIndex)
721}
722
723// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
724func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
725 for i := range m.allEmails {
726 if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
727 m.allEmails[i].IsRead = true
728 break
729 }
730 }
731 if emails, ok := m.emailsByAccount[accountID]; ok {
732 for i := range emails {
733 if emails[i].UID == uid {
734 emails[i].IsRead = true
735 break
736 }
737 }
738 }
739 m.updateList()
740}
741
742// RemoveEmail removes an email by UID and account ID
743func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
744 // Remove from account-specific list
745 if emails, ok := m.emailsByAccount[accountID]; ok {
746 var filtered []fetcher.Email
747 for _, e := range emails {
748 if e.UID != uid {
749 filtered = append(filtered, e)
750 }
751 }
752 m.emailsByAccount[accountID] = filtered
753 }
754
755 // Remove from all emails list
756 var filteredAll []fetcher.Email
757 for _, e := range m.allEmails {
758 if !(e.UID == uid && e.AccountID == accountID) {
759 filteredAll = append(filteredAll, e)
760 }
761 }
762 m.allEmails = filteredAll
763
764 m.updateList()
765}
766
767// SetSize sets the width and height of the inbox, then updates the list.
768func (m *Inbox) SetSize(width, height int) {
769 m.width = width
770 m.height = height
771 m.list.SetWidth(width)
772 m.list.SetHeight(height / 2)
773}
774
775// SetFolderName sets a custom folder name for the inbox title.
776func (m *Inbox) SetFolderName(name string) {
777 m.folderName = name
778 m.list.Title = m.getTitle()
779}
780
781// SetPluginStatus sets a persistent status string from plugins, shown in the title.
782func (m *Inbox) SetPluginStatus(status string) {
783 m.pluginStatus = status
784 m.list.Title = m.getTitle()
785}
786
787// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
788func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) {
789 m.pluginKeyBindings = bindings
790}
791
792// SetEmails updates all emails (used after fetch)
793func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) {
794 m.accounts = accounts
795 m.allEmails = emails
796 m.noMoreByAccount = make(map[string]bool)
797
798 // Rebuild tabs: empty for single account, "ALL" + accounts for multiple
799 var tabs []AccountTab
800 if len(accounts) <= 1 {
801 tabs = []AccountTab{{ID: "", Label: "", Email: ""}}
802 } else {
803 tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}}
804 for _, acc := range accounts {
805 tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email})
806 }
807 }
808 m.tabs = tabs
809
810 // Re-group emails by account
811 m.emailsByAccount = make(map[string][]fetcher.Email)
812 for _, email := range emails {
813 m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email)
814 }
815
816 // Update email counts
817 m.emailCountByAcct = make(map[string]int)
818 for accID, accEmails := range m.emailsByAccount {
819 m.emailCountByAcct[accID] = len(accEmails)
820 }
821
822 m.updateList()
823}