refactor(list-messages): switch from page-based to cursor-based pagination

- Changed `list-messages` API from page/page_size to cursor/next_page_token
- Aligns pagination approach with Gmail API for easier integration
- Frontend updates to handle cursor-based navigation
This commit is contained in:
rustmailer
2025-09-28 07:39:40 +08:00
parent 08ef77c23c
commit 16c84abe67
15 changed files with 401 additions and 145 deletions
+2 -2
View File
@@ -202,9 +202,9 @@ impl GmailClient {
account_id: u64,
use_proxy: Option<u64>,
label_id: &str,
page_token: Option<String>,
page_token: Option<&str>,
after: Option<&str>,
max_results: u32,
max_results: u64,
) -> RustMailerResult<MessageList> {
let mut url = format!(
"https://gmail.googleapis.com/gmail/v1/users/me/messages?labelIds={}&maxResults={}",
+4 -4
View File
@@ -43,9 +43,9 @@ pub async fn fetch_and_save_since_date(
account_id,
use_proxy,
&label.label_id,
page_token,
page_token.as_deref(),
Some(date),
ENVELOPE_BATCH_SIZE,
ENVELOPE_BATCH_SIZE as u64,
)
.await?;
// The total number of messages can only be retrieved via an API query
@@ -156,9 +156,9 @@ pub async fn fetch_and_save_full_label(
account_id,
use_proxy,
&label.label_id,
page_token,
page_token.as_deref(),
None,
ENVELOPE_BATCH_SIZE,
ENVELOPE_BATCH_SIZE as u64,
)
.await?;
// Update page_token returned by Gmail API
+24 -3
View File
@@ -2,7 +2,9 @@
// Licensed under RustMailer License Agreement v1.0
// Unauthorized copying, modification, or distribution is prohibited.
use crate::base64_encode_url_safe;
use crate::base64_decode_url_safe;
use crate::modules::error::RustMailerResult;
use crate::raise_error;
use super::error::code::ErrorCode;
use super::error::RustMailerError;
@@ -22,14 +24,14 @@ use tracing::error;
pub mod auth;
pub mod error;
pub mod log;
pub mod lru;
pub mod paginated;
pub mod parallel;
pub mod rustls;
pub mod signal;
pub mod timeout;
pub mod tls;
pub mod validator;
pub mod lru;
pub mod parallel;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, Object)]
pub struct Addr {
@@ -219,3 +221,22 @@ impl ResponseError for RustMailerError {
}
}
}
pub fn decode_page_token(next_page_token: Option<&str>) -> RustMailerResult<u64> {
match next_page_token {
Some(token) => {
let decoded = base64_decode_url_safe!(token)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| s.parse::<u64>().ok());
decoded.ok_or_else(|| {
raise_error!(
"Invalid next_page_token: not a valid page token".into(),
ErrorCode::InvalidParameter
)
})
}
None => Ok(1),
}
}
+2 -2
View File
@@ -85,13 +85,13 @@ impl MessageService for RustMailerMessageService {
async fn list_messages(
&self,
request: Request<ListMessagesRequest>,
) -> Result<Response<PagedMessages>, Status> {
) -> Result<Response<CursorDataPage>, Status> {
let req = require_account_access(request, |r| r.account_id)?;
let result = list_messages_in_mailbox(
req.account_id,
&req.mailbox_name,
req.page,
req.next_page_token.as_deref(),
req.page_size,
req.remote,
req.desc,
+1 -1
View File
@@ -37,7 +37,7 @@ async fn test1() {
let request = ListMessagesRequest {
account_id: id!(64),
mailbox_name: "INBOX".into(),
page: 1,
next_page_token: None,
page_size: 10,
remote: false,
desc: true,
+204 -53
View File
@@ -3,7 +3,7 @@
// Unauthorized copying, modification, or distribution is prohibited.
use crate::{
encode_mailbox_name,
base64_encode_url_safe, encode_mailbox_name,
modules::{
account::{entity::MailerType, v2::AccountV2},
cache::{
@@ -12,10 +12,11 @@ use crate::{
client::GmailClient, envelope::GmailEnvelope, labels::GmailLabels,
},
},
common::{decode_page_token, parallel::run_with_limit},
context::executors::RUST_MAIL_CONTEXT,
envelope::extractor::extract_envelope,
error::{code::ErrorCode, RustMailerResult},
rest::response::DataPage,
rest::response::{CursorDataPage, DataPage},
},
raise_error,
};
@@ -24,19 +25,29 @@ use async_imap::types::Fetch;
pub async fn list_messages_in_mailbox(
account_id: u64,
mailbox_name: &str,
page: u64,
next_page_token: Option<&str>,
page_size: u64,
remote: bool,
desc: bool,
) -> RustMailerResult<DataPage<EmailEnvelopeV3>> {
) -> RustMailerResult<CursorDataPage<EmailEnvelopeV3>> {
let account = AccountV2::check_account_active(account_id, false).await?;
validate_pagination_params(page, page_size)?;
if page_size == 0 {
return Err(raise_error!(
"page_size must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
if page_size > 500 {
return Err(raise_error!(
"The page_size exceeds the maximum allowed limit of 500.".into(),
ErrorCode::InvalidParameter
));
}
let remote = remote || account.minimal_sync();
if remote {
fetch_remote_messages(account_id, mailbox_name, page, page_size, desc).await
fetch_remote_messages(&account, mailbox_name, next_page_token, page_size, desc).await
} else {
fetch_local_messages(&account, mailbox_name, page, page_size, desc).await
fetch_local_messages(&account, mailbox_name, next_page_token, page_size, desc).await
}
}
@@ -47,9 +58,9 @@ fn validate_pagination_params(page: u64, page_size: u64) -> RustMailerResult<()>
ErrorCode::InvalidParameter
));
}
if page_size > 1000 {
if page_size > 500 {
return Err(raise_error!(
"The page_size exceeds the maximum allowed limit of 1000.".into(),
"The page_size exceeds the maximum allowed limit of 500.".into(),
ErrorCode::InvalidParameter
));
}
@@ -57,37 +68,126 @@ fn validate_pagination_params(page: u64, page_size: u64) -> RustMailerResult<()>
}
async fn fetch_remote_messages(
account_id: u64,
account: &AccountV2,
mailbox_name: &str,
page: u64,
next_page_token: Option<&str>,
page_size: u64,
desc: bool,
) -> RustMailerResult<DataPage<EmailEnvelopeV3>> {
let excutor = RUST_MAIL_CONTEXT.imap(account_id).await?;
let (mut fetches, total_items) = excutor
.retrieve_metadata_paginated(
page,
page_size,
encode_mailbox_name!(mailbox_name).as_str(),
desc,
false,
)
.await?;
) -> RustMailerResult<CursorDataPage<EmailEnvelopeV3>> {
match account.mailer_type {
MailerType::ImapSmtp => {
let page = decode_page_token(next_page_token)?;
let excutor = RUST_MAIL_CONTEXT.imap(account.id).await?;
let (mut fetches, total_items) = excutor
.retrieve_metadata_paginated(
page,
page_size,
encode_mailbox_name!(mailbox_name).as_str(),
desc,
false,
)
.await?;
if total_items == 0 {
return Ok(CursorDataPage::new(
None,
Some(page_size),
0,
Some(0),
vec![],
));
}
if desc {
fetches.reverse();
if desc {
fetches.reverse();
}
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let envelopes = process_fetches(fetches, account.id, mailbox_name).await?;
let next_page_token = if page == total_pages {
None
} else {
Some(base64_encode_url_safe!((page + 1).to_string()))
};
Ok(CursorDataPage::new(
next_page_token,
Some(page_size),
total_items,
Some(total_pages),
envelopes,
))
}
MailerType::GmailApi => {
let label_map =
GmailClient::reverse_label_map(account.id, account.use_proxy, true).await?;
let label_id = label_map.get(mailbox_name).ok_or_else(|| {
raise_error!(
format!("Label not found for mailbox: {}", mailbox_name),
ErrorCode::ResourceNotFound
)
})?;
let message_list = GmailClient::list_messages(
account.id,
account.use_proxy,
label_id,
next_page_token,
None,
page_size,
)
.await?;
let total = message_list.result_size_estimate.ok_or_else(|| {
raise_error!(
"Missing 'resultSizeEstimate' in Gmail API response".into(),
ErrorCode::InternalError
)
})?;
let messages = message_list.messages;
let messages = match messages {
Some(ref msgs) if !msgs.is_empty() => msgs,
_ => {
return Ok(CursorDataPage {
next_page_token: None,
page_size: Some(page_size),
total_items: 0,
items: vec![],
total_pages: Some(0),
})
}
};
let account_id = account.id;
let use_proxy = account.use_proxy;
let next_page_token = message_list.next_page_token;
let batch_messages =
run_with_limit(5, messages.iter().cloned(), move |index| async move {
GmailClient::get_message(account_id, use_proxy, &index.id).await
})
.await?;
let envelopes: Vec<EmailEnvelopeV3> = batch_messages
.into_iter()
.map(|m| {
let mut envelope: GmailEnvelope = m.try_into()?;
envelope.account_id = account_id;
envelope.label_name = mailbox_name.into();
Ok(envelope.into_v3(&label_map))
})
.collect::<RustMailerResult<Vec<EmailEnvelopeV3>>>()?;
let total_pages = (total as f64 / page_size as f64).ceil() as u64;
Ok(CursorDataPage {
next_page_token,
page_size: Some(page_size),
total_items: total,
items: envelopes,
total_pages: Some(total_pages),
})
}
}
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let envelopes = process_fetches(fetches, account_id, mailbox_name).await?;
Ok(DataPage::new(
Some(page),
Some(page_size),
total_items,
Some(total_pages),
envelopes,
))
}
async fn process_fetches(
@@ -106,10 +206,11 @@ async fn process_fetches(
async fn fetch_local_messages(
account: &AccountV2,
mailbox_name: &str,
page: u64,
next_page_token: Option<&str>,
page_size: u64,
desc: bool,
) -> RustMailerResult<DataPage<EmailEnvelopeV3>> {
) -> RustMailerResult<CursorDataPage<EmailEnvelopeV3>> {
let page = decode_page_token(next_page_token)?;
match account.mailer_type {
MailerType::ImapSmtp => {
let mailbox = MailBox::get(account.id, mailbox_name).await.map_err(|_| {
@@ -120,27 +221,77 @@ async fn fetch_local_messages(
ErrorCode::MailBoxNotCached
)
})?;
let DataPage {
current_page: _,
page_size,
total_items,
items,
total_pages,
} = EmailEnvelopeV3::list_messages_in_mailbox(mailbox.id, page, page_size, desc)
.await?;
EmailEnvelopeV3::list_messages_in_mailbox(mailbox.id, page, page_size, desc).await
if total_items == 0 {
Ok(CursorDataPage::new(None, page_size, 0, None, vec![]))
} else {
let total_pages = total_pages.ok_or_else(|| {
raise_error!(
"Internal error: total_pages is None (this should never happen)".into(),
ErrorCode::InternalError
)
})?;
let next_page_token = if page == total_pages {
None
} else {
Some(base64_encode_url_safe!((page + 1).to_string()))
};
Ok(CursorDataPage::new(
next_page_token,
page_size,
total_items,
Some(total_pages),
items,
))
}
}
MailerType::GmailApi => {
let target_label = GmailLabels::get_by_name(account.id, mailbox_name).await?;
let envelopes =
GmailEnvelope::list_messages_in_label(target_label.id, page, page_size, desc)
.await?;
let DataPage {
current_page: _,
page_size,
total_items,
items,
total_pages,
} = GmailEnvelope::list_messages_in_label(target_label.id, page, page_size, desc)
.await?;
let map = GmailClient::label_map(account.id, account.use_proxy).await?;
Ok(DataPage {
current_page: envelopes.current_page,
page_size: envelopes.page_size,
total_items: envelopes.total_items,
total_pages: envelopes.total_pages,
items: envelopes
.items
.into_iter()
.map(|e| e.into_v3(&map))
.collect(),
})
if total_items == 0 {
Ok(CursorDataPage::new(None, page_size, 0, None, vec![]))
} else {
let total_pages = total_pages.ok_or_else(|| {
raise_error!(
"Internal error: total_pages is None (this should never happen)".into(),
ErrorCode::InternalError
)
})?;
let next_page_token = if page == total_pages {
None
} else {
Some(base64_encode_url_safe!((page + 1).to_string()))
};
Ok(CursorDataPage::new(
next_page_token,
page_size,
total_items,
Some(total_pages),
items.into_iter().map(|e| e.into_v3(&map)).collect(),
))
}
}
}
}
+3 -17
View File
@@ -2,19 +2,20 @@
// Licensed under RustMailer License Agreement v1.0
// Unauthorized copying, modification, or distribution is prohibited.
use crate::base64_encode_url_safe;
use crate::modules::account::entity::MailerType;
use crate::modules::cache::imap::address::AddressEntity;
use crate::modules::cache::imap::sync::flow::generate_uid_sequence_hashset;
use crate::modules::cache::imap::v2::EmailEnvelopeV3;
use crate::modules::cache::vendor::gmail::sync::client::GmailClient;
use crate::modules::cache::vendor::gmail::sync::envelope::GmailEnvelope;
use crate::modules::common::decode_page_token;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::common::parallel::run_with_limit;
use crate::modules::database::Paginated;
use crate::modules::error::code::ErrorCode;
use crate::modules::message::search::cache::IMAP_SEARCH_CACHE;
use crate::modules::rest::response::CursorDataPage;
use crate::{base64_decode_url_safe, base64_encode_url_safe};
use crate::{
encode_mailbox_name,
modules::{
@@ -611,22 +612,7 @@ impl MessageSearchRequest {
));
}
let page = match next_page_token {
Some(next_page_token) => {
let decoded = base64_decode_url_safe!(next_page_token)
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|s| s.parse::<u64>().ok());
decoded.ok_or_else(|| {
raise_error!(
"Invalid next_page_token: not a valid page token".into(),
ErrorCode::InvalidParameter
)
})?
}
None => 1,
};
let page = decode_page_token(next_page_token)?;
let mailbox = self.mailbox.as_deref().ok_or_else(|| {
raise_error!(
"IMAP accounts must specify a mailbox (e.g. INBOX, Sent, or custom folder)".into(),
+7 -4
View File
@@ -125,14 +125,17 @@ impl MessageApi {
mailbox: Query<String>,
/// fetches messages from the IMAP server; otherwise, uses local data.
remote: Query<Option<bool>>,
/// The page number for pagination (1-based).
page: Query<u64>,
/// The token for fetching the next page of results in pagination.
///
/// - If `None`, this indicates that the first page should be returned.
/// - If `Some(token)`, the page corresponding to this token will be fetched.
next_page_token: Query<Option<String>>,
/// The number of messages per page.
page_size: Query<u64>,
/// lists messages in descending order; otherwise, ascending. internal date
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<EmailEnvelopeV3>>> {
) -> ApiResult<Json<CursorDataPage<EmailEnvelopeV3>>> {
let remote = remote.0.unwrap_or(false);
let desc = desc.0.unwrap_or(false);
let account_id = account_id.0;
@@ -142,7 +145,7 @@ impl MessageApi {
list_messages_in_mailbox(
account_id,
mailbox.0.trim(),
page.0,
next_page_token.0.as_deref(),
page_size.0,
remote,
desc,