use super::*;
pub(crate) struct ParsedEmailContent {
pub(crate) result: EmailExtractionResult,
pub(crate) nested_messages: Vec<NestedMessagePayload>,
pub(crate) nested_embedded_messages: Vec<EmailExtractionResult>,
pub(crate) warnings: Vec<ProcessingWarning>,
}
pub(crate) struct NestedMessagePayload {
part_id: usize,
pub(crate) data: Option<Bytes>,
pub(crate) size: usize,
}
pub(crate) fn parse_eml_content(data: &[u8]) -> Result<EmailExtractionResult> {
Ok(parse_eml_content_internal(data, false, None)?.result)
}
pub(super) struct EmailAddressFields {
subject: Option<String>,
from_email: Option<String>,
from_name: Option<String>,
to_emails: Vec<String>,
cc_emails: Vec<String>,
bcc_emails: Vec<String>,
date: Option<String>,
message_id: Option<String>,
reply_to: Vec<String>,
in_reply_to: Vec<String>,
references: Vec<String>,
}
pub(super) fn extract_sender_fields(message: &mail_parser::Message<'_>) -> (Option<String>, Option<String>) {
let sender = message.from().and_then(|from| from.first());
let from_email = sender.and_then(|address| address.address().map(str::to_string));
let from_name = sender
.and_then(|address| address.name().map(str::trim))
.filter(|name| !name.is_empty())
.map(str::to_string);
(from_email, from_name)
}
pub(super) fn extract_email_date_field(message: &mail_parser::Message<'_>, data: &[u8]) -> Option<String> {
extract_raw_date_header(data).or_else(|| {
message.date().and_then(|d| {
let rfc3339 = d.to_rfc3339();
if rfc3339.starts_with("2000-00") || rfc3339.starts_with("0000-") {
None
} else {
Some(rfc3339)
}
})
})
}
pub(super) fn extract_email_address_fields(message: &mail_parser::Message<'_>, data: &[u8]) -> EmailAddressFields {
let subject = message.subject().map(|s| s.to_string());
let (from_email, from_name) = extract_sender_fields(message);
let to_emails: Vec<String> = message
.to()
.map(|to| {
to.iter()
.filter_map(|addr| addr.address().map(|email| email.to_string()))
.collect()
})
.unwrap_or_default();
let cc_emails: Vec<String> = message
.cc()
.map(|cc| {
cc.iter()
.filter_map(|addr| addr.address().map(|email| email.to_string()))
.collect()
})
.unwrap_or_default();
let bcc_emails: Vec<String> = message
.bcc()
.map(|bcc| {
bcc.iter()
.filter_map(|addr| addr.address().map(|email| email.to_string()))
.collect()
})
.unwrap_or_default();
let date = extract_email_date_field(message, data);
let message_id = message.message_id().map(|id| id.to_string());
let reply_to: Vec<String> = message
.reply_to()
.map(|addrs| {
addrs
.iter()
.filter_map(|addr| addr.address().map(|email| email.to_string()))
.collect()
})
.unwrap_or_default();
let in_reply_to: Vec<String> = message
.in_reply_to()
.as_text_list()
.map(|list| list.iter().map(|s| s.to_string()).collect())
.unwrap_or_default();
let references: Vec<String> = message
.references()
.as_text_list()
.map(|list| list.iter().map(|s| s.to_string()).collect())
.unwrap_or_default();
EmailAddressFields {
subject,
from_email,
from_name,
to_emails,
cc_emails,
bcc_emails,
date,
message_id,
reply_to,
in_reply_to,
references,
}
}
pub(super) fn build_email_attachments(
message: &mail_parser::Message<'_>,
nested_messages: &[NestedMessagePayload],
include_nested_messages: bool,
) -> Vec<EmailAttachment> {
let mut attachments = Vec::with_capacity(message.attachments().count().min(20));
for &part_id in &message.attachments {
let Some(attachment) = message.parts.get(part_id as usize) else {
continue;
};
let filename = attachment.attachment_name().map(|s| s.to_string());
let mime_type = if matches!(&attachment.body, mail_parser::PartType::Message(_)) {
"message/rfc822".to_string()
} else {
attachment
.content_type()
.map(|ct| {
let content_type_str = format!("{}/{}", ct.ctype(), ct.subtype().unwrap_or("octet-stream"));
parse_content_type(&content_type_str)
})
.unwrap_or_else(|| "application/octet-stream".to_string())
};
let (data, size) = match &attachment.body {
mail_parser::PartType::Message(nested) => {
let raw_message = nested.raw_message();
let data = if include_nested_messages {
nested_messages
.iter()
.find(|payload| payload.part_id == part_id as usize)
.and_then(|payload| payload.data.clone())
} else {
Some(Bytes::copy_from_slice(raw_message))
};
(data, raw_message.len())
}
_ => {
let contents = attachment.contents();
(Some(Bytes::copy_from_slice(contents)), contents.len())
}
};
let is_image = is_image_mime_type(&mime_type);
attachments.push(EmailAttachment {
name: filename.clone(),
filename,
mime_type: Some(mime_type),
size: Some(size),
is_image,
data,
});
}
attachments
}
pub(super) fn augment_email_metadata(
metadata: &mut HashMap<String, String>,
addresses: &EmailAddressFields,
raw_headers: &HashMap<String, String>,
attachments: &[EmailAttachment],
) {
if let Some(from_name) = &addresses.from_name {
metadata.insert("from_name".to_string(), from_name.clone());
}
if !addresses.reply_to.is_empty() {
metadata.insert("reply_to".to_string(), addresses.reply_to.join(", "));
}
if !addresses.in_reply_to.is_empty() {
metadata.insert("in_reply_to".to_string(), addresses.in_reply_to.join(", "));
}
if !addresses.references.is_empty() {
metadata.insert("references".to_string(), addresses.references.join(", "));
}
for (key, value) in raw_headers {
metadata.insert(key.clone(), value.clone());
}
if !attachments.is_empty() {
let attachment_details: Vec<String> = attachments
.iter()
.map(|att| {
let name = att.filename.as_deref().or(att.name.as_deref()).unwrap_or("unnamed");
let mime = att.mime_type.as_deref().unwrap_or("application/octet-stream");
let size = att.size.unwrap_or(0);
format!("{}|{}|{}", name, mime, size)
})
.collect();
metadata.insert("attachment_details".to_string(), attachment_details.join("; "));
}
}
pub(super) fn extract_email_bodies(
message: &mail_parser::Message<'_>,
should_treat_as_html: bool,
) -> (Option<String>, Option<String>) {
let has_genuine_text_part = has_genuine_text_body(message);
let plain_text = if has_genuine_text_part || should_treat_as_html {
let mut all_text = Vec::new();
let mut i = 0;
while let Some(text) = message.body_text(i) {
all_text.push(text.to_string());
i += 1;
}
collect_nested_message_text(message, &mut all_text);
if all_text.is_empty() {
None
} else {
Some(all_text.join("\n\n"))
}
} else {
None
};
let has_genuine_html_part = has_genuine_html_body(message);
let html_content = if has_genuine_html_part || should_treat_as_html {
let mut all_html = Vec::new();
let mut i = 0;
while let Some(html) = message.body_html(i) {
all_html.push(html.to_string());
i += 1;
}
collect_nested_message_html(message, &mut all_html);
if all_html.is_empty() {
None
} else {
Some(all_html.join("\n\n"))
}
} else {
None
};
(plain_text, html_content)
}
pub(super) fn resolve_email_content(
plain_text: &Option<String>,
html_content: Option<String>,
should_treat_as_html: bool,
) -> (Option<String>, String) {
let html_content = if should_treat_as_html && html_content.is_none() && plain_text.is_some() {
plain_text.clone()
} else {
html_content
};
let content = if let Some(html) = &html_content {
clean_html_content(html)
} else if let Some(plain) = plain_text {
plain.clone()
} else {
String::new()
};
(html_content, content)
}
pub(super) fn parse_eml_content_internal(
data: &[u8],
include_nested_messages: bool,
max_nested_message_bytes: Option<u64>,
) -> Result<ParsedEmailContent> {
let data = if let Some(transcoded) = maybe_transcode_utf16(data) {
std::borrow::Cow::Owned(transcoded)
} else {
std::borrow::Cow::Borrowed(data)
};
let message = mail_parser::MessageParser::default()
.parse(&data)
.ok_or_else(|| XbergError::parsing("Failed to parse EML file: invalid email format".to_string()))?;
let addresses = extract_email_address_fields(&message, &data);
let raw_headers = extract_raw_headers(&data);
let should_treat_as_html = message
.content_type()
.and_then(|ct| ct.subtype())
.map(|subtype| subtype.eq_ignore_ascii_case("html"))
.unwrap_or(false);
let (plain_text, html_content) = extract_email_bodies(&message, should_treat_as_html);
let (html_content, content) = resolve_email_content(&plain_text, html_content, should_treat_as_html);
let nested_messages = if include_nested_messages {
collect_nested_message_payloads(&message, max_nested_message_bytes)
} else {
Vec::new()
};
let attachments = build_email_attachments(&message, &nested_messages, include_nested_messages);
let mut metadata = build_metadata(EmailMetadataFields {
subject: &addresses.subject,
from_email: &addresses.from_email,
to_emails: &addresses.to_emails,
cc_emails: &addresses.cc_emails,
bcc_emails: &addresses.bcc_emails,
date: &addresses.date,
message_id: &addresses.message_id,
attachments: &attachments,
});
augment_email_metadata(&mut metadata, &addresses, &raw_headers, &attachments);
let EmailAddressFields {
subject,
from_email,
to_emails,
cc_emails,
bcc_emails,
date,
message_id,
..
} = addresses;
Ok(ParsedEmailContent {
result: EmailExtractionResult {
subject,
from_email,
to_emails,
cc_emails,
bcc_emails,
date,
message_id,
plain_text,
html_content,
content,
attachments,
metadata,
},
nested_messages,
nested_embedded_messages: Vec::new(),
warnings: Vec::new(),
})
}
pub(super) fn collect_nested_message_payloads(
message: &mail_parser::Message<'_>,
max_nested_message_bytes: Option<u64>,
) -> Vec<NestedMessagePayload> {
use mail_parser::PartType;
message
.parts
.iter()
.enumerate()
.filter_map(|(part_id, part)| match &part.body {
PartType::Message(nested) if !nested.raw_message().is_empty() => {
let raw_message = nested.raw_message();
let data = max_nested_message_bytes
.is_none_or(|cap| raw_message.len() as u64 <= cap)
.then(|| Bytes::copy_from_slice(raw_message));
Some(NestedMessagePayload {
part_id,
data,
size: raw_message.len(),
})
}
_ => None,
})
.collect()
}
pub(super) fn has_genuine_text_body(message: &mail_parser::Message<'_>) -> bool {
use mail_parser::PartType;
for &part_id in &message.text_body {
if let Some(part) = message.parts.get(part_id as usize)
&& matches!(&part.body, PartType::Text(_))
{
return true;
}
}
false
}
pub(super) fn has_genuine_html_body(message: &mail_parser::Message<'_>) -> bool {
use mail_parser::PartType;
for &part_id in &message.html_body {
if let Some(part) = message.parts.get(part_id as usize)
&& matches!(&part.body, PartType::Html(_))
{
return true;
}
}
false
}
pub(super) fn collect_nested_message_text(message: &mail_parser::Message<'_>, out: &mut Vec<String>) {
use mail_parser::PartType;
for part in &message.parts {
if let PartType::Message(sub_msg) = &part.body {
let mut i = 0;
while let Some(text) = sub_msg.body_text(i) {
out.push(text.to_string());
i += 1;
}
collect_nested_message_text(sub_msg, out);
}
}
}
pub(super) fn collect_nested_message_html(message: &mail_parser::Message<'_>, out: &mut Vec<String>) {
use mail_parser::PartType;
for part in &message.parts {
if let PartType::Message(sub_msg) = &part.body {
let mut i = 0;
while let Some(html) = sub_msg.body_html(i) {
out.push(html.to_string());
i += 1;
}
collect_nested_message_html(sub_msg, out);
}
}
}
const DATE_HEADER_SCAN_CAP: usize = 8192;
const RAW_HEADER_SCAN_CAP: usize = 16384;
pub(super) fn find_header_section_end(data: &[u8], cap: usize) -> usize {
if let Some(pos) = memchr::memmem::find(data, b"\r\n\r\n") {
return pos;
}
if let Some(pos) = memchr::memmem::find(data, b"\n\n") {
return pos;
}
data.len().min(cap)
}
pub(super) fn extract_raw_date_header(data: &[u8]) -> Option<String> {
let header_end = find_header_section_end(data, DATE_HEADER_SCAN_CAP);
let headers = String::from_utf8_lossy(&data[..header_end]);
let headers = headers.as_ref();
let mut date_value = None;
for line in headers.lines() {
if let Some(val) = line.strip_prefix("Date:").or_else(|| line.strip_prefix("date:")) {
date_value = Some(val.trim().to_string());
} else if date_value.is_some() && (line.starts_with(' ') || line.starts_with('\t')) {
if let Some(ref mut dv) = date_value {
dv.push(' ');
dv.push_str(line.trim());
}
} else if date_value.is_some() {
break;
}
}
date_value.filter(|s| !s.is_empty())
}
pub(super) fn extract_raw_headers(data: &[u8]) -> HashMap<String, String> {
let mut headers = HashMap::new();
let header_end = find_header_section_end(data, RAW_HEADER_SCAN_CAP);
let header_section = String::from_utf8_lossy(&data[..header_end]);
let header_section = header_section.as_ref();
let target_headers: &[(&str, &str)] = &[
("content-type:", "content_type"),
("mime-version:", "mime_version"),
("x-mailer:", "x_mailer"),
("user-agent:", "user_agent"),
("list-id:", "list_id"),
("list-unsubscribe:", "list_unsubscribe"),
];
let mut current_key: Option<&str> = None;
let mut current_value = String::new();
for line in header_section.lines() {
if line.starts_with(' ') || line.starts_with('\t') {
if current_key.is_some() {
current_value.push(' ');
current_value.push_str(line.trim());
}
continue;
}
if let Some(key) = current_key {
if !current_value.is_empty() {
headers.insert(key.to_string(), current_value.clone());
}
current_key = None;
current_value.clear();
}
let line_lower = line.to_lowercase();
for &(prefix, meta_key) in target_headers {
if line_lower.starts_with(prefix) {
current_key = Some(meta_key);
current_value = line[prefix.len()..].trim().to_string();
break;
}
}
}
if let Some(key) = current_key
&& !current_value.is_empty()
{
headers.insert(key.to_string(), current_value);
}
headers
}