// // Copyright (c) 2025 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::modules::common::AddrVec; use crate::modules::error::code::ErrorCode; use crate::modules::error::BichonResult; use crate::modules::utils::create_hash; use crate::{calculate_hash, raise_error, utc_now}; use crate::{id, modules::indexer::envelope::Envelope}; use async_imap::types::Fetch; use html2text::from_read; use mail_parser::{Message, MessageParser, MimeHeaders}; pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult { 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))?; let size = fetch.size.unwrap_or(body.len() as u32); let message = MessageParser::new().parse(body).ok_or_else(|| { raise_error!( "Email header parse result is not available".into(), ErrorCode::InternalError ) })?; 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()) { from_read(html.as_bytes(), 0) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? } else { String::new() }; let message_id = message .message_id() .map(String::from) .unwrap_or(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 subject = message.subject().map(String::from).unwrap_or("".into()); let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0); let bcc: Option> = message.bcc().map(|addr| { AddrVec::from(addr) .0 .into_iter() .filter_map(|a| a.address) .collect() }); let cc: Option> = message.cc().map(|addr| { AddrVec::from(addr) .0 .into_iter() .filter_map(|a| a.address) .collect() }); let to: Option> = message.to().map(|addr| { AddrVec::from(addr) .0 .into_iter() .filter_map(|a| a.address) .collect() }); 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 attachments: Vec = message .attachments() .filter_map(|att| att.attachment_name()) .map(|name| name.to_string()) .collect(); let envelope = Envelope { id: create_hash(account_id, &message_id), message_id, account_id, mailbox_id, uid, subject, text, from, to: to.unwrap_or_default(), cc: cc.unwrap_or_default(), bcc: bcc.unwrap_or_default(), date, internal_date, size, thread_id, attachments, tags: None, }; Ok(envelope) } pub fn extract_envelope_from_eml( body: &[u8], account_id: u64, mailbox_id: u64, ) -> BichonResult { let uid = 0; let size = body.len() as u32; let message = MessageParser::new().parse(body).ok_or_else(|| { raise_error!( "Email header parse result is not available".into(), ErrorCode::InternalError ) })?; 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()) { html2text::config::plain() .allow_width_overflow() .string_from_read(html.as_bytes(), 100) .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))? } else { String::new() }; let message_id = message .message_id() .map(String::from) .unwrap_or(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 subject = message.subject().map(String::from).unwrap_or("".into()); let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0); let bcc: Option> = message.bcc().map(|addr| { AddrVec::from(addr) .0 .into_iter() .filter_map(|a| a.address) .collect() }); let cc: Option> = message.cc().map(|addr| { AddrVec::from(addr) .0 .into_iter() .filter_map(|a| a.address) .collect() }); let to: Option> = message.to().map(|addr| { AddrVec::from(addr) .0 .into_iter() .filter_map(|a| a.address) .collect() }); 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 attachments: Vec = message .attachments() .filter_map(|att| att.attachment_name()) .map(|name| name.to_string()) .collect(); let envelope = Envelope { id: create_hash(account_id, &message_id), message_id, account_id, mailbox_id, uid, subject, text, from, to: to.unwrap_or_default(), cc: cc.unwrap_or_default(), bcc: bcc.unwrap_or_default(), date, internal_date: date, size, thread_id, attachments, tags: None, }; Ok(envelope) } pub fn compute_thread_id( in_reply_to: Option, references: Option>, message_id: &str, ) -> u64 { if in_reply_to.is_some() && references.as_ref().map_or(false, |r| !r.is_empty()) { return calculate_hash!(&references.as_ref().unwrap()[0]); } calculate_hash!(message_id) } pub fn generate_message_id() -> String { let ts = utc_now!(); let pid = std::process::id(); format!("<{:016x}.{}.{}@{}>", id!(128), ts, pid, "bichon") } fn extract_references(message: &Message<'_>) -> Option> { 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, } } #[cfg(test)] mod test { use html2text::config; #[test] fn test_various_html_with_overflow_enabled() { let cases = [ ("

Hello World

", "Simple paragraph"), ("

Title

Content

", "Heading + paragraph"), ("
  • Item1
  • Item2
", "Unordered list"), ( "Bold and italic", "Inline formatting", ), ( "
Nested elements
", "Nested inline elements inside block", ), ( "
AB
", "Simple table", ), ( "
  preformatted text\n  line2
", "Preformatted block", ), ("😃 emoji test", "Wide emoji"), ("link", "Anchor tag"), ( "

Quoted text

", "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), } } } }