feat(api): Add envelope endpoint and improve API documentation

- 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 <noreply@anthropic.com>
This commit is contained in:
Michel-Marie MAUDET
2025-12-14 11:12:16 +01:00
co-authored by Claude Opus 4.5
parent c05a8944ef
commit 70db81dc03
5 changed files with 109 additions and 28 deletions
Generated
+1 -1
View File
@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"ahash",
"async-imap",
+41
View File
@@ -741,6 +741,47 @@ impl EnvelopeIndexManager {
})
}
pub async fn get_envelope_by_id(
&self,
account_id: u64,
message_id: u64,
) -> BichonResult<Option<Envelope>> {
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<Vec<LargestEmail>> {
self.reader
.reload()
+60 -20
View File
@@ -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<u64>,
/// The ID of the mailbox to list messages from.
mailbox_id: Query<u64>,
page: Query<u64>,
page_size: Query<u64>,
@@ -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<u64>,
id: Query<u64>,
/// The ID of the message to fetch.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
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<u64>,
/// The ID of the message.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Envelope>> {
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<u64>,
id: Query<u64>,
/// The ID of the message to download.
message_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
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<u64>,
id: Query<u64>,
/// The ID of the message containing the attachment.
message_id: Path<u64>,
/// The filename of the attachment to download.
name: Query<String>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
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)
+3 -3
View File
@@ -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<u64>,
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<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
+4 -4
View File
@@ -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<MessageContentResponse>(`/api/v1/message-content/${accountId}?id=${id}`);
const response = await axiosInstance.get<MessageContentResponse>(`/api/v1/message-content/${accountId}/${id}`);
return response.data;
};
@@ -93,7 +93,7 @@ export const delete_messages = async (payload: Record<string, number[]>) => {
};
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`);
};
};