fix: refresh/load-more cursor reset (#170) (#177)

Drew Smirnoff created

Change summary

fetcher/fetcher.go | 168 ++++++++++++++++++++++++++++-------------------
main.go            |  65 ++++++++++++++---
tui/inbox.go       |  20 +++++
tui/messages.go    |   1 
4 files changed, 171 insertions(+), 83 deletions(-)

Detailed changes

fetcher/fetcher.go 🔗

@@ -179,101 +179,131 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 		return []Email{}, nil
 	}
 
-	to := mbox.Messages - offset
-	from := uint32(1)
-	if to > limit {
-		from = to - limit + 1
-	}
+	var allEmails []Email
 
-	if to < 1 {
+	// Start from the top minus offset
+	cursor := uint32(0)
+	if mbox.Messages > offset {
+		cursor = mbox.Messages - offset
+	} else {
 		return []Email{}, nil
 	}
 
-	seqset := new(imap.SeqSet)
-	seqset.AddRange(from, to)
-
-	messages := make(chan *imap.Message, limit)
-	done := make(chan error, 1)
-	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
-	go func() {
-		done <- c.Fetch(seqset, fetchItems, messages)
-	}()
-
-	var msgs []*imap.Message
-	for msg := range messages {
-		msgs = append(msgs, msg)
-	}
-
-	if err := <-done; err != nil {
-		return nil, err
+	// Determine if we should filter
+	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
+	if fetchEmail == "" {
+		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
 	}
+	isSentMailbox := mailbox == getSentMailbox(account)
 
-	var emails []Email
-	for _, msg := range msgs {
-		if msg == nil || msg.Envelope == nil {
-			continue
+	// Loop until we have enough emails or run out of messages
+	for len(allEmails) < int(limit) && cursor > 0 {
+		// Determine chunk size
+		// Fetch at least 'limit' or 50 messages to reduce round trips
+		chunkSize := limit
+		if chunkSize < 50 {
+			chunkSize = 50
 		}
 
-		var fromAddr string
-		if len(msg.Envelope.From) > 0 {
-			fromAddr = msg.Envelope.From[0].Address()
+		from := uint32(1)
+		if cursor > uint32(chunkSize) {
+			from = cursor - uint32(chunkSize) + 1
 		}
 
-		var toAddrList []string
-		// Build recipient list from To and Cc for matching and display
-		for _, addr := range msg.Envelope.To {
-			toAddrList = append(toAddrList, addr.Address())
-		}
-		for _, addr := range msg.Envelope.Cc {
-			toAddrList = append(toAddrList, addr.Address())
+		seqset := new(imap.SeqSet)
+		seqset.AddRange(from, cursor)
+
+		messages := make(chan *imap.Message, chunkSize)
+		done := make(chan error, 1)
+		fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
+
+		go func() {
+			done <- c.Fetch(seqset, fetchItems, messages)
+		}()
+
+		var batchMsgs []*imap.Message
+		for msg := range messages {
+			batchMsgs = append(batchMsgs, msg)
 		}
 
-		// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
-		fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
-		if fetchEmail == "" {
-			fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
+		if err := <-done; err != nil {
+			return nil, err
 		}
 
-		// Determine if this is a sent mailbox
-		isSentMailbox := mailbox == getSentMailbox(account)
+		// Filter messages in this batch
+		var batchEmails []Email
+		for _, msg := range batchMsgs {
+			if msg == nil || msg.Envelope == nil {
+				continue
+			}
 
-		// Apply different filtering logic based on mailbox type
-		matched := false
-		if isSentMailbox {
-			// For sent mailbox, check if the sender matches the fetchEmail
-			if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
-				matched = true
+			var fromAddr string
+			if len(msg.Envelope.From) > 0 {
+				fromAddr = msg.Envelope.From[0].Address()
 			}
-		} else {
-			// For inbox and other mailboxes, check if any recipient matches the fetchEmail
-			for _, r := range toAddrList {
-				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
+
+			var toAddrList []string
+			for _, addr := range msg.Envelope.To {
+				toAddrList = append(toAddrList, addr.Address())
+			}
+			for _, addr := range msg.Envelope.Cc {
+				toAddrList = append(toAddrList, addr.Address())
+			}
+
+			matched := false
+			if isSentMailbox {
+				if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
 					matched = true
-					break
+				}
+			} else {
+				for _, r := range toAddrList {
+					if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
+						matched = true
+						break
+					}
 				}
 			}
-		}
 
-		if !matched {
-			// Skip messages not matching the filter criteria
-			continue
+			if !matched {
+				continue
+			}
+
+			batchEmails = append(batchEmails, Email{
+				UID:       msg.Uid,
+				From:      fromAddr,
+				To:        toAddrList,
+				Subject:   decodeHeader(msg.Envelope.Subject),
+				Date:      msg.Envelope.Date,
+				AccountID: account.ID,
+			})
+		}
+
+		// Sort batch Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary)
+		// Assuming seqset order or standard behavior, we want to ensure we append Newest emails first
+		// so that the final list is correct.
+		// Actually, let's just sort the batch by UID desc (Newest first)
+		// Simple bubble sort for small batch
+		for i := 0; i < len(batchEmails); i++ {
+			for j := i + 1; j < len(batchEmails); j++ {
+				if batchEmails[j].UID > batchEmails[i].UID {
+					batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
+				}
+			}
 		}
 
-		emails = append(emails, Email{
-			UID:       msg.Uid,
-			From:      fromAddr,
-			To:        toAddrList,
-			Subject:   decodeHeader(msg.Envelope.Subject),
-			Date:      msg.Envelope.Date,
-			AccountID: account.ID,
-		})
+		// Append to allEmails
+		allEmails = append(allEmails, batchEmails...)
+
+		// Update cursor for next iteration
+		cursor = from - 1
 	}
 
-	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
-		emails[i], emails[j] = emails[j], emails[i]
+	// Trim if we have too many
+	if len(allEmails) > int(limit) {
+		allEmails = allEmails[:limit]
 	}
 
-	return emails, nil
+	return allEmails, nil
 }
 
 func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {

main.go 🔗

@@ -32,6 +32,7 @@ import (
 const (
 	initialEmailLimit = 20
 	paginationLimit   = 20
+	maxCacheEmails    = 100
 )
 
 // Version variables are injected by the build (GoReleaser ldflags).
@@ -280,17 +281,22 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current = m.inbox
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 
+		counts := make(map[string]int)
+		for k, v := range emailsByAcct {
+			counts[k] = len(v)
+		}
+
 		// Start background refresh
 		return m, tea.Batch(
 			m.current.Init(),
 			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: tui.MailboxInbox} },
-			refreshEmails(m.config, tui.MailboxInbox),
+			refreshEmails(m.config, tui.MailboxInbox, counts),
 		)
 
 	case tui.RequestRefreshMsg:
 		return m, tea.Batch(
 			func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: msg.Mailbox} },
-			refreshEmails(m.config, msg.Mailbox),
+			refreshEmails(m.config, msg.Mailbox, msg.Counts),
 		)
 
 	case tui.EmailsRefreshedMsg:
@@ -424,32 +430,40 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if m.sentByAcct == nil {
 				m.sentByAcct = make(map[string][]fetcher.Email)
 			}
-			m.sentByAcct[msg.AccountID] = append(m.sentByAcct[msg.AccountID], msg.Emails...)
-			m.sentEmails = append(m.sentEmails, msg.Emails...)
+			unique := filterUnique(m.sentByAcct[msg.AccountID], msg.Emails)
+			m.sentByAcct[msg.AccountID] = append(m.sentByAcct[msg.AccountID], unique...)
+			m.sentEmails = append(m.sentEmails, unique...)
 			return m, nil
 		}
 		if msg.Mailbox == tui.MailboxTrash {
 			if m.trashByAcct == nil {
 				m.trashByAcct = make(map[string][]fetcher.Email)
 			}
-			m.trashByAcct[msg.AccountID] = append(m.trashByAcct[msg.AccountID], msg.Emails...)
-			m.trashEmails = append(m.trashEmails, msg.Emails...)
+			unique := filterUnique(m.trashByAcct[msg.AccountID], msg.Emails)
+			m.trashByAcct[msg.AccountID] = append(m.trashByAcct[msg.AccountID], unique...)
+			m.trashEmails = append(m.trashEmails, unique...)
 			return m, nil
 		}
 		if msg.Mailbox == tui.MailboxArchive {
 			if m.archiveByAcct == nil {
 				m.archiveByAcct = make(map[string][]fetcher.Email)
 			}
-			m.archiveByAcct[msg.AccountID] = append(m.archiveByAcct[msg.AccountID], msg.Emails...)
-			m.archiveEmails = append(m.archiveEmails, msg.Emails...)
+			unique := filterUnique(m.archiveByAcct[msg.AccountID], msg.Emails)
+			m.archiveByAcct[msg.AccountID] = append(m.archiveByAcct[msg.AccountID], unique...)
+			m.archiveEmails = append(m.archiveEmails, unique...)
 			return m, nil
 		}
 		// Inbox
 		if m.emailsByAcct == nil {
 			m.emailsByAcct = make(map[string][]fetcher.Email)
 		}
-		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], msg.Emails...)
-		m.emails = append(m.emails, msg.Emails...)
+		unique := filterUnique(m.emailsByAcct[msg.AccountID], msg.Emails)
+		m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], unique...)
+		m.emails = append(m.emails, unique...)
+
+		// Save to cache
+		go saveEmailsToCache(m.emails)
+
 		return m, nil
 
 	case tui.GoToSendMsg:
