diff --git a/protos/rustmailer.proto b/protos/rustmailer.proto index a1ca819..3c05401 100644 --- a/protos/rustmailer.proto +++ b/protos/rustmailer.proto @@ -915,6 +915,9 @@ enum Conditions { UNKEYWORD = 31; // Messages that do not have the \Seen flag set. UNSEEN = 32; + // This is a full Gmail search expression, only available for Gmail API accounts. + // Messages with a specific header containing the specified text + GMAIL_SEARCH = 33; } // Logic defines a logical operator (AND, OR, NOT) applied to child search conditions. @@ -939,16 +942,22 @@ enum Operator { message MessageSearchRequest { // The ID of the account. uint64 account_id = 1; - // The name of the mailbox to search within. - string mailbox_name = 2; + // The name of the mailbox to search in + // - For **IMAP accounts**, this field is **required** and specifies which mailbox + // (e.g. `INBOX`, `Sent`, or a custom folder) the search will run against. + // - For **Gmail API accounts**, this field is **optional**. If provided, it is treated + // as a label name and will override any label filter specified in the `query` string. + optional string mailbox_name = 2; // The search query. MessageSearch search = 3; - // The requested page number (1-based). - uint64 page = 4; + // 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. + optional string next_page_token = 4; // The number of messages to return per page. uint64 page_size = 5; - // If true, results will be returned in descending order. - bool desc = 6; + // If true, results will be returned in descending order. imap account only + optional bool desc = 6; } /// Request structure for unified message search across accounts. @@ -1034,6 +1043,23 @@ message PagedMessages { optional uint64 total_pages = 5; } +// CursorDataPage represents a collection of EmailEnvelope messages +// returned using cursor-based pagination rather than numeric page numbers. +message CursorDataPage { + // A cursor used for pagination (returned by the previous response). + // Pass this token to retrieve the next page of results. + // If `None`, there are no more pages available. + optional string next_page_token = 1; + // Optional: The number of items per page. + optional uint64 page_size = 2; + // The total number of items available across all pages. + uint64 total_items = 3; + // The list of EmailEnvelope items for the current page. + repeated EmailEnvelope items = 4; + // Optional: The total number of pages available. + optional uint64 total_pages = 5; +} + // A list of email envelopes. // corresponding to an EmailEnvelope array. message EmailEnvelopeList { @@ -1138,7 +1164,7 @@ service MessageService { // Fetches the complete raw EML content of an email message. rpc FetchRawMessage(FetchRawMessageRequest) returns (ByteResponse); // Searches for messages within a mailbox based on specified criteria. - rpc MessageSearch(MessageSearchRequest) returns (PagedMessages); + rpc MessageSearch(MessageSearchRequest) returns (CursorDataPage); // Performs a unified search across mail accounts and messages. rpc UnifiedSearch(UnifiedSearchRequest) returns (PagedMessages); // Creates a reply draft email linked to an existing message thread. diff --git a/src/modules/cache/imap/v2.rs b/src/modules/cache/imap/v2.rs index a9ae1d4..b64d59f 100644 --- a/src/modules/cache/imap/v2.rs +++ b/src/modules/cache/imap/v2.rs @@ -238,7 +238,7 @@ pub struct EmailEnvelopeV3 { pub mid: Option, /// A list of labels applied to the message. /// - /// Each element is a string representing a Gmail label ID (e.g., "INBOX", "UNREAD"). + /// Each element is a string representing a Gmail label name (e.g., "INBOX", "UNREAD"). /// This field reflects the current labels associated with the email. /// /// Note: This field is populated only for Gmail API accounts. For other account types, it will be empty. diff --git a/src/modules/cache/vendor/gmail/cache.rs b/src/modules/cache/vendor/gmail/cache.rs index 91039d0..bf1e120 100644 --- a/src/modules/cache/vendor/gmail/cache.rs +++ b/src/modules/cache/vendor/gmail/cache.rs @@ -10,4 +10,4 @@ use ahash::AHashMap; use crate::modules::common::lru::TimedLruCache; pub static GMAIL_LABELS_CACHE: LazyLock>> = - LazyLock::new(|| TimedLruCache::new(100, Duration::from_secs(3600))); + LazyLock::new(|| TimedLruCache::new(100, Duration::from_secs(60))); diff --git a/src/modules/cache/vendor/gmail/model/messages.rs b/src/modules/cache/vendor/gmail/model/messages.rs index 20d9bf2..4aed0b4 100644 --- a/src/modules/cache/vendor/gmail/model/messages.rs +++ b/src/modules/cache/vendor/gmail/model/messages.rs @@ -32,7 +32,7 @@ pub struct MessageList { pub next_page_token: Option, #[serde(rename = "resultSizeEstimate")] #[serde(default, skip_serializing_if = "Option::is_none")] - pub result_size_estimate: Option, + pub result_size_estimate: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/modules/common/mod.rs b/src/modules/common/mod.rs index 9a0ca8f..fb65b53 100644 --- a/src/modules/common/mod.rs +++ b/src/modules/common/mod.rs @@ -2,6 +2,8 @@ // Licensed under RustMailer License Agreement v1.0 // Unauthorized copying, modification, or distribution is prohibited. +use crate::base64_encode_url_safe; + use super::error::code::ErrorCode; use super::error::RustMailerError; use mail_parser::{Addr as ImapAddr, Address as ImapAddress}; diff --git a/src/modules/grpc/service/message/from.rs b/src/modules/grpc/service/message/from.rs index 2b01a68..e960ac1 100644 --- a/src/modules/grpc/service/message/from.rs +++ b/src/modules/grpc/service/message/from.rs @@ -9,7 +9,7 @@ use crate::modules::{ v2::EmailEnvelopeV3, }, common::Addr, - grpc::service::rustmailer_grpc::{self, PagedMessages}, + grpc::service::rustmailer_grpc::{self}, imap::section::{EmailBodyPart, Encoding, ImapAttachment, Param, PartType, SegmentPath}, message::{ append::AppendReplyToDraftRequest, @@ -23,7 +23,7 @@ use crate::modules::{ }, transfer::MailboxTransferRequest, }, - rest::response::DataPage, + rest::response::{CursorDataPage, DataPage}, }; impl From for MailboxTransferRequest { @@ -134,7 +134,19 @@ impl TryFrom for FlagMessageRequest { } } -impl From> for PagedMessages { +impl From> for rustmailer_grpc::CursorDataPage { + fn from(value: CursorDataPage) -> Self { + Self { + next_page_token: value.next_page_token, + page_size: value.page_size, + total_items: value.total_items, + items: value.items.into_iter().map(Into::into).collect(), + total_pages: value.total_pages, + } + } +} + +impl From> for rustmailer_grpc::PagedMessages { fn from(value: DataPage) -> Self { Self { current_page: value.current_page, @@ -576,6 +588,7 @@ impl TryFrom for Conditions { 30 => Ok(Conditions::Unflagged), 31 => Ok(Conditions::Unkeyword), 32 => Ok(Conditions::Unseen), + 33 => Ok(Conditions::GmailSeacrch), _ => Err("Invalid value for Conditions"), } } diff --git a/src/modules/grpc/service/message/mod.rs b/src/modules/grpc/service/message/mod.rs index effed83..8d6e3fa 100644 --- a/src/modules/grpc/service/message/mod.rs +++ b/src/modules/grpc/service/message/mod.rs @@ -8,8 +8,9 @@ use crate::modules::common::auth::ClientContext; use crate::modules::error::code::ErrorCode; use crate::modules::grpc::auth::require_account_access; use crate::modules::grpc::service::rustmailer_grpc::{ - AppendReplyToDraftRequest, ByteResponse, EmailEnvelopeList, GetThreadMessagesRequest, - ListThreadsRequest, MessageContentResponse, PagedMessages, UnifiedSearchRequest, + AppendReplyToDraftRequest, ByteResponse, CursorDataPage, EmailEnvelopeList, + GetThreadMessagesRequest, ListThreadsRequest, MessageContentResponse, PagedMessages, + UnifiedSearchRequest, }; use crate::modules::grpc::service::rustmailer_grpc::{ Empty, FetchMessageAttachmentRequest, FetchMessageContentRequest, FetchRawMessageRequest, @@ -162,17 +163,24 @@ impl MessageService for RustMailerMessageService { async fn message_search( &self, request: Request, - ) -> Result, Status> { + ) -> Result, Status> { let req = require_account_access(request, |r| r.account_id)?; let account_id = req.account_id; - let page = req.page; + let next_page_token = req.next_page_token.clone(); let page_size = req.page_size; let desc = req.desc; let request: RustMailerMessageSearchRequest = req .try_into() .map_err(|e: &'static str| raise_error!(e.to_string(), ErrorCode::InvalidParameter))?; - let result = request.search(account_id, page, page_size, desc).await?; + let result = request + .search_impl( + account_id, + next_page_token.as_deref(), + page_size, + desc.unwrap_or(true), + ) + .await?; Ok(Response::new(result.into())) } diff --git a/src/modules/message/search/payload.rs b/src/modules/message/search/payload.rs index a547c70..0e11e31 100644 --- a/src/modules/message/search/payload.rs +++ b/src/modules/message/search/payload.rs @@ -9,9 +9,12 @@ 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::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::{ @@ -82,7 +85,10 @@ pub enum Conditions { Flagged, /// Messages with the specified text in the FROM field From, + /// This is a full Gmail search expression, only available for Gmail API accounts. /// Messages with a specific header containing the specified text + GmailSeacrch, + /// Search emails by a specific header value. Header, /// Messages with the specified keyword flag set Keyword, @@ -245,6 +251,12 @@ impl MessageSearch { Conditions::Unflagged => "UNFLAGGED".into(), Conditions::Unkeyword => format!("UNKEYWORD {}", Self::quote_value(value)?), Conditions::Unseen => "UNSEEN".into(), + Conditions::GmailSeacrch => { + return Err(raise_error!( + "This condition is only supported for Gmail API accounts".into(), + ErrorCode::InvalidParameter + )); + } }; Ok(command) } @@ -309,6 +321,34 @@ impl MessageSearch { } } + pub fn to_gmail_api_search_command(&self) -> RustMailerResult { + const ERR_MSG: &str = r#"Invalid GmailSeacrch condition format. + The JSON must include: + { + "type": "Condition", + "condition": "GmailSeacrch", + "value": "from:example@example.com OR subject:\"Invoice\" after:2025/01/01" + } + - "type" must be "Condition" + - "condition" must be "GmailSeacrch" + - "value" must be a full Gmail API search query + "#; + + match self { + Self::Condition(condition) => match condition.condition { + Conditions::GmailSeacrch => { + let value = condition + .value + .as_deref() + .ok_or_else(|| raise_error!(ERR_MSG.into(), ErrorCode::InvalidParameter))?; + Ok(value.into()) + } + _ => Err(raise_error!(ERR_MSG.into(), ErrorCode::InvalidParameter)), + }, + Self::Logic(_) => Err(raise_error!(ERR_MSG.into(), ErrorCode::InvalidParameter)), + } + } + fn format_date(date: Option<&str>) -> RustMailerResult { let date = date.ok_or_else(|| { raise_error!("Date value is required".into(), ErrorCode::InvalidParameter) @@ -415,63 +455,194 @@ impl MessageSearch { pub struct MessageSearchRequest { /// The search criteria to apply (can be a simple condition or complex logical expression) pub search: MessageSearch, - /// The name of the mailbox to search in - pub mailbox: String, + /// The name of the mailbox to search in + /// - For **IMAP accounts**, this field is **required** and specifies which mailbox + /// (e.g. `INBOX`, `Sent`, or a custom folder) the search will run against. + /// - For **Gmail API accounts**, this field is **optional**. If provided, it is treated + /// as a label name and will override any label filter specified in the `query` string. + pub mailbox: Option, } impl MessageSearchRequest { - fn cache_key( + fn imap_search_cache_key( &self, account_id: u64, - page: u64, page_size: u64, desc: bool, + mailbox: &str, search_query: &str, ) -> String { format!( - "{}_{}_{}_{}_{}_{}", - account_id, self.mailbox, page, page_size, desc, search_query + "{}_{}_{}_{}_{}", + account_id, mailbox, page_size, desc, search_query ) } - pub async fn search( + + pub async fn search_impl( &self, account_id: u64, - page: u64, + next_page_token: Option<&str>, page_size: u64, desc: bool, - ) -> RustMailerResult> { + ) -> RustMailerResult> { let account = AccountV2::check_account_active(account_id, false).await?; - self.search_remote(&account, page, page_size, desc).await + match account.mailer_type { + MailerType::ImapSmtp => { + self.imap_search_impl(&account, next_page_token, page_size, desc) + .await + } + MailerType::GmailApi => { + self.gmail_api_search_impl(&account, next_page_token, page_size) + .await + } + } } - async fn search_remote( + async fn gmail_api_search_impl( &self, account: &AccountV2, - page: u64, + next_page_token: Option<&str>, page_size: u64, - desc: bool, - ) -> RustMailerResult> { - // Validate page and page_size - if page == 0 || page_size == 0 { + ) -> RustMailerResult> { + if page_size == 0 { return Err(raise_error!( - "Both page and page_size must be greater than 0.".into(), + "page_size must be greater than 0.".into(), 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 )); } + let query = self.search.to_gmail_api_search_command()?; + let label_map: AHashMap = + GmailClient::reverse_label_map(account.id, account.use_proxy, false).await?; + + let label_id = match self.mailbox.as_deref() { + Some(name) => label_map.get(name).cloned().map(Some).ok_or_else(|| { + raise_error!( + format!("Label '{}' not found in Gmail account", name), + ErrorCode::InvalidParameter + ) + })?, + None => None, + }; + + let message_list = GmailClient::search_messages( + account.id, + account.use_proxy, + label_id.as_deref(), + next_page_token, + Some(query.as_str()), + 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 = batch_messages + .into_iter() + .map(|m| { + let mut envelope: GmailEnvelope = m.try_into()?; + envelope.account_id = account_id; + Ok(envelope.into_v3(&label_map)) + }) + .collect::>>()?; + + 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), + }) + } + + async fn imap_search_impl( + &self, + account: &AccountV2, + next_page_token: Option<&str>, + page_size: u64, + desc: bool, + ) -> RustMailerResult> { + // Validate page and 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 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::().ok()); + + decoded.ok_or_else(|| { + raise_error!( + "Invalid next_page_token: not a valid page token".into(), + ErrorCode::InvalidParameter + ) + })? + } + None => 1, + }; + 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(), + ErrorCode::InvalidParameter + ) + })?; + let search_query = self.search.to_imap_command(true)?; + info!( "Executing remote search for account_id: {}, mailbox: {}, with query: {}", - account.id, self.mailbox, &search_query + account.id, mailbox, &search_query ); let excutor = RUST_MAIL_CONTEXT.imap(account.id).await?; - let cache_key = self.cache_key(account.id, page, page_size, desc, &search_query); + let cache_key = + self.imap_search_cache_key(account.id, page_size, desc, mailbox, &search_query); // Attempt to retrieve from cache if let Some(v) = IMAP_SEARCH_CACHE.get(&cache_key).await { @@ -480,8 +651,8 @@ impl MessageSearchRequest { let total_pages = (total as f64 / page_size as f64).ceil() as u64; if page > total_pages { - return Ok(DataPage::new( - Some(page), + return Ok(CursorDataPage::new( + None, Some(page_size), total, Some(total_pages), @@ -493,18 +664,24 @@ impl MessageSearchRequest { let fetches = excutor .uid_fetch_meta( current_page_uids, - encode_mailbox_name!(&self.mailbox).as_str(), + encode_mailbox_name!(mailbox).as_str(), false, ) .await?; let mut envelopes = Vec::new(); for fetch in fetches { - let envelope = extract_envelope(&fetch, account.id, &self.mailbox)?; + let envelope = extract_envelope(&fetch, account.id, mailbox)?; envelopes.push(envelope); } - return Ok(DataPage::new( - Some(page), + let next_page_token = if page == total_pages { + None + } else { + Some(base64_encode_url_safe!((page + 1).to_string())) + }; + + return Ok(CursorDataPage::new( + next_page_token, Some(page_size), total, Some(total_pages), @@ -514,14 +691,14 @@ impl MessageSearchRequest { // Cache miss, perform search and fetch data let uid_sets = excutor - .uid_search(&encode_mailbox_name!(self.mailbox), &search_query) + .uid_search(&encode_mailbox_name!(mailbox), &search_query) .await?; if uid_sets.is_empty() { IMAP_SEARCH_CACHE .set(cache_key, Arc::new((vec![], 0))) .await; - return Ok(DataPage::new( - Some(page), + return Ok(CursorDataPage::new( + None, Some(page_size), 0, None, @@ -538,8 +715,8 @@ impl MessageSearchRequest { .await; if page > total_pages { - return Ok(DataPage::new( - Some(page), + return Ok(CursorDataPage::new( + None, Some(page_size), total_items, Some(total_pages), @@ -549,20 +726,21 @@ impl MessageSearchRequest { let current_page_uids = &pages[(page - 1) as usize]; let fetches = excutor - .uid_fetch_meta( - current_page_uids, - &encode_mailbox_name!(self.mailbox), - false, - ) + .uid_fetch_meta(current_page_uids, &encode_mailbox_name!(mailbox), false) .await?; let mut envelopes = Vec::new(); for fetch in fetches { - let envelope = extract_envelope(&fetch, account.id, &self.mailbox)?; + let envelope = extract_envelope(&fetch, account.id, mailbox)?; envelopes.push(envelope); } + let next_page_token = if page == total_pages { + None + } else { + Some(base64_encode_url_safe!((page + 1).to_string())) + }; - Ok(DataPage::new( - Some(page), + Ok(CursorDataPage::new( + next_page_token, Some(page_size), total_items, Some(total_pages), diff --git a/src/modules/message/search/tests.rs b/src/modules/message/search/tests.rs index 05208d6..e26d899 100644 --- a/src/modules/message/search/tests.rs +++ b/src/modules/message/search/tests.rs @@ -4,11 +4,8 @@ #[cfg(test)] mod tests { - use std::collections::HashSet; - - use crate::modules::{ - cache::imap::sync::flow::generate_uid_sequence_hashset, - message::search::payload::{Condition, Conditions, Logic, MessageSearch, Operator}, + use crate::modules::message::search::payload::{ + Condition, Conditions, Logic, MessageSearch, Operator, }; fn cond(condition: Conditions, value: &str) -> MessageSearch { MessageSearch::Condition(Condition { diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index 1bdf6ee..e5de196 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -21,7 +21,7 @@ use crate::modules::message::transfer::{ transfer_messages, MailboxTransferRequest, MessageTransfer, }; use crate::modules::rest::api::ApiTags; -use crate::modules::rest::response::DataPage; +use crate::modules::rest::response::{CursorDataPage, DataPage}; use crate::modules::rest::ApiResult; use poem::web::Path; use poem::Body; @@ -308,23 +308,26 @@ impl MessageApi { &self, /// The ID of the account owning the mailboxes. account_id: Path, - /// The page number for pagination (1-based). - page: Query, + /// 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>, /// The number of messages per page. page_size: Query, - /// If `true`, lists results in descending order; otherwise, ascending. + /// If `true`, lists results in descending order; otherwise, ascending. imap account only desc: Query>, /// specifying the search criteria (e.g., keywords, flags). payload: Json, context: ClientContext, - ) -> ApiResult>> { + ) -> ApiResult>> { let request = payload.0; let desc = desc.0.unwrap_or(false); let account_id = account_id.0; context.require_account_access(account_id)?; Ok(Json( request - .search(account_id, page.0, page_size.0, desc) + .search_impl(account_id, next_page_token.0.as_deref(), page_size.0, desc) .await?, )) } diff --git a/src/modules/rest/response.rs b/src/modules/rest/response.rs index bc0a5c7..3d023ae 100644 --- a/src/modules/rest/response.rs +++ b/src/modules/rest/response.rs @@ -95,3 +95,72 @@ impl< } } } + +/// Represents a paginated response containing a subset of items along with pagination metadata. +/// +/// This generic structure is commonly used to return paged data from list or search endpoints. +/// The type `S` represents the individual item type within the result set. +/// +/// # Type Parameters +/// - `S`: The type of each item in the `items` list. Must implement several traits for serialization +/// and OpenAPI documentation. +/// +/// # Fields +/// - `current_page`: The current page number (1-based). `None` if unspecified or not applicable. +/// - `page_size`: The number of items per page. `None` if unspecified. +/// - `total_items`: The total number of items matching the query. +/// - `items`: The list of items returned for the current page. +/// - `total_pages`: The total number of pages available. `None` if not calculated. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Object)] +pub struct CursorDataPage +where + S: Serialize + + std::fmt::Debug + + std::marker::Unpin + + Send + + Sync + + poem_openapi::types::Type + + poem_openapi::types::ParseFromJSON + + poem_openapi::types::ToJSON, +{ + /// A cursor used for pagination (returned by the previous response). + /// Pass this token to retrieve the next page of results. + /// If `None`, there are no more pages available. + pub next_page_token: Option, + /// The number of items per page. + pub page_size: Option, + /// The total number of items across all pages. + pub total_items: u64, + /// The list of items returned on the current page. + pub items: Vec, + /// The total number of pages. This is optional and may not be set if not calculated. + pub total_pages: Option, +} + +impl< + S: Serialize + + std::fmt::Debug + + std::marker::Unpin + + Send + + Sync + + poem_openapi::types::Type + + poem_openapi::types::ParseFromJSON + + poem_openapi::types::ToJSON, + > CursorDataPage +{ + pub fn new( + next_page_token: Option, + page_size: Option, + total_items: u64, + total_pages: Option, + items: Vec, + ) -> Self { + Self { + next_page_token, + page_size, + total_items, + total_pages, + items, + } + } +}