fix: preserve paginated emails during background refresh (#235)

arclayto and drew created

* 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 <me@andrinoff.com>

---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: drew <me@andrinoff.com>

Change summary

go.mod       |  2 
main.go      | 76 ++++++++++++++++++++++++++++++++++++++++++-----------
tui/inbox.go | 32 ++-------------------
3 files changed, 65 insertions(+), 45 deletions(-)

Detailed changes

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
 )

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 {

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
 	}