diff --git a/crates/core/src/envelope/extractor.rs b/crates/core/src/envelope/extractor.rs
index 8024870..9398f84 100644
--- a/crates/core/src/envelope/extractor.rs
+++ b/crates/core/src/envelope/extractor.rs
@@ -16,11 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+use crate::cache::imap::mailbox::MailBox;
use crate::common::AddrVec;
use crate::envelope::meta::parse_bichon_metadata;
use crate::envelope::utils::normalize_subject;
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
+use crate::imap::executor::ImapExecutor;
use crate::message::content::AttachmentInfo;
use crate::store::blob::{DetachedEmail, BLOB_MANAGER};
use crate::store::tantivy::attachment::ATTACHMENT_MANAGER;
@@ -515,6 +517,115 @@ pub fn reattach_eml_content(
Ok((e.envelope, Bytes::from(restored_eml)))
}
+/// Returns the raw EML for an indexed message, self-healing a missing content blob.
+///
+/// Behaves like [`reattach_eml_content`], but when the message's content blob is
+/// absent from the blob store it fetches that single message on demand from the
+/// IMAP server (`UID FETCH (BODY.PEEK[])`), persists it for future requests,
+/// and returns it. If the on-demand fetch itself fails, the original "content not
+/// found" error from [`reattach_eml_content`] is surfaced unchanged so the caller
+/// still produces its 404.
+pub async fn reattach_eml_content_self_healing(
+ account_id: u64,
+ envelope_id: String,
+) -> BichonResult<(Envelope, Bytes)> {
+ let envelope = 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
+ )
+ })?
+ .envelope;
+
+ // Fast path: the content blob is present, reuse the regular reattach logic.
+ if BLOB_MANAGER.get_email(&envelope.content_hash)?.is_some() {
+ return reattach_eml_content(account_id, envelope_id);
+ }
+
+ // The blob is missing. Try to recover it directly from the IMAP server.
+ match recover_message_blob(&envelope).await {
+ Ok(raw_body) => {
+ tracing::info!(
+ account_id,
+ envelope_id = %envelope_id,
+ uid = envelope.uid,
+ "Self-healed missing email content blob via on-demand IMAP fetch"
+ );
+ Ok((envelope, raw_body))
+ }
+ Err(e) => {
+ tracing::warn!(
+ account_id,
+ envelope_id = %envelope_id,
+ uid = envelope.uid,
+ error = %e,
+ "On-demand IMAP fetch for missing content blob failed; returning not-found"
+ );
+ // Surface the canonical "content not found" error to the caller.
+ reattach_eml_content(account_id, envelope_id)
+ }
+ }
+}
+
+/// Fetches one message from IMAP and re-stores its detached blob.
+///
+/// On success the freshly fetched raw RFC822 body is returned; it is also queued
+/// (in detached form) into the blob store so subsequent requests hit the cache.
+/// Fails if the message cannot be fetched, or if the fetched bytes do not match
+/// the archived `content_hash` (the server-side message no longer matches what
+/// Bichon archived, so it cannot be treated as a recovery of that blob).
+async fn recover_message_blob(envelope: &Envelope) -> BichonResult {
+ let mailbox = MailBox::find_mailbox(envelope.account_id, envelope.mailbox_id)?
+ .ok_or_else(|| {
+ raise_error!(
+ format!(
+ "Mailbox not found: account_id={} mailbox_id={}",
+ envelope.account_id, envelope.mailbox_id
+ ),
+ ErrorCode::ResourceNotFound
+ )
+ })?;
+
+ let mut session = ImapExecutor::create_connection(envelope.account_id).await?;
+ let result = ImapExecutor::fetch_single_message_body(
+ &mut session,
+ &mailbox.encoded_name(),
+ envelope.uid,
+ )
+ .await;
+ session.logout().await.ok();
+ let raw_body = result?;
+
+ let fetched_hash = compute_content_hash(&raw_body);
+ if fetched_hash != envelope.content_hash {
+ return Err(raise_error!(
+ format!(
+ "Fetched message does not match archived content: expected content_hash={} got={}",
+ envelope.content_hash, fetched_hash
+ ),
+ ErrorCode::ImapUnexpectedResult
+ ));
+ }
+
+ // Re-create the detached blob (stripped EML + attachments) so the missing
+ // blob is repopulated for future requests. The detached EML is queued under
+ // `fetched_hash`, which equals `envelope.content_hash`.
+ let message = MessageParser::new().parse(raw_body.as_slice()).ok_or_else(|| {
+ raise_error!(
+ "Failed to parse fetched email content".into(),
+ ErrorCode::InternalError
+ )
+ })?;
+ detach_and_store_attachments(&raw_body, &message, &fetched_hash).await;
+
+ Ok(Bytes::from(raw_body))
+}
+
#[cfg(test)]
mod test {
use html2text::config;
diff --git a/crates/core/src/imap/executor.rs b/crates/core/src/imap/executor.rs
index 3d6fe11..97ee492 100644
--- a/crates/core/src/imap/executor.rs
+++ b/crates/core/src/imap/executor.rs
@@ -289,6 +289,58 @@ impl ImapExecutor {
Ok(())
}
+ /// Fetches the raw RFC822 body of a single message by UID.
+ ///
+ /// Selects (read-only) the given mailbox and issues `UID FETCH (BODY.PEEK[])`.
+ /// Used for on-demand self-healing when an indexed message's content blob is missing.
+ /// Returns the raw bytes, or an error if the message cannot be retrieved.
+ pub async fn fetch_single_message_body(
+ session: &mut Session>,
+ encoded_mailbox_name: &str,
+ uid: u32,
+ ) -> BichonResult> {
+ session
+ .examine(encoded_mailbox_name)
+ .await
+ .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
+
+ let mut stream = session
+ .uid_fetch(uid.to_string(), BODY_FETCH_COMMAND)
+ .await
+ .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
+
+ let fetch = stream
+ .try_next()
+ .await
+ .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
+ .ok_or_else(|| {
+ raise_error!(
+ format!("UID {uid} not found on IMAP server"),
+ ErrorCode::ResourceNotFound
+ )
+ })?;
+
+ let body = fetch
+ .body()
+ .ok_or_else(|| {
+ raise_error!(
+ format!("No body returned for UID {uid}"),
+ ErrorCode::ImapUnexpectedResult
+ )
+ })?
+ .to_vec();
+
+ // Drain any remaining items so the stream is fully consumed before reuse.
+ while stream
+ .try_next()
+ .await
+ .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?
+ .is_some()
+ {}
+
+ Ok(body)
+ }
+
pub async fn create_connection(
account_id: u64,
) -> BichonResult>> {
diff --git a/crates/core/src/store/blob.rs b/crates/core/src/store/blob.rs
index 0fa2128..c3e7c98 100644
--- a/crates/core/src/store/blob.rs
+++ b/crates/core/src/store/blob.rs
@@ -18,7 +18,7 @@
use crate::{
common::signal::SIGNAL_MANAGER,
- envelope::extractor::reattach_eml_content,
+ envelope::extractor::reattach_eml_content_self_healing,
error::{code::ErrorCode, BichonResult},
settings::dir::DATA_DIR_MANAGER,
};
@@ -226,7 +226,13 @@ impl BlobManager {
}
}
-pub fn get_reader(account_id: u64, eid: String) -> BichonResult> {
- let (_, data) = reattach_eml_content(account_id, eid)?;
+/// Returns a reader over the raw EML for an indexed message.
+///
+/// If the message's content blob is missing from the blob store, it is fetched
+/// on demand from the IMAP server, persisted, and returned (self-healing). The
+/// underlying "content not found" error is only surfaced if that on-demand
+/// fetch itself fails.
+pub async fn get_reader(account_id: u64, eid: String) -> BichonResult> {
+ let (_, data) = reattach_eml_content_self_healing(account_id, eid).await?;
Ok(Cursor::new(data))
}
diff --git a/crates/server/src/rest/api/message.rs b/crates/server/src/rest/api/message.rs
index 3c995de..e4f4477 100644
--- a/crates/server/src/rest/api/message.rs
+++ b/crates/server/src/rest/api/message.rs
@@ -225,7 +225,7 @@ impl MessageApi {
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())?;
+ 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)