@@ -1179,7 +1193,7 @@ func loadCachedEmails() tea.Cmd {
 	}
 }
 
-func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
+func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind, counts map[string]int) tea.Cmd {
 	return func() tea.Msg {
 		emailsByAccount := make(map[string][]fetcher.Email)
 		var mu sync.Mutex
@@ -1191,10 +1205,18 @@ func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
 				defer wg.Done()
 				var emails []fetcher.Email
 				var err error
+
+				limit := uint32(initialEmailLimit)
+				if counts != nil {
+					if c, ok := counts[acc.ID]; ok && c > 0 {
+						limit = uint32(c)
+					}
+				}
+
 				if mailbox == tui.MailboxSent {
-					emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0)
+					emails, err = fetcher.FetchSentEmails(&acc, limit, 0)
 				} else {
-					emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0)
+					emails, err = fetcher.FetchEmails(&acc, limit, 0)
 				}
 				if err != nil {
 					log.Printf("Error fetching from %s: %v", acc.Email, err)
@@ -1212,6 +1234,9 @@ func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd {
 }
 
 func saveEmailsToCache(emails []fetcher.Email) {
+	if len(emails) > maxCacheEmails {
+		emails = emails[:maxCacheEmails]
+	}
 	var cachedEmails []config.CachedEmail
 	for _, email := range emails {
 		cachedEmails = append(cachedEmails, config.CachedEmail{
@@ -1792,6 +1817,20 @@ func runUpdateCLI() error {
 	return nil
 }
 
+func filterUnique(existing, incoming []fetcher.Email) []fetcher.Email {
+	seen := make(map[uint32]struct{})
+	for _, e := range existing {
+		seen[e.UID] = struct{}{}
+	}
+	var unique []fetcher.Email
+	for _, e := range incoming {
+		if _, ok := seen[e.UID]; !ok {
+			unique = append(unique, e)
+		}
+	}
+	return unique
+}
+
 func main() {
 	// If invoked as CLI update command, run updater and exit.
 	if len(os.Args) > 1 && os.Args[1] == "update" {

tui/inbox.go 🔗

@@ -163,6 +163,9 @@ func NewInboxSingleAccount(emails []fetcher.Email) *Inbox {
 }
 
 func (m *Inbox) updateList() {
+	// Capture current index to restore later
+	currentIndex := m.list.Index()
+
 	var displayEmails []fetcher.Email
 	var showAccountLabel bool
 
@@ -236,6 +239,16 @@ func (m *Inbox) updateList() {
 		l.SetHeight(m.height / 2)
 	}
 
+	// Restore index
+	// If index is out of bounds (e.g. list shrank), clamp it.
+	if currentIndex >= len(items) {
+		currentIndex = len(items) - 1
+	}
+	if currentIndex < 0 {
+		currentIndex = 0
+	}
+	l.Select(currentIndex)
+
 	m.list = l
 }
 
@@ -326,8 +339,13 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			}
 		case "r":
+			// Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
+			counts := make(map[string]int)
+			for k, v := range m.emailCountByAcct {
+				counts[k] = v
+			}
 			return m, func() tea.Msg {
-				return RequestRefreshMsg{Mailbox: m.mailbox}
+				return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
 			}
 		case "enter":
 			selectedItem, ok := m.list.SelectedItem().(item)

tui/messages.go 🔗

@@ -281,4 +281,5 @@ type EmailsRefreshedMsg struct {
 // RequestRefreshMsg signals a request to refresh emails from the server.
 type RequestRefreshMsg struct {
 	Mailbox MailboxKind
+	Counts  map[string]int
 }