From 27b7b1f06224a7bf77121c0cd4bc1f9eff654349 Mon Sep 17 00:00:00 2001 From: arclayto Date: Tue, 3 Mar 2026 23:35:46 -0600 Subject: [PATCH] fix: preserve paginated emails during background refresh (#235) * fix: preserve paginated emails during background refresh EmailsRefreshedMsg was overwriting m.emailsByAcct and rebuilding m.emails from scratch, discarding any emails loaded via pagination. This caused a race where paginated emails appeared in the inbox list but could not be opened because they no longer existed in the main model's email store. Merge refreshed emails with existing paginated emails instead of replacing them. Also remove an unnecessary getEmailByUIDAndAccount lookup in ViewEmailMsg that silently blocked opening emails not yet in the store, and drop the unused email parameter from fetchEmailBodyCmd. * fix: restore email existence guard in ViewEmailMsg Keep the pre-fetch check to avoid unnecessary IMAP server calls for emails not in the local store. The merge fix in EmailsRefreshedMsg ensures paginated emails are preserved, so this guard now works correctly. * fix: prevent email view from closing after paginated emails load Three interconnected issues caused the email view to auto-close when opening an email that was loaded via pagination: 1. The inbox's EmailsRefreshedMsg handler overwrote its internal email data with the raw (unmerged) refresh response, discarding paginated emails. Since main.go already merges the data and pushes it via SetEmails, the inbox handler now only clears the refreshing flag. 2. EmailsRefreshedMsg was forwarded to m.current twice: once at the top of Update (line 110) and again explicitly. This second call could target the wrong component or overwrite correctly merged data. Fixed by calling m.inbox.Update() directly to clear the refreshing state. 3. The EmailsFetchedMsg handler unconditionally set m.current = m.inbox, which would close the email view if a pagination fetch with offset 0 completed while the user was reading an email. Fixed by only switching to inbox from a loading screen, not from an active email view. Signed-off-by: drew --------- Signed-off-by: drew Co-authored-by: drew --- go.mod | 2 +- main.go | 76 +++++++++++++++++++++++++++++++++++++++++----------- tui/inbox.go | 32 +++------------------- 3 files changed, 65 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 86bc607ee7208a818308d3c100f746aea650e4a6..1e2400a0eac934e43ec448d2bea435198b025626 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/yuin/goldmark v1.7.16 github.com/zalando/go-keyring v0.2.6 + go.mozilla.org/pkcs7 v0.9.0 golang.org/x/sys v0.41.0 golang.org/x/text v0.34.0 ) @@ -37,7 +38,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.mozilla.org/pkcs7 v0.9.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect ) diff --git a/main.go b/main.go index 56a45c77311a629c2179c39d5c1d6e807d28a692..7e49dbe3be28ad54e476252089aff4eb526e5c41 100644 --- a/main.go +++ b/main.go @@ -322,17 +322,51 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.EmailsRefreshedMsg: if msg.Mailbox == tui.MailboxSent { - m.sentByAcct = msg.EmailsByAccount - m.sentEmails = flattenAndSort(msg.EmailsByAccount) + for accID, refreshed := range msg.EmailsByAccount { + refreshedUIDs := make(map[uint32]struct{}, len(refreshed)) + for _, e := range refreshed { + refreshedUIDs[e.UID] = struct{}{} + } + if existing, ok := m.sentByAcct[accID]; ok { + for _, e := range existing { + if _, found := refreshedUIDs[e.UID]; !found { + refreshed = append(refreshed, e) + } + } + } + m.sentByAcct[accID] = refreshed + } + m.sentEmails = flattenAndSort(m.sentByAcct) if m.sentInbox != nil { m.sentInbox.SetEmails(m.sentEmails, m.config.Accounts) - m.current, _ = m.current.Update(msg) + // Clear refreshing state on the sent inbox directly so we + // don't accidentally swap m.current away from an email view. + m.sentInbox.Update(msg) } return m, nil } - m.emailsByAcct = msg.EmailsByAccount - m.emails = flattenAndSort(msg.EmailsByAccount) + // Merge refreshed emails with any paginated emails already loaded. + // The refresh only fetches the initial batch; paginated emails beyond + // that must be preserved. + for accID, refreshed := range msg.EmailsByAccount { + refreshedUIDs := make(map[uint32]struct{}, len(refreshed)) + for _, e := range refreshed { + refreshedUIDs[e.UID] = struct{}{} + } + // Keep paginated emails not covered by the refresh + if existing, ok := m.emailsByAcct[accID]; ok { + for _, e := range existing { + if _, found := refreshedUIDs[e.UID]; !found { + refreshed = append(refreshed, e) + } + } + } + m.emailsByAcct[accID] = refreshed + } + // Accounts not in the refresh response (e.g. fetch failed) are + // kept as-is since m.emailsByAcct was not cleared. + m.emails = flattenAndSort(m.emailsByAcct) // Save to cache (inbox only) go saveEmailsToCache(m.emails) @@ -340,8 +374,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update inbox if it exists if m.inbox != nil { m.inbox.SetEmails(m.emails, m.config.Accounts) - // Forward the message to inbox to clear refreshing state - m.current, _ = m.current.Update(msg) + // Clear refreshing state on the inbox directly so we don't + // accidentally swap m.current away from an email view. + m.inbox.Update(msg) } return m, nil @@ -422,12 +457,22 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.emails = flattenAndSort(m.emailsByAcct) if m.inbox == nil { m.inbox = tui.NewInbox(m.emails, m.config.Accounts) - } else { - m.inbox.SetEmails(m.emails, m.config.Accounts) + m.current = m.inbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() } - m.current = m.inbox - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - return m, m.current.Init() + m.inbox.SetEmails(m.emails, m.config.Accounts) + // Only switch to inbox if we're on a loading/status screen, not + // when the user is viewing an email or composing. + if _, onInbox := m.current.(*tui.Inbox); onInbox { + return m, nil + } + if _, onStatus := m.current.(tui.Status); onStatus { + m.current = m.inbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + return m, nil case tui.FetchMoreEmailsMsg: if msg.AccountID == "" { @@ -604,12 +649,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() case tui.ViewEmailMsg: - email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) - if email == nil { + if m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) == nil { return m, nil } m.current = tui.NewStatus("Fetching email content...") - return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID, msg.Mailbox)) + return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, msg.UID, msg.AccountID, msg.Mailbox)) case tui.EmailBodyFetchedMsg: if msg.Err != nil { @@ -1354,7 +1398,7 @@ func parseEmailAddress(addr string) (name, email string) { return name, email } -func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { +func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { account := cfg.GetAccountByID(accountID) if account == nil { diff --git a/tui/inbox.go b/tui/inbox.go index 630848bf72620a97f40de8c0e85f9d095cb92288..afde54e69f5ee63b159ff9d5f905d04617586a6d 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -403,35 +403,11 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Mailbox != m.mailbox { return m, nil } + // Only clear the refreshing indicator. The actual email data is + // merged by the main model (preserving paginated emails) and + // pushed to us via SetEmails, so we must not overwrite it here. m.isRefreshing = false - - // Replace emails with fresh data - m.emailsByAccount = msg.EmailsByAccount - - // Flatten all emails - var allEmails []fetcher.Email - for _, emails := range msg.EmailsByAccount { - allEmails = append(allEmails, emails...) - } - - // Sort by date (newest first) - for i := 0; i < len(allEmails); i++ { - for j := i + 1; j < len(allEmails); j++ { - if allEmails[j].Date.After(allEmails[i].Date) { - allEmails[i], allEmails[j] = allEmails[j], allEmails[i] - } - } - } - - m.allEmails = allEmails - - // Update email counts - m.emailCountByAcct = make(map[string]int) - for accID, accEmails := range m.emailsByAccount { - m.emailCountByAcct[accID] = len(accEmails) - } - - m.updateList() + m.list.Title = m.getTitle() return m, nil }