From 70db81dc03d96a44a4858f8e30203b663e0e8b11 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Sun, 14 Dec 2025 11:12:16 +0100 Subject: [PATCH] feat(api): Add envelope endpoint and improve API documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /envelope/{account_id}/{message_id} endpoint to retrieve message envelope (metadata) - Add get_envelope_by_id method to ENVELOPE_INDEX_MANAGER for querying single envelope - Move message_id from query parameter to path parameter for clearer API paths: - /message-content/{account_id}/{message_id} - /download-message/{account_id}/{message_id} - /download-attachment/{account_id}/{message_id} - /envelope/{account_id}/{message_id} - Fix API documentation descriptions to be more accurate: - search_messages: Now correctly describes search functionality - get_thread_messages: Mentions thread_id requirement - proxy endpoints: Fixed copy-paste errors from OAuth2 docs - Update frontend API client to use new path-based URLs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 2 +- src/modules/indexer/manager.rs | 41 +++++++++++++++ src/modules/rest/api/message.rs | 80 +++++++++++++++++++++-------- src/modules/rest/api/system.rs | 6 +-- web/src/api/mailbox/envelope/api.ts | 8 +-- 5 files changed, 109 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4fae5ce..6c7b3b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,7 +424,7 @@ dependencies = [ [[package]] name = "bichon" -version = "0.1.3" +version = "0.1.4" dependencies = [ "ahash", "async-imap", diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index 8e1b4ce..1e648fe 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -741,6 +741,47 @@ impl EnvelopeIndexManager { }) } + pub async fn get_envelope_by_id( + &self, + account_id: u64, + message_id: u64, + ) -> BichonResult> { + let searcher = self.create_searcher()?; + let f = SchemaTools::envelope_fields(); + + let query = BooleanQuery::new(vec![ + ( + Occur::Must, + Box::new(TermQuery::new( + Term::from_field_u64(f.f_account_id, account_id), + IndexRecordOption::Basic, + )), + ), + ( + Occur::Must, + Box::new(TermQuery::new( + Term::from_field_u64(f.f_id, message_id), + IndexRecordOption::Basic, + )), + ), + ]); + + let docs: Vec<(f32, DocAddress)> = searcher + .search(&query, &TopDocs::with_limit(1)) + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + + if let Some((_, doc_address)) = docs.first() { + let doc: TantivyDocument = searcher + .doc_async(*doc_address) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + let envelope = Envelope::from_tantivy_doc(&doc).await?; + Ok(Some(envelope)) + } else { + Ok(None) + } + } + pub async fn top_10_largest_emails(&self) -> BichonResult> { self.reader .reload() diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index 5696e27..97d3556 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -32,9 +32,8 @@ use crate::modules::rest::response::DataPage; use crate::modules::rest::ApiResult; use crate::modules::rest::ErrorCode; use crate::raise_error; -use poem::web::Path; use poem::Body; -use poem_openapi::param::Query; +use poem_openapi::param::{Path, Query}; use poem_openapi::payload::{Attachment, AttachmentType, Json}; use poem_openapi::OpenApi; use std::collections::HashMap; @@ -63,7 +62,7 @@ impl MessageApi { Ok(delete_messages_impl(request).await?) } - /// Lists messages in a specified mailbox for the given account. + /// Lists messages in a mailbox. Requires `mailbox_id`, `page`, and `page_size` query parameters. #[oai( path = "/list-messages/:account_id", method = "get", @@ -71,7 +70,9 @@ impl MessageApi { )] async fn list_messages( &self, + /// The ID of the account. account_id: Path, + /// The ID of the mailbox to list messages from. mailbox_id: Query, page: Query, page_size: Query, @@ -85,7 +86,8 @@ impl MessageApi { )) } - /// Lists messages in a specified mailbox for the given account. + /// Searches messages across all mailboxes using various filter criteria. + /// The search filters are provided in the request body. #[oai( path = "/search-messages", method = "post", @@ -100,7 +102,7 @@ impl MessageApi { Ok(Json(search_messages_impl(payload.0).await?)) } - /// Get thread's envelopes in a specified mailbox for the given account. + /// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters. #[oai( path = "/get-thread-messages/:account_id", method = "get", @@ -126,67 +128,105 @@ impl MessageApi { )) } - /// Fetches the content of a specific email for the given account. + /// Fetches the content of a specific email. #[oai( - path = "/message-content/:account_id", + path = "/message-content/:account_id/:message_id", method = "get", operation_id = "fetch_message_content" )] async fn fetch_message_content( &self, + /// The ID of the account. account_id: Path, - id: Query, + /// The ID of the message to fetch. + message_id: Path, context: ClientContext, ) -> ApiResult> { let account_id = account_id.0; context.require_account_access(account_id)?; - Ok(Json(retrieve_email_content(account_id, id.0).await?)) + Ok(Json(retrieve_email_content(account_id, message_id.0).await?)) } - /// Fetches the full content of a specific email for the given account. + /// Retrieves the envelope (metadata) of a specific message. #[oai( - path = "/download-message/:account_id", + path = "/envelope/:account_id/:message_id", + method = "get", + operation_id = "get_envelope" + )] + async fn get_envelope( + &self, + /// The ID of the account. + account_id: Path, + /// The ID of the message. + message_id: Path, + context: ClientContext, + ) -> ApiResult> { + let account_id = account_id.0; + context.require_account_access(account_id)?; + let envelope = ENVELOPE_INDEX_MANAGER + .get_envelope_by_id(account_id, message_id.0) + .await? + .ok_or_else(|| { + raise_error!( + format!( + "Envelope not found: account_id={} message_id={}", + account_id, message_id.0 + ), + ErrorCode::ResourceNotFound + ) + })?; + Ok(Json(envelope)) + } + + /// Downloads the raw EML file of a specific email. + #[oai( + path = "/download-message/:account_id/:message_id", method = "get", operation_id = "download_message" )] async fn download_message( &self, + /// The ID of the account. account_id: Path, - id: Query, + /// The ID of the message to download. + message_id: Path, context: ClientContext, ) -> ApiResult> { let account_id = account_id.0; AccountModel::check_account_exists(account_id).await?; context.require_account_access(account_id)?; - let id = id.0; - let reader = EML_INDEX_MANAGER.get_reader(account_id, id).await?; + let message_id = message_id.0; + let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) - .filename(format!("{id}.eml")); + .filename(format!("{message_id}.eml")); Ok(attachment) } - /// Downloads a specific attachment by filename. + /// Downloads a specific attachment from an email. Requires `name` query parameter. #[oai( - path = "/download-attachment/:account_id", + path = "/download-attachment/:account_id/:message_id", method = "get", operation_id = "download_attachment" )] async fn download_attachment( &self, + /// The ID of the account. account_id: Path, - id: Query, + /// The ID of the message containing the attachment. + message_id: Path, + /// The filename of the attachment to download. name: Query, context: ClientContext, ) -> ApiResult> { let account_id = account_id.0; AccountModel::check_account_exists(account_id).await?; context.require_account_access(account_id)?; - let email_id = id.0; + let message_id = message_id.0; let name = name.0.trim(); let reader = EML_INDEX_MANAGER - .get_attachment(account_id, email_id, name) + .get_attachment(account_id, message_id, name) .await?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) diff --git a/src/modules/rest/api/system.rs b/src/modules/rest/api/system.rs index 9050410..3384372 100644 --- a/src/modules/rest/api/system.rs +++ b/src/modules/rest/api/system.rs @@ -78,7 +78,7 @@ impl SystemApi { #[oai(path = "/proxy/:id", method = "delete", operation_id = "remove_proxy")] async fn remove_proxy( &self, - /// The name of the OAuth2 configuration to retrieve + /// The ID of the proxy configuration to delete. id: Path, context: ClientContext, ) -> ApiResult<()> { @@ -86,11 +86,11 @@ impl SystemApi { Ok(Proxy::delete(id.0).await?) } - /// Retrieve a specific proxy configuration by ID + /// Retrieve a specific proxy configuration by ID. Requires root permission. #[oai(path = "/proxy/:id", method = "get", operation_id = "get_proxy")] async fn get_proxy( &self, - /// The name of the OAuth2 configuration to retrieve + /// The ID of the proxy configuration to retrieve. id: Path, context: ClientContext, ) -> ApiResult> { diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index f30bf60..69accde 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -48,7 +48,7 @@ export const get_thread_messages = async (accountId: number, thread_id: number, } export const download_attachment = async (accountId: number, id: number, attachmentFileName: string) => { - const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}?id=${id}&name=${attachmentFileName}`, { responseType: 'blob' }); + const response = await axiosInstance.get(`/api/v1/download-attachment/${accountId}/${id}?name=${attachmentFileName}`, { responseType: 'blob' }); const blob = new Blob([response.data]); saveAs(blob, attachmentFileName); }; @@ -83,7 +83,7 @@ export const getContent = (messageContent: MessageContentResponse): string | nul }; export const load_message = async (accountId: number, id: number) => { - const response = await axiosInstance.get(`/api/v1/message-content/${accountId}?id=${id}`); + const response = await axiosInstance.get(`/api/v1/message-content/${accountId}/${id}`); return response.data; }; @@ -93,7 +93,7 @@ export const delete_messages = async (payload: Record) => { }; export const download_message = async (accountId: number, id: number) => { - const response = await axiosInstance.get(`/api/v1/download-message/${accountId}?id=${id}`, { responseType: 'blob' }); + const response = await axiosInstance.get(`/api/v1/download-message/${accountId}/${id}`, { responseType: 'blob' }); const blob = new Blob([response.data]); saveAs(blob, `${id}.eml`); -}; \ No newline at end of file +};