2025-11-19 02:14:37 +08:00
//
2026-03-10 01:32:57 +08:00
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
2025-11-19 02:14:37 +08:00
//
// 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/>.
2026-05-22 13:46:40 +02:00
use crate ::cache ::imap ::mailbox ::MailBox ;
2026-04-23 21:45:34 +08:00
use crate ::common ::AddrVec ;
2026-04-25 20:14:14 +08:00
use crate ::envelope ::meta ::parse_bichon_metadata ;
2026-04-23 21:45:34 +08:00
use crate ::envelope ::utils ::normalize_subject ;
use crate ::error ::code ::ErrorCode ;
use crate ::error ::BichonResult ;
2026-05-22 13:46:40 +02:00
use crate ::imap ::executor ::ImapExecutor ;
2026-04-23 21:45:34 +08:00
use crate ::message ::content ::AttachmentInfo ;
2026-05-12 01:56:24 +08:00
use crate ::store ::blob ::{ DetachedEmail , BLOB_MANAGER };
2026-04-23 21:45:34 +08:00
use crate ::store ::tantivy ::attachment ::ATTACHMENT_MANAGER ;
use crate ::store ::tantivy ::envelope ::ENVELOPE_MANAGER ;
use crate ::store ::tantivy ::model ::{ AttachmentModel , EnvelopeWithAttachments };
use crate ::utils ::html ::extract_text ;
use crate ::utils ::{ compute_content_hash , hex_hash };
use crate ::{ id , store ::envelope ::Envelope };
2026-03-18 01:04:41 +08:00
use crate ::{ raise_error , utc_now };
2025-11-19 02:14:37 +08:00
use async_imap ::types ::Fetch ;
2026-04-01 04:49:18 +08:00
use bytes ::Bytes ;
2026-03-03 12:30:26 +08:00
use mail_parser ::{ Address , HeaderName , Message , MessageParser , MimeHeaders };
2026-04-19 01:01:46 +08:00
use tantivy ::TantivyDocument ;
2026-04-25 20:14:14 +08:00
use tantivy ::schema ::Facet ;
2026-03-24 21:48:04 +08:00
use tracing ::error ;
2026-03-18 01:04:41 +08:00
use uuid ::Uuid ;
2025-11-19 02:14:37 +08:00
2026-03-24 21:48:04 +08:00
pub async fn extract_envelope_and_store_it (
2026-04-01 04:49:18 +08:00
fetch : Fetch ,
2026-03-03 12:30:26 +08:00
account_id : u64 ,
mailbox_id : u64 ,
2026-03-24 21:48:04 +08:00
) -> BichonResult < () > {
2025-11-19 02:14:37 +08:00
let internal_date = fetch
. internal_date ()
. map ( | d | d . timestamp_millis ())
. unwrap_or ( 0 );
let uid = fetch . uid . unwrap_or ( 0 );
let body = fetch
. body ()
. ok_or_else ( || raise_error! ( "No body available" . into (), ErrorCode ::InternalError )) ? ;
2025-11-27 22:11:05 +08:00
let size = fetch . size . unwrap_or ( body . len () as u32 );
2026-03-24 21:48:04 +08:00
extract_envelope_core ( body , uid , size , internal_date , account_id , mailbox_id ). await
2025-11-19 02:14:37 +08:00
}
2026-03-24 21:48:04 +08:00
pub async fn extract_envelope_from_eml (
2025-11-27 22:11:05 +08:00
body : & [ u8 ],
account_id : u64 ,
mailbox_id : u64 ,
2026-03-24 21:48:04 +08:00
) -> BichonResult < () > {
extract_envelope_core ( body , 0 , body . len () as u32 , 0 , account_id , mailbox_id ). await
2026-03-03 12:30:26 +08:00
}
2026-03-24 21:48:04 +08:00
pub async fn extract_envelope_from_smtp (
2026-03-10 01:25:02 +08:00
body : & [ u8 ],
account_id : u64 ,
mailbox_id : u64 ,
2026-03-24 21:48:04 +08:00
) -> BichonResult < () > {
2026-03-10 01:25:02 +08:00
extract_envelope_core (
body ,
0 ,
body . len () as u32 ,
utc_now! (),
account_id ,
mailbox_id ,
)
2026-03-24 21:48:04 +08:00
. await
2026-03-10 01:25:02 +08:00
}
2026-03-24 21:48:04 +08:00
async fn extract_envelope_core (
2026-03-03 12:30:26 +08:00
body : & [ u8 ],
uid : u32 ,
size : u32 ,
internal_date : i64 ,
account_id : u64 ,
mailbox_id : u64 ,
2026-03-24 21:48:04 +08:00
) -> BichonResult < () > {
2026-05-23 11:36:47 +08:00
//The content hash of the original raw EML
2026-03-24 21:48:04 +08:00
let email_content_hash = compute_content_hash ( body );
let message : Message < '_ > = MessageParser ::new (). parse ( body ). ok_or_else ( || {
2025-11-27 22:11:05 +08:00
raise_error! (
"Email header parse result is not available" . into (),
ErrorCode ::InternalError
)
}) ? ;
2026-04-05 16:06:07 +08:00
let preview_limit = 100 ;
2025-11-27 22:11:05 +08:00
let text = if let Some ( text ) = message . body_text ( 0 ). map ( | cow | cow . into_owned ()) {
text
} else if let Some ( html ) = message . body_html ( 0 ). map ( | cow | cow . into_owned ()) {
2026-01-28 13:19:33 +08:00
extract_text ( html )
2025-11-27 22:11:05 +08:00
} else {
String ::new ()
};
2026-04-05 16:06:07 +08:00
let text = text . split_whitespace (). collect ::< Vec < _ >> (). join ( " " );
let preview = if text . chars (). count () > preview_limit {
text . chars (). take ( preview_limit ). collect ::< String > () + "..."
} else {
text . clone ()
};
let body_text = text ;
2025-11-27 22:11:05 +08:00
let message_id = message
. message_id ()
. map ( String ::from )
2026-03-03 12:30:26 +08:00
. unwrap_or_else ( generate_message_id );
2025-11-27 22:11:05 +08:00
let in_reply_to = message . in_reply_to (). as_text (). map ( String ::from );
let references = extract_references ( & message );
let thread_id = compute_thread_id ( in_reply_to , references , & message_id );
2025-12-27 03:38:53 +08:00
let mut subject = message . subject (). map ( String ::from ). unwrap_or_default ();
if subject . contains ( '\u{FFFD}' ) {
subject = normalize_subject ( message . header_raw ( HeaderName ::Subject ));
}
2025-11-27 22:11:05 +08:00
let date = message . date (). map ( | d | d . to_timestamp () * 1000 ). unwrap_or ( 0 );
2026-03-24 21:48:04 +08:00
let internal_date = if internal_date == 0 {
date
} else {
internal_date
};
2026-03-03 12:30:26 +08:00
let parse_addrs = | addrs : Option <& Address < '_ >>| {
addrs
. map ( | addr | {
AddrVec ::from ( addr )
. 0
. into_iter ()
. filter_map ( | a | a . address )
. collect ()
})
. unwrap_or_default ()
};
let bcc = parse_addrs ( message . bcc ());
let cc = parse_addrs ( message . cc ());
let to = parse_addrs ( message . to ());
2025-11-27 22:11:05 +08:00
let from = message
. from ()
. and_then ( | addr | AddrVec ::from ( addr ). 0. into_iter (). next ())
. and_then ( | add | add . address )
. unwrap_or_else ( || "unknown" . to_string ());
2026-03-24 21:48:04 +08:00
let attachment_count = message . attachment_count ();
let attachments = detach_and_store_attachments ( body , & message , & email_content_hash ). await ;
2026-03-03 12:30:26 +08:00
2026-04-19 01:01:46 +08:00
let envelope_id = Uuid ::new_v4 (). to_string ();
let now = utc_now! ();
2026-04-25 20:14:14 +08:00
let mut final_tags = Vec ::new ();
if let Some ( meta_header ) = message . header_raw ( "X-Bichon-Metadata" ) {
if let Some ( bmd ) = parse_bichon_metadata ( meta_header ) {
if let Some ( tags ) = bmd . tags {
let validated_tags : Result < Vec < String > , _ > = tags
. iter ()
. map ( | tag | {
Facet ::from_text ( tag )
. map ( | _ | tag . clone ())
. map_err ( | e | e )
})
. collect ();
match validated_tags {
Ok ( valid_list ) => {
final_tags = valid_list ;
}
Err ( e ) => {
eprintln! (
"Tag validation failed, ignoring all tags: {:#?} " ,
e
);
}
}
}
}
}
2026-04-19 01:01:46 +08:00
let attachment_docs : Vec < TantivyDocument > = attachments
2026-03-24 21:48:04 +08:00
. iter ()
2026-04-19 01:01:46 +08:00
. filter ( | a | ! a . inline || a . content_id . is_none ())
. map ( | a | AttachmentModel {
id : Uuid ::new_v4 (). to_string (),
envelope_id : envelope_id . clone (),
account_id ,
account_email : None ,
mailbox_id ,
mailbox_name : None ,
subject : subject . clone (),
content_hash : a . content_hash . clone (),
from : from . clone (),
date ,
ingest_at : now ,
size : a . size as u64 ,
ext : a . get_extension (),
category : a . get_category (). to_string (),
content_type : a . file_type . clone (),
shard_id : 0 ,
text : None ,
has_text : false ,
is_ocr : false ,
page_count : None ,
is_indexed : false ,
is_message : a . is_message ,
name : a . filename . clone (),
tags : None ,
auto_tags : None ,
}). map ( | a | a . into_document ())
. collect ();
2025-11-27 22:11:05 +08:00
let envelope = Envelope {
2026-04-19 01:01:46 +08:00
id : envelope_id ,
2025-11-27 22:11:05 +08:00
message_id ,
account_id ,
mailbox_id ,
uid ,
subject ,
2026-04-05 16:06:07 +08:00
preview ,
2025-11-27 22:11:05 +08:00
from ,
2026-03-03 12:30:26 +08:00
to ,
cc ,
bcc ,
2025-11-27 22:11:05 +08:00
date ,
2026-03-03 12:30:26 +08:00
internal_date ,
2026-04-19 01:01:46 +08:00
ingest_at : now ,
2025-11-27 22:11:05 +08:00
size ,
thread_id ,
2026-03-24 21:48:04 +08:00
attachment_count ,
2026-04-19 01:01:46 +08:00
regular_attachment_count : attachment_docs . len (),
2026-04-25 20:14:14 +08:00
tags : ( ! final_tags . is_empty ()). then_some ( final_tags ),
2025-12-28 13:20:21 +08:00
account_email : None ,
mailbox_name : None ,
2026-03-24 21:48:04 +08:00
content_hash : email_content_hash ,
2025-11-27 22:11:05 +08:00
};
2026-04-15 20:02:41 +08:00
// 'attachments' contains both regular and inline attachments
2026-04-05 16:06:07 +08:00
let ea = EnvelopeWithAttachments {
envelope ,
attachments : Some ( attachments ),
};
let doc = ea . to_document ( & body_text , 0 ) ? ;
2026-04-19 01:01:46 +08:00
ENVELOPE_MANAGER . queue ( doc ). await ;
for doc in attachment_docs {
ATTACHMENT_MANAGER . queue ( doc ). await ;
}
2026-03-24 21:48:04 +08:00
Ok (())
2025-11-27 22:11:05 +08:00
}
2026-03-24 21:48:04 +08:00
pub fn extract_envelope_from_nested_message (
2026-03-15 18:55:24 +08:00
message : Message < '_ > ,
account_id : u64 ,
) -> BichonResult < Envelope > {
let text = if let Some ( text ) = message . body_text ( 0 ). map ( | cow | cow . into_owned ()) {
text
} else if let Some ( html ) = message . body_html ( 0 ). map ( | cow | cow . into_owned ()) {
extract_text ( html )
} else {
String ::new ()
};
let message_id = message
. message_id ()
. map ( String ::from )
. unwrap_or_else ( generate_message_id );
let in_reply_to = message . in_reply_to (). as_text (). map ( String ::from );
let references = extract_references ( & message );
let thread_id = compute_thread_id ( in_reply_to , references , & message_id );
let mut subject = message . subject (). map ( String ::from ). unwrap_or_default ();
if subject . contains ( '\u{FFFD}' ) {
subject = normalize_subject ( message . header_raw ( HeaderName ::Subject ));
}
let date = message . date (). map ( | d | d . to_timestamp () * 1000 ). unwrap_or ( 0 );
let parse_addrs = | addrs : Option <& Address < '_ >>| {
addrs
. map ( | addr | {
AddrVec ::from ( addr )
. 0
. into_iter ()
. filter_map ( | a | a . address )
. collect ()
})
. unwrap_or_default ()
};
let bcc = parse_addrs ( message . bcc ());
let cc = parse_addrs ( message . cc ());
let to = parse_addrs ( message . to ());
let from = message
. from ()
. and_then ( | addr | AddrVec ::from ( addr ). 0. into_iter (). next ())
. and_then ( | add | add . address )
. unwrap_or_else ( || "unknown" . to_string ());
let envelope = Envelope {
2026-03-18 01:04:41 +08:00
id : Default ::default (),
2026-03-15 18:55:24 +08:00
message_id ,
account_id ,
2026-03-18 01:04:41 +08:00
mailbox_id : Default ::default (),
uid : Default ::default (),
2026-03-15 18:55:24 +08:00
subject ,
2026-04-05 16:06:07 +08:00
preview : text ,
2026-03-15 18:55:24 +08:00
from ,
to ,
cc ,
bcc ,
date ,
2026-03-18 01:04:41 +08:00
internal_date : Default ::default (),
2026-04-05 16:06:07 +08:00
ingest_at : Default ::default (),
2026-03-18 01:04:41 +08:00
size : Default ::default (),
2026-03-15 18:55:24 +08:00
thread_id ,
2026-03-18 01:04:41 +08:00
attachment_count : Default ::default (),
2026-03-24 21:48:04 +08:00
regular_attachment_count : Default ::default (),
2026-03-18 01:04:41 +08:00
tags : Default ::default (),
account_email : Default ::default (),
mailbox_name : Default ::default (),
content_hash : Default ::default (),
2026-03-15 18:55:24 +08:00
};
Ok ( envelope )
}
2025-11-19 02:14:37 +08:00
pub fn compute_thread_id (
in_reply_to : Option < String > ,
references : Option < Vec < String >> ,
message_id : & str ,
2026-03-18 01:04:41 +08:00
) -> String {
2025-11-19 02:14:37 +08:00
if in_reply_to . is_some () && references . as_ref (). map_or ( false , | r | ! r . is_empty ()) {
2026-03-18 01:04:41 +08:00
return hex_hash ( & references . as_ref (). unwrap ()[ 0 ]);
2025-11-19 02:14:37 +08:00
}
2026-03-18 01:04:41 +08:00
hex_hash ( message_id )
2025-11-19 02:14:37 +08:00
}
pub fn generate_message_id () -> String {
let ts = utc_now! ();
let pid = std ::process ::id ();
format! ( "< {:016x} . {} . {} @ {} >" , id! ( 128 ), ts , pid , "bichon" )
}
2026-05-12 01:56:24 +08:00
pub fn extract_references ( message : & Message < '_ > ) -> Option < Vec < String >> {
2025-11-19 02:14:37 +08:00
match message . references () {
mail_parser ::HeaderValue ::Text ( cow ) => Some ( vec! [ cow . to_string ()]),
mail_parser ::HeaderValue ::TextList ( vec ) => {
Some ( vec . iter (). map ( | cow | cow . to_string ()). collect ())
}
_ => None ,
}
}
2025-12-08 01:30:46 +08:00
2026-03-24 21:48:04 +08:00
pub async fn detach_and_store_attachments (
original_body : & [ u8 ],
message : & Message < '_ > ,
eml_content_hash : & str ,
) -> Vec < AttachmentInfo > {
let mut stripped_eml = original_body . to_vec ();
let mut attachment_infos = Vec ::new ();
// Step 1: Collect and sort attachment ranges in reverse to maintain offset integrity
let mut ranges : Vec < _ > = message
. attachments ()
. map ( | att | {
(
att . raw_body_offset () as usize ,
att . raw_end_offset () as usize ,
att ,
)
})
. collect ();
ranges . sort_by ( | a , b | b . 0. cmp ( & a . 0 ));
2026-04-01 04:49:18 +08:00
let mut attachments = Vec ::with_capacity ( ranges . len ());
2026-03-24 21:48:04 +08:00
for ( raw_start , raw_end , att ) in ranges {
// Step 2: Extract raw bytes and store them as standalone documents
let raw_bytes = & original_body [ raw_start .. raw_end ];
2026-05-23 11:36:47 +08:00
//This is the content hash of the decoded attachment, not the undecoded one.
2026-04-20 00:12:18 +08:00
let content_hash = compute_content_hash ( att . contents ());
2026-03-24 21:48:04 +08:00
2026-05-23 11:36:47 +08:00
//"The actual content stored in the blob is the raw undecoded data, to avoid the reconstructed EML differing from the original due to decoding and re-encoding.
2026-04-20 00:12:18 +08:00
attachments . push (( content_hash . clone (), Bytes ::copy_from_slice ( raw_bytes ))); //
2026-04-01 04:49:18 +08:00
2026-03-24 21:48:04 +08:00
// Step 3: Replace raw attachment content with a hash-based placeholder
let placeholder = format! ( "<<BICHON_DETACH_HASH: {} >>" , & content_hash );
let p_bytes = placeholder . as_bytes ();
stripped_eml . splice ( raw_start .. raw_end , p_bytes . iter (). cloned ());
let info = AttachmentInfo {
filename : att . attachment_name (). map ( | n | n . to_string ()),
size : att . contents (). len (),
inline : att
. content_disposition ()
. map ( | d | d . is_inline ())
. unwrap_or ( false ),
file_type : att
. content_type ()
. map ( | ct | {
format! (
" {} / {} " ,
ct . c_type . as_ref (),
ct . c_subtype . as_deref (). unwrap_or ( "" )
)
})
. unwrap_or_else ( || "application/octet-stream" . to_string ()),
content_id : att . content_id (). map ( | id | id . to_string ()),
content_hash : content_hash . clone (),
is_message : att . is_message (),
};
attachment_infos . push ( info );
}
// Step 4: Store the final stripped EML content
2026-04-01 04:49:18 +08:00
BLOB_MANAGER
. queue ( DetachedEmail {
email : ( eml_content_hash . to_string (), Bytes ::from ( stripped_eml )),
attachments : Some ( attachments ),
})
2026-03-24 21:48:04 +08:00
. await ;
attachment_infos
}
2026-05-14 02:29:23 +08:00
pub fn reattach_eml_content (
2026-03-24 21:48:04 +08:00
account_id : u64 ,
envelope_id : String ,
2026-04-01 04:49:18 +08:00
) -> BichonResult < ( Envelope , Bytes ) > {
2026-04-19 01:01:46 +08:00
let e = ENVELOPE_MANAGER
2026-04-05 16:06:07 +08:00
. get_envelope_by_id ( account_id , & envelope_id )
2026-05-14 02:29:23 +08:00
?
2026-03-24 21:48:04 +08:00
. ok_or_else ( || {
raise_error! (
format! (
"Envelope not found: account_id= {} envelope_id= {} " ,
account_id , & envelope_id
),
ErrorCode ::ResourceNotFound
)
}) ? ;
2026-04-01 04:49:18 +08:00
let restored_eml = BLOB_MANAGER
2026-04-05 16:06:07 +08:00
. get_email ( & e . envelope . content_hash ) ?
2026-03-24 21:48:04 +08:00
. ok_or_else ( || {
raise_error! (
format! (
2026-04-01 04:49:18 +08:00
"Original email content not found: account_id= {} envelope_id= {} content_hash= {} " ,
2026-04-05 16:06:07 +08:00
account_id , & envelope_id , & e . envelope . content_hash
2026-04-01 04:49:18 +08:00
),
2026-03-24 21:48:04 +08:00
ErrorCode ::ResourceNotFound
)
}) ? ;
2026-04-05 16:06:07 +08:00
if ! e . envelope . has_any_attachments () {
return Ok (( e . envelope , restored_eml ));
2026-03-24 21:48:04 +08:00
}
2026-04-01 04:49:18 +08:00
let mut restored_eml = restored_eml . to_vec ();
2026-04-05 16:06:07 +08:00
let actual_count = e . attachments . as_ref (). map ( | a | a . len ()). unwrap_or ( 0 );
if e . envelope . attachment_count != actual_count {
2026-03-24 21:48:04 +08:00
return Err ( raise_error! (
2026-04-05 16:06:07 +08:00
format! (
"Consistency check failed: envelope.attachment_count ( {} ) does not match attachments.len ( {} )" ,
e . envelope . attachment_count ,
actual_count
),
2026-03-24 21:48:04 +08:00
ErrorCode ::InternalError
));
}
let mut tasks = Vec ::new ();
2026-04-05 16:06:07 +08:00
for detail in e . attachments . unwrap () {
let placeholder_str = format! ( "<<BICHON_DETACH_HASH: {} >>" , & detail . content_hash );
2026-03-24 21:48:04 +08:00
let pattern = placeholder_str . as_bytes ();
let pattern_len = pattern . len ();
let mut search_cursor = 0 ;
while let Some ( pos ) = restored_eml [ search_cursor .. ]
. windows ( pattern_len )
. position ( | window | window == pattern )
{
let absolute_start = search_cursor + pos ;
let absolute_end = absolute_start + pattern_len ;
tasks . push ((
absolute_start ,
absolute_end ,
2026-04-05 16:06:07 +08:00
detail . content_hash . clone (),
2026-03-24 21:48:04 +08:00
));
search_cursor = absolute_end ;
}
}
tasks . sort_by ( | a , b | b . 0. cmp ( & a . 0 ));
for ( start , end , hash ) in tasks {
2026-04-01 04:49:18 +08:00
if let Some ( original_data ) = BLOB_MANAGER . get_attachment ( & hash ) ? {
2026-03-24 21:48:04 +08:00
restored_eml . splice ( start .. end , original_data . iter (). cloned ());
} else {
error! ( "[ERROR] Missing attachment blob for hash: {}" , hash );
}
}
2026-04-05 16:06:07 +08:00
Ok (( e . envelope , Bytes ::from ( restored_eml )))
2026-03-24 21:48:04 +08:00
}
2026-05-22 13:46:40 +02:00
/// 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 <uid> (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 < Bytes > {
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 ))
}
2025-12-08 01:30:46 +08:00
#[cfg(test)]
mod test {
use html2text ::config ;
#[test]
fn test_various_html_with_overflow_enabled () {
let cases = [
( "<p>Hello World</p>" , "Simple paragraph" ),
( "<h1>Title</h1><p>Content</p>" , "Heading + paragraph" ),
( "<ul><li>Item1</li><li>Item2</li></ul>" , "Unordered list" ),
(
"<strong>Bold</strong> and <em>italic</em>" ,
"Inline formatting" ,
),
(
"<div><span>Nested</span> elements</div>" ,
"Nested inline elements inside block" ,
),
(
"<table><tr><td>A</td><td>B</td></tr></table>" ,
"Simple table" ,
),
(
"<pre> preformatted text \n line2</pre>" ,
"Preformatted block" ,
),
( "😃 emoji test" , "Wide emoji" ),
( "<a href= \" # \" >link</a>" , "Anchor tag" ),
(
"<blockquote><p>Quoted text</p></blockquote>" ,
"Blockquote with paragraph" ,
),
];
for ( html , desc ) in cases {
let result = config ::plain ()
. allow_width_overflow ()
. string_from_read ( html . as_bytes (), 100 );
match result {
Ok ( output ) => {
println! ( "✓ Rendered ( {} ) => \n {} " , desc , output );
}
Err ( e ) => panic! ( "Unexpected error for {} : {:?} " , desc , e ),
}
}
}
}