mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
The download-message REST endpoint returned 404 [30000] "Original email content not found" whenever an indexed message's raw content blob was missing from the blob store, leaving the message permanently unrecoverable even though it still existed on the IMAP server. Make the endpoint self-healing: when the content blob is absent, fetch that one message on demand from the IMAP server via UID FETCH <uid> (BODY.PEEK[]), repopulate the detached blob, and return the content. The 404 is now only produced when the on-demand fetch itself fails (mailbox gone, UID gone, connection failure, or the fetched bytes no longer match the archived content_hash). - Add ImapExecutor::fetch_single_message_body: examines the mailbox read-only and fetches one message by UID, reusing the existing BODY.PEEK[] fetch command. - Add reattach_eml_content_self_healing / recover_message_blob in the envelope extractor: fast-path delegates to reattach_eml_content when the blob exists; otherwise recovers it via IMAP and re-stores the stripped EML + attachments through the existing blob queue. - Make store::blob::get_reader async and route it through the self-healing path; update the single caller (download_message). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
371 lines
14 KiB
Rust
371 lines
14 KiB
Rust
//
|
|
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
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<HashMap<u64, Vec<String>>>,
|
|
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<EmailSearchRequest>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Json<DataPage<Envelope>>> {
|
|
let authorized_ids: Option<HashSet<u64>> =
|
|
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<u64>,
|
|
// Thread ID
|
|
thread_id: Query<String>,
|
|
/// The page number for pagination (1-based).
|
|
page: Query<u64>,
|
|
/// The number of messages per page.
|
|
page_size: Query<u64>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Json<DataPage<Envelope>>> {
|
|
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<u64>,
|
|
/// The ID of the message to fetch.
|
|
envelope_id: Path<String>,
|
|
/// Block remote content (http/https URLs) from email body.
|
|
block_remote_content: Query<Option<bool>>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Json<FullMessageContent>> {
|
|
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<u64>,
|
|
/// The ID of the message to fetch.
|
|
envelope_id: Path<String>,
|
|
content_hash: Query<String>,
|
|
block_remote_content: Query<Option<bool>>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Json<FullNestedMessageContent>> {
|
|
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<u64>,
|
|
/// The ID of the message.
|
|
envelope_id: Path<String>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Json<Envelope>> {
|
|
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<u64>,
|
|
/// The ID of the message to download.
|
|
envelope_id: Path<String>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Attachment<Body>> {
|
|
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<u64>,
|
|
/// Message IDs to restore.
|
|
payload: Json<RestoreMessagesRequest>,
|
|
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<u64>,
|
|
/// The ID of the message containing the attachment.
|
|
envelope_id: Path<String>,
|
|
/// The content_hash of the attachment to download.
|
|
content_hash: Query<String>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Attachment<Body>> {
|
|
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)
|
|
}
|
|
|
|
/// 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<u64>,
|
|
/// The ID of the message containing the attachment.
|
|
envelope_id: Path<String>,
|
|
/// The filename of the attachment to download.
|
|
content_hash: Query<String>,
|
|
nested_content_hash: Query<String>,
|
|
context: WrappedContext,
|
|
) -> ApiResult<Attachment<Body>> {
|
|
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<Json<Vec<TagCount>>> {
|
|
let authorized_ids: Option<HashSet<u64>> =
|
|
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<TagsRequest>,
|
|
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<Json<HashSet<String>>> {
|
|
let authorized_ids: Option<HashSet<u64>> =
|
|
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)?))
|
|
}
|
|
}
|