From 41fbf0b7d955d44adbd1cc6b012a2f0ef6ba011e Mon Sep 17 00:00:00 2001 From: Brad Arant Date: Mon, 7 Sep 2026 04:20:42 +0000 Subject: [PATCH] Add pagination to the email list (was hardcoded to newest 50, no way to see older mail) listMessages() only ever fetched everything then kept the newest limit (default 50) messages, with no offset parameter and no way for the client to know how many messages existed beyond what it was shown - older mail was completely unreachable through the webmail UI. __imap::listMessages() now takes an offset (counted back from the newest message) and returns {"total":N,"messages":[...]} instead of a bare array, so the client can page backward/forward and know when it has reached either end. __imaplist tag and /email/list both pass offset through; /email/search is unchanged (search results are not paginated). email.js tracks currentOffset (reset alongside currentFolder/currentUid at every existing reset site), and renders Prev/Next controls next to the unread count showing the current 1-based range and total. --- __imap.cpp | 32 ++++++---- __imap.h | 4 +- __imaplist.cpp | 7 ++- jetc/barant-site/assets/images/email.js | 77 +++++++++++++++++++++++-- jetc/barant-site/src/email.jet | 2 +- 5 files changed, 103 insertions(+), 19 deletions(-) diff --git a/__imap.cpp b/__imap.cpp index 2b5edb4..b2506e2 100644 --- a/__imap.cpp +++ b/__imap.cpp @@ -832,7 +832,7 @@ namespace jet { return extractLiteralAt(fetchLine, braceEnd); } - coreutils::MString __imap::listMessages(const std::string &folder, int limit) { + coreutils::MString __imap::listMessages(const std::string &folder, int limit, int offset) { if (!selectFolderIfNeeded(folder)) throw coreutils::Exception("imap: could not select folder " + folder, __FILE__, __LINE__, 502); @@ -843,22 +843,30 @@ namespace jet { throw coreutils::Exception("imap: FETCH failed", __FILE__, __LINE__, 502); std::vector items = parseSummaryLines(lines); + int total = (int)items.size(); - // Keep only the newest `limit` - UIDs are assigned in strictly - // ascending delivery order (see MailFolder.h, BARANTMail, built - // earlier this session), so the tail of this UID-ascending list - // is the newest mail. - if ((int)items.size() > limit) - items.erase(items.begin(), items.end() - limit); - std::reverse(items.begin(), items.end()); + // items is UID-ascending (oldest-first) - UIDs are assigned in + // strictly ascending delivery order (see MailFolder.h, BARANTMail, + // built earlier this session), so the tail is the newest mail. + // `offset` counts back from the newest message (0 = the very + // newest), so the page is the `limit`-wide slice ending `offset` + // messages from the end. + int end = total - offset; + if (end < 0) + end = 0; + int start = end - limit; + if (start < 0) + start = 0; + std::vector page(items.begin() + start, items.begin() + end); + std::reverse(page.begin(), page.end()); - std::string json = "["; - for (size_t i = 0; i < items.size(); ++i) { + std::string json = "{\"total\":" + std::to_string(total) + ",\"messages\":["; + for (size_t i = 0; i < page.size(); ++i) { if (i) json += ","; - json += items[i]; + json += page[i]; } - json += "]"; + json += "]}"; return coreutils::MString(json); } diff --git a/__imap.h b/__imap.h index b04d44b..7d396d9 100644 --- a/__imap.h +++ b/__imap.h @@ -55,7 +55,9 @@ namespace jet { // just write whatever this returns straight into their own `out`, // the same one-call-does-everything shape __sql's query() call // has relative to . - coreutils::MString listMessages(const std::string &folder, int limit); + /// offset counts back from the newest message (0 = the newest + /// page); the returned JSON is {"total":N,"messages":[...]}. + coreutils::MString listMessages(const std::string &folder, int limit, int offset); coreutils::MString searchMessages(const std::string &folder, const std::string &query); coreutils::MString fetchMessage(const std::string &folder, uint32_t uid); coreutils::MString setFlag(const std::string &folder, uint32_t uid, const std::string &flag, bool add); diff --git a/__imaplist.cpp b/__imaplist.cpp index 46acb69..3c4e049 100644 --- a/__imaplist.cpp +++ b/__imaplist.cpp @@ -20,7 +20,12 @@ namespace jet { if (limit <= 0) limit = 50; - out = global.getImapSession(sessionId)->listMessages(folder, limit); + std::string offsetStr = resolveKeyword("offset").str(); + int offset = offsetStr.empty() ? 0 : atoi(offsetStr.c_str()); + if (offset < 0) + offset = 0; + + out = global.getImapSession(sessionId)->listMessages(folder, limit, offset); } } diff --git a/jetc/barant-site/assets/images/email.js b/jetc/barant-site/assets/images/email.js index 9313894..f7ec8cd 100644 --- a/jetc/barant-site/assets/images/email.js +++ b/jetc/barant-site/assets/images/email.js @@ -35,6 +35,14 @@ '' + '' + '' + + '', + pagePrev: + '' + + '' + + '', + pageNext: + '' + + '' + '' }; @@ -49,6 +57,19 @@ // showing search results instead (see performSearch()/clearSearch() // and loadMessageList()'s own branch on this). var currentSearchQuery = null; + // How many newest-first messages /email/list has already skipped - + // 0 is the newest page. Must match LIST_LIMIT server-side (email.jet + // defaults "limit" to 50 too) so a page is exactly one "limit" + // worth of messages. Reset to 0 everywhere currentFolder/currentUid + // already get reset (folder switch, search, account switch) - a + // stale offset from one folder/account applied to another would + // silently skip or duplicate messages. + var currentOffset = 0; + var LIST_LIMIT = 50; + // Total message count the server reported for the CURRENT page's + // folder/query, used only to enable/disable the Next button - + // updated every time updateListCount() runs. + var currentTotal = 0; // The array last handed to renderMessageList() - kept so openMessage() // can flip a single message's .seen flag locally and recompute the // toolbar's unread/total count without a round trip. @@ -70,6 +91,7 @@ return; currentFolder = folder; currentUid = null; + currentOffset = 0; // A folder click always means "show me this folder, plain" - drops // out of search mode even if the folder didn't actually change, // which is exactly how a user would expect to back out of a @@ -129,6 +151,7 @@ '
' + '' + escapeHtml(userEmail) + '' + '' + + '' + '' + '' + '' + @@ -207,6 +230,7 @@ var query = input.value.trim(); currentSearchQuery = query === '' ? null : query; currentUid = null; + currentOffset = 0; document.getElementById('email-reading-pane').innerHTML = '
Select a message to read it.
'; loadMessageList(); @@ -234,10 +258,51 @@ countEl.textContent = '(' + unread + '/' + messages.length + ')'; } - function renderMessageList(messages) { + // total === null means "not paginated" (search results, or nothing + // loaded yet) - hides the whole control rather than showing a + // meaningless "1-0 of null". Otherwise renders the current page's + // 1-based range and enables Prev/Next based on currentOffset/total, + // matching how far a click on either button would actually move. + function updatePaginationControls(total) { + var el = document.getElementById('email-pagination'); + if (!el) + return; + currentTotal = total || 0; + if (total === null || total === 0) { + el.innerHTML = ''; + return; + } + var rangeStart = total === 0 ? 0 : currentOffset + 1; + var rangeEnd = Math.min(currentOffset + LIST_LIMIT, total); + var atStart = currentOffset <= 0; + var atEnd = currentOffset + LIST_LIMIT >= total; + el.innerHTML = + '' + + '' + rangeStart + '–' + rangeEnd + ' of ' + total + '' + + ''; + var prevBtn = document.getElementById('email-page-prev'); + var nextBtn = document.getElementById('email-page-next'); + if (prevBtn) prevBtn.addEventListener('click', function() { + if (currentOffset <= 0) + return; + currentOffset = Math.max(0, currentOffset - LIST_LIMIT); + currentUid = null; + loadMessageList(); + }); + if (nextBtn) nextBtn.addEventListener('click', function() { + if (currentOffset + LIST_LIMIT >= currentTotal) + return; + currentOffset += LIST_LIMIT; + currentUid = null; + loadMessageList(); + }); + } + + function renderMessageList(messages, total) { var list = document.getElementById('email-list'); currentMessages = messages; updateListCount(messages); + updatePaginationControls(total === undefined ? null : total); if (!messages.length) { list.innerHTML = '
No messages.
'; return; @@ -397,10 +462,11 @@ // (see __imap.cpp's shared parseSummaryLines()), so this is the // only place that needs to know search mode even exists - // renderMessageList() below has no idea which one produced what - // it's rendering. + // it's rendering. Search isn't paginated (offset/limit only apply + // to the plain listing) - a search result set is returned in full. var request = currentSearchQuery ? post('/email/search', { folder: currentFolder, query: currentSearchQuery }) - : post('/email/list', { folder: currentFolder }); + : post('/email/list', { folder: currentFolder, offset: currentOffset, limit: LIST_LIMIT }); return Promise.all([ request, skipFade ? true : delay(180) @@ -409,8 +475,10 @@ var res = results[0]; if (!res.ok && res.error) list.innerHTML = '
' + escapeHtml(res.error) + '
'; + else if (currentSearchQuery) + renderMessageList(Array.isArray(res) ? res : [], null); else - renderMessageList(Array.isArray(res) ? res : []); + renderMessageList(Array.isArray(res.messages) ? res.messages : [], res.total || 0); // Every place that reloads the list (connect, folder switch, // refresh, post-trash) is a place the unseen counts next to // each folder name could plausibly have changed too - one @@ -482,6 +550,7 @@ function resetViewForActiveAccount(user) { currentFolder = 'Inbox'; currentUid = null; + currentOffset = 0; currentSearchQuery = null; var searchInput = document.getElementById('email-search-input'); if (searchInput) searchInput.value = ''; diff --git a/jetc/barant-site/src/email.jet b/jetc/barant-site/src/email.jet index 1fa0525..8bc4209 100644 --- a/jetc/barant-site/src/email.jet +++ b/jetc/barant-site/src/email.jet @@ -67,7 +67,7 @@ - + {"ok":false,"error":"$[_imaperr]"}