use std::collections::HashMap;
use mail_parser::{Address, Message, MessageParser, MimeHeaders};
use crate::backend::images::{build_picture, normalize_cid, ImagePolicy, MapImageResolver};
use crate::backend::markdown::escape_text;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
use docling_core::{DoclingDocument, Node, PictureImage};
pub struct EmailBackend {
pub list_attachments: bool,
pub images: ImagePolicy,
}
impl DeclarativeBackend for EmailBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let projected = crate::backend::cfb::CompoundFile::detect(&source.bytes)
.then(|| super::msg::project(&source.bytes))
.flatten();
let raw: &[u8] = projected.as_ref().map_or(&source.bytes, |p| &p.rfc822);
let msg = MessageParser::default()
.parse(raw)
.ok_or_else(|| ConversionError::Parse("email: could not parse message".into()))?;
let mut doc = DoclingDocument::new(&source.name);
if let Some(subject) = msg.subject().map(header_safe).filter(|s| !s.is_empty()) {
doc.push(Node::Heading {
level: 1,
text: escape_text(&subject),
});
}
for (label, addrs) in [("From", msg.from()), ("To", msg.to())] {
let text = format_addresses(addrs);
if !text.is_empty() {
doc.push(Node::Paragraph {
text: escape_text(&format!("{label}: {text}")),
});
}
}
if let Some(date) = msg.date() {
let date = date.to_rfc3339().replace('Z', "+00:00");
doc.push(Node::Paragraph {
text: escape_text(&format!("Date: {date}")),
});
}
match self.body_with_images(source, &msg, projected.as_ref()) {
Some(nodes) => doc.nodes.extend(nodes),
None => {
for para in body_paragraphs(&msg) {
doc.push(Node::Paragraph {
text: escape_text(¶),
});
}
}
}
if self.list_attachments {
let labels = match &projected {
Some(p) => p.attachment_labels.clone(),
None => eml_attachment_labels(&msg),
};
if !labels.is_empty() {
doc.push(Node::Heading {
level: 3,
text: "Attachments".into(),
});
for label in labels {
doc.push(Node::ListItem {
ordered: false,
number: 1,
first_in_list: false,
text: escape_text(&label),
level: 0,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
});
}
}
}
Ok(doc)
}
}
impl EmailBackend {
fn body_with_images(
&self,
source: &SourceDocument,
msg: &Message,
projected: Option<&super::msg::ProjectedMsg>,
) -> Option<Vec<Node>> {
if !self.images.sources.embedded() {
return None;
}
let (bodies, cids): (Vec<String>, HashMap<String, PictureImage>) = match projected {
Some(p) => {
let html = p
.html_body
.as_deref()
.map(|b| super::html::decode_html_bytes(b).into_owned())
.filter(|h| !h.trim().is_empty());
(html.into_iter().collect(), msg_cid_images(&source.bytes))
}
None => (
(0..msg.html_body_count())
.filter_map(|i| msg.body_html(i).map(|h| h.into_owned()))
.collect(),
eml_cid_images(msg),
),
};
for html in bodies {
let resolver = MapImageResolver::new(cids.clone(), &self.images);
let html_doc = super::html::convert_html("email-body.html", &html, &resolver);
if html_doc
.nodes
.iter()
.any(|n| matches!(n, Node::Picture { image: Some(_), .. }))
{
return Some(html_doc.nodes);
}
}
None
}
}
fn eml_cid_images(msg: &Message) -> HashMap<String, PictureImage> {
let mut out = HashMap::new();
for part in &msg.parts {
if part.is_multipart() {
continue;
}
let Some(ct) = part.content_type() else {
continue;
};
if !ct.ctype().eq_ignore_ascii_case("image") {
continue;
}
let Some(id) = part.content_id() else {
continue;
};
let mimetype =
format!("{}/{}", ct.ctype(), ct.subtype().unwrap_or("")).to_ascii_lowercase();
if let Some(pic) = build_picture(mimetype, part.contents().to_vec()) {
out.entry(normalize_cid(id)).or_insert(pic);
}
}
out
}
fn msg_cid_images(bytes: &[u8]) -> HashMap<String, PictureImage> {
let mut out = HashMap::new();
for att in super::msg::attachments(bytes).unwrap_or_default() {
let (Some(id), Some(payload)) = (att.content_id.as_deref(), att.payload) else {
continue;
};
let pic = match att.mime.as_deref().filter(|m| m.starts_with("image/")) {
Some(mime) => build_picture(mime.to_ascii_lowercase(), payload),
None => super::ooxml::picture_image(att.name.as_deref().unwrap_or(""), payload),
};
if let Some(pic) = pic {
out.entry(normalize_cid(id)).or_insert(pic);
}
}
out
}
fn eml_attachment_labels(msg: &Message) -> Vec<String> {
use mail_parser::MimeHeaders;
msg.attachments()
.enumerate()
.map(|(i, part)| {
let name = part
.attachment_name()
.map(header_safe)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("attachment-{}", i + 1));
match part.content_type() {
Some(ct) => {
let mime = match ct.subtype() {
Some(sub) => format!("{}/{sub}", ct.ctype()),
None => ct.ctype().to_string(),
};
format!("{name} ({})", header_safe(&mime))
}
None => name,
}
})
.collect()
}
fn header_safe(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn quote_display_name(name: &str) -> String {
if name.chars().any(|c| {
matches!(
c,
'(' | ')' | '<' | '>' | '[' | ']' | ':' | ';' | '@' | ',' | '"' | '\\'
)
}) {
let escaped: String = name
.chars()
.flat_map(|c| {
if matches!(c, '"' | '\\') {
vec!['\\', c]
} else {
vec![c]
}
})
.collect();
format!("\"{escaped}\"")
} else {
name.to_string()
}
}
fn format_addresses(addr: Option<&Address>) -> String {
let Some(addr) = addr else {
return String::new();
};
addr.iter()
.filter_map(|a| {
let name = a.name().map(header_safe).filter(|s| !s.is_empty());
let email = a.address().map(str::trim).filter(|s| !s.is_empty());
match (name, email) {
(Some(n), Some(e)) => Some(format!("{} <{e}>", quote_display_name(&n))),
(None, Some(e)) => Some(e.to_string()),
_ => None,
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn body_paragraphs(msg: &Message) -> Vec<String> {
let re = cached_regex!(r"\n\s*\n+");
let split = |text: &str, out: &mut Vec<String>| {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
for p in re.split(text.trim()) {
let p = p.trim();
if !p.is_empty() {
out.push(p.to_string());
}
}
};
let mut out = Vec::new();
for i in 0..msg.text_body_count() {
if let Some(t) = msg.body_text(i) {
split(&t, &mut out);
}
}
if !out.is_empty() {
return out;
}
for i in 0..msg.html_body_count() {
if let Some(t) = msg.body_html(i) {
let html_doc =
super::html::convert_html("email-body.html", &t, &super::images::NoFetch);
split(&html_doc.export_to_markdown(), &mut out);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
#[test]
fn title_headers_escaped_and_body_split() {
let eml = "From: Alice <a@x.com>\r\nTo: Bob <b@y.com>\r\nSubject: Hi\r\n\
Content-Type: text/plain\r\n\r\nLine one.\r\n\r\nLine two.\r\n";
let src = SourceDocument::from_bytes("m", InputFormat::Email, eml.as_bytes().to_vec());
let md = EmailBackend {
list_attachments: false,
images: ImagePolicy::default(),
}
.convert(&src)
.unwrap()
.export_to_markdown();
assert_eq!(
md.trim(),
"# Hi\n\nFrom: Alice <a@x.com>\n\nTo: Bob <b@y.com>\n\nLine one.\n\nLine two."
);
}
#[test]
fn display_names_and_bodies_follow_docling_2129() {
assert_eq!(quote_display_name("Doe, John"), "\"Doe, John\"");
assert_eq!(quote_display_name("Ann \"Q\" Lee"), "\"Ann \\\"Q\\\" Lee\"");
assert_eq!(quote_display_name("J. R. Hartley"), "J. R. Hartley");
assert_eq!(quote_display_name("Zoë Müller"), "Zoë Müller");
assert_eq!(header_safe(" two\r\n lines "), "two lines");
let eml = "From: \"Doe, John\" <j@x.com>\r\nTo: b@y.com\r\nSubject: Re:\r\n folded\r\n\
Content-Type: text/plain\r\n\r\nOne.\r\rTwo.\r";
let src = SourceDocument::from_bytes("m.eml", InputFormat::Email, eml.as_bytes().to_vec());
let md = EmailBackend {
list_attachments: false,
images: ImagePolicy::default(),
}
.convert(&src)
.unwrap()
.export_to_markdown();
assert!(
md.starts_with("# Re: folded\n\nFrom: \"Doe, John\" <j@x.com>\n"),
"{md}"
);
assert!(md.ends_with("One.\n\nTwo.\n"), "{md}");
}
#[test]
fn eml_list_attachments_appends_labels() {
let eml = concat!(
"From: A <a@x.com>\r\n",
"To: B <b@y.com>\r\n",
"Subject: S\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/mixed; boundary=\"bb\"\r\n\r\n",
"--bb\r\nContent-Type: text/plain\r\n\r\nBody.\r\n",
"--bb\r\nContent-Type: text/plain\r\n",
"Content-Disposition: attachment; filename=\"note.txt\"\r\n\r\n",
"hi\r\n--bb--\r\n",
);
let src = SourceDocument::from_bytes("m", crate::InputFormat::Email, eml.into());
let md = EmailBackend {
list_attachments: true,
images: ImagePolicy::default(),
}
.convert(&src)
.unwrap()
.export_to_markdown();
assert!(md.contains("### Attachments"), "{md}");
assert!(md.contains("- note.txt (text/plain)"), "{md}");
let md_off = EmailBackend {
list_attachments: false,
images: ImagePolicy::default(),
}
.convert(&src)
.unwrap()
.export_to_markdown();
assert!(!md_off.contains("Attachments"), "{md_off}");
}
}