diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 8ec150d3f5422c7e114c2f76e00c0903141e8150..1023b87284bae840d5ae02eda4c848ff7150aae3 100644 --- a/fetcher/fetcher.go +++ b/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) { diff --git a/main.go b/main.go index 501dc72a645581ce60a4a02b6c300c088ecc3e0d..c03f425cd1b9866dc417b9613d1e37f0d91e3fe1 100644 --- a/main.go +++ b/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" { diff --git a/tui/inbox.go b/tui/inbox.go index 369128a2f52a101b23d357841eddd6c134cea62b..b6c98db2ba4bc752c2dc691725a5edc2571cc7a9 100644 --- a/tui/inbox.go +++ b/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) diff --git a/tui/messages.go b/tui/messages.go index 4ca41dd029c2e8f7c7d0332918421dd302b6d6dd..340c27d918f87682a562139c5a9985befb6b1469 100644 --- a/tui/messages.go +++ b/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 }