// // Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com) // // This file is part of the Bichon Email Archiving Project // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . use crate::common::auth::WrappedContext; use crate::rest::api::ApiTags; use crate::rest::ApiResult; use bichon_core::account::migration::AccountModel; use bichon_core::common::paginated::DataPage; use bichon_core::error::code::ErrorCode; use bichon_core::message::append::restore_emails; use bichon_core::message::append::RestoreMessagesRequest; use bichon_core::message::attachment::retrieve_attachment_content; use bichon_core::message::attachment::retrieve_nested_attachment_content; use bichon_core::message::content::retrieve_nested_eml_content; use bichon_core::message::content::FullNestedMessageContent; use bichon_core::message::content::{retrieve_email_content, FullMessageContent}; use bichon_core::message::delete::delete_messages_impl; use bichon_core::message::list::get_thread_messages; use bichon_core::message::search::{search_messages_impl, EmailSearchRequest}; use bichon_core::message::tags::TagCount; use bichon_core::message::tags::TagsRequest; use bichon_core::raise_error; use bichon_core::store::blob::get_reader; use bichon_core::store::envelope::Envelope; use bichon_core::store::tantivy::envelope::ENVELOPE_MANAGER; use bichon_core::store::tantivy::validate_facet; use bichon_core::users::permissions::Permission; use poem::Body; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::{Attachment, AttachmentType, Json}; use poem_openapi::OpenApi; use std::collections::HashMap; use std::collections::HashSet; pub struct MessageApi; #[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Message")] impl MessageApi { /// Deletes messages from a mailbox or moves them to the trash for the specified account. #[oai( path = "/delete-messages", method = "post", operation_id = "delete_messages" )] async fn delete_messages( &self, /// specifying the mailbox and messages to delete. payload: Json>>, context: WrappedContext, ) -> ApiResult<()> { let request = payload.0; for account_id in request.keys() { context.require_permission(Some(*account_id), Permission::DATA_DELETE)?; } Ok(delete_messages_impl(request).await?) } /// Searches messages across all mailboxes using various filter criteria. /// The search filters are provided in the request body. #[oai( path = "/search-messages", method = "post", operation_id = "search_messages" )] async fn search_messages( &self, payload: Json, context: WrappedContext, ) -> ApiResult>> { let authorized_ids: Option> = if context.has_permission(None, Permission::DATA_READ_ALL) { None } else { Some(context.user.account_access_map.keys().cloned().collect()) }; Ok(Json(search_messages_impl(authorized_ids, payload.0)?)) } /// 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", operation_id = "get_thread_messages" )] async fn get_thread_messages( &self, /// The ID of the account owning the mailbox. account_id: Path, // Thread ID thread_id: Query, /// The page number for pagination (1-based). page: Query, /// The number of messages per page. page_size: Query, context: WrappedContext, ) -> ApiResult>> { let account_id = account_id.0; let thread_id = thread_id.0.trim(); context.require_permission(Some(account_id), Permission::DATA_READ)?; Ok(Json(get_thread_messages( account_id, thread_id, page.0, page_size.0, )?)) } /// Fetches the content of a specific email. /// Set `block_remote_content=true` to strip external images, scripts, /// and other content loaded from http/https URLs. #[oai( path = "/message-content/:account_id/:envelope_id", method = "get", operation_id = "fetch_message_content" )] async fn fetch_message_content( &self, /// The ID of the account. account_id: Path, /// The ID of the message to fetch. envelope_id: Path, /// Block remote content (http/https URLs) from email body. block_remote_content: Query>, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; let block_remote = block_remote_content.0.unwrap_or(false); context.require_permission(Some(account_id), Permission::DATA_READ)?; Ok(Json(retrieve_email_content( account_id, envelope_id.0, block_remote, )?)) } /// Retrieves the content of an email embedded as an attachment. #[oai( path = "/nested-message-content/:account_id/:envelope_id", method = "get", operation_id = "fetch_nested_message_content" )] async fn fetch_nested_message_content( &self, /// The ID of the account. account_id: Path, /// The ID of the message to fetch. envelope_id: Path, content_hash: Query, block_remote_content: Query>, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; let block_remote = block_remote_content.0.unwrap_or(false); context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); Ok(Json(retrieve_nested_eml_content( account_id, envelope_id.0, content_hash, block_remote, )?)) } /// Retrieves the envelope (metadata) of a specific message. #[oai( path = "/envelope/:account_id/:envelope_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. envelope_id: Path, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; context.require_permission(Some(account_id), Permission::DATA_READ)?; let envelope_id = envelope_id.0; let e = ENVELOPE_MANAGER .get_envelope_by_id(account_id, &envelope_id)? .ok_or_else(|| { raise_error!( format!( "Envelope not found: account_id={} envelope_id={}", account_id, &envelope_id ), ErrorCode::ResourceNotFound ) })?; Ok(Json(e.envelope)) } /// Downloads the raw EML file of a specific email. #[oai( path = "/download-message/:account_id/:envelope_id", method = "get", operation_id = "download_message" )] async fn download_message( &self, /// The ID of the account. account_id: Path, /// The ID of the message to download. envelope_id: Path, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; AccountModel::check_account_exists(account_id)?; context.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)?; let envelope_id = envelope_id.0; let reader = get_reader(account_id, envelope_id.clone()).await?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) .filename(format!("{envelope_id}.eml")); Ok(attachment) } /// Restore an email to an account's IMAP server. #[oai( path = "/restore-messages/:account_id", method = "post", operation_id = "restore_messages" )] async fn restore_messages( &self, account_id: Path, /// Message IDs to restore. payload: Json, context: WrappedContext, ) -> ApiResult<()> { let account_id = account_id.0; context.require_permission(Some(account_id), Permission::DATA_EXPORT_BATCH)?; Ok(restore_emails(account_id, payload.0.envelope_ids).await?) } /// Downloads a specific attachment from an email. Requires `name` query parameter. #[oai( path = "/download-attachment/:account_id/:envelope_id", method = "get", operation_id = "download_attachment" )] async fn download_attachment( &self, /// The ID of the account. account_id: Path, /// The ID of the message containing the attachment. envelope_id: Path, /// The content_hash of the attachment to download. content_hash: Query, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; let envelope_id = envelope_id.0.trim().to_string(); AccountModel::check_account_exists(account_id)?; context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) .filename(content_hash); Ok(attachment) } /// Returns raw attachment content for in-browser preview with /// `Content-Disposition: inline` and the correct MIME type. #[oai( path = "/preview-attachment/:account_id/:envelope_id", method = "get", operation_id = "preview_attachment" )] async fn preview_attachment( &self, /// The ID of the account. account_id: Path, /// The ID of the message containing the attachment. envelope_id: Path, /// The content_hash of the attachment to preview. content_hash: Query, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; let envelope_id = envelope_id.0.trim().to_string(); AccountModel::check_account_exists(account_id)?; context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?; let body = Body::from_async_read(reader); Ok(Attachment::new(body).attachment_type(AttachmentType::Inline)) } /// Downloads an attachment from within a nested email (EML file). #[oai( path = "/download-nested-attachment/:account_id/:envelope_id", method = "get", operation_id = "download_nested_attachment" )] async fn download_nested_attachment( &self, /// The ID of the account. account_id: Path, /// The ID of the message containing the attachment. envelope_id: Path, /// The filename of the attachment to download. content_hash: Query, nested_content_hash: Query, context: WrappedContext, ) -> ApiResult> { let account_id = account_id.0; let envelope_id = envelope_id.0.trim().to_string(); AccountModel::check_account_exists(account_id)?; context.require_permission(Some(account_id), Permission::DATA_READ)?; let content_hash = content_hash.0.trim(); let nested_content_hash = nested_content_hash.0.trim(); let reader = retrieve_nested_attachment_content( account_id, envelope_id, content_hash, nested_content_hash, )?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) .filename(nested_content_hash); Ok(attachment) } /// Returns all facets in the index along with their document counts. #[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")] async fn get_all_tags(&self, context: WrappedContext) -> ApiResult>> { let authorized_ids: Option> = if context.has_permission(None, Permission::DATA_READ_ALL) { None } else { Some(context.user.account_access_map.keys().cloned().collect()) }; Ok(Json(ENVELOPE_MANAGER.get_all_tags(authorized_ids)?)) } /// Adds or removes facet tags for multiple emails across accounts. #[oai( path = "/update-tags", method = "post", operation_id = "update_envelope_tags" )] async fn update_envelope_tags( &self, req: Json, context: WrappedContext, ) -> ApiResult<()> { let req = req.0; for tag in &req.tags { validate_facet(tag)?; } for account_id in req.updates.keys() { context.require_permission(Some(*account_id), Permission::DATA_MANAGE)?; } ENVELOPE_MANAGER.update_envelope_tags(req).await?; Ok(()) } /// Retrieves a unique list of all contact email addresses across authorized accounts. #[oai( path = "/all-contacts", method = "get", operation_id = "get_all_contacts" )] async fn get_all_contacts(&self, context: WrappedContext) -> ApiResult>> { let authorized_ids: Option> = if context.has_permission(None, Permission::DATA_READ_ALL) { None } else { Some(context.user.account_access_map.keys().cloned().collect()) }; Ok(Json(ENVELOPE_MANAGER.get_all_contacts(authorized_ids)?)) } }