use crate::part::ParsedPart;
struct AppendTarget<'a>(Option<&'a mut Vec<String>>);
impl<'a> AppendTarget<'a> {
fn new(vec: &'a mut Vec<String>) -> Self {
Self(Some(vec))
}
fn push(&mut self, id: String) {
if let Some(ref mut v) = self.0 {
v.push(id);
}
}
fn disable(&mut self) {
self.0 = None;
}
fn is_active(&self) -> bool {
self.0.is_some()
}
fn len(&self) -> usize {
self.0.as_ref().map_or(0, |v| v.len())
}
fn slice_from(&self, start: usize) -> Vec<String> {
self.0
.as_ref()
.map(|v| v[start..].to_vec())
.unwrap_or_default()
}
fn extend(&mut self, ids: Vec<String>) {
if let Some(ref mut v) = self.0 {
v.extend(ids);
}
}
fn as_child(&mut self) -> AppendTarget<'_> {
AppendTarget(self.0.as_deref_mut())
}
}
pub(crate) struct BodyStructure {
pub(crate) text_body: Vec<String>,
pub(crate) html_body: Vec<String>,
pub(crate) attachments: Vec<String>,
}
pub fn compute_body_structure(root: &ParsedPart) -> BodyStructure {
let mut text_body: Vec<String> = Vec::new();
let mut html_body: Vec<String> = Vec::new();
let mut attachments: Vec<String> = Vec::new();
parse_structure(
std::slice::from_ref(root),
"mixed",
false,
&mut AppendTarget::new(&mut text_body),
&mut AppendTarget::new(&mut html_body),
&mut attachments,
);
BodyStructure {
text_body,
html_body,
attachments,
}
}
fn is_inline_media_type(media_type: &str) -> bool {
media_type.starts_with("image/")
|| media_type.starts_with("audio/")
|| media_type.starts_with("video/")
}
fn parse_structure(
parts: &[ParsedPart],
multipart_type: &str,
in_alternative: bool,
text_body: &mut AppendTarget<'_>,
html_body: &mut AppendTarget<'_>,
attachments: &mut Vec<String>,
) {
let text_length_at_entry = text_body.len();
let html_length_at_entry = html_body.len();
for (i, part) in parts.iter().enumerate() {
let is_multipart = part.content_type.starts_with("multipart/");
let is_inline = part
.disposition
.as_deref()
.is_none_or(|d| !d.eq_ignore_ascii_case("attachment"))
&& (part.content_type == "text/plain"
|| part.content_type == "text/html"
|| is_inline_media_type(&part.content_type))
&& (i == 0
|| (multipart_type != "related"
&& (is_inline_media_type(&part.content_type) || part.filename.is_none())));
if is_multipart {
let sub_multipart_type = part
.content_type
.split_once('/')
.map(|(_, sub)| sub)
.unwrap_or("mixed");
let new_in_alternative = in_alternative || sub_multipart_type == "alternative";
let mut sub_text = text_body.as_child();
let mut sub_html = html_body.as_child();
parse_structure(
&part.children,
sub_multipart_type,
new_in_alternative,
&mut sub_text,
&mut sub_html,
attachments,
);
} else if is_inline {
if multipart_type == "alternative" {
match part.content_type.as_str() {
"text/plain" => {
text_body.push(part.part_id.clone());
}
"text/html" => {
html_body.push(part.part_id.clone());
}
_ => {
attachments.push(part.part_id.clone());
}
}
continue;
} else if in_alternative {
if part.content_type == "text/plain" {
html_body.disable(); }
if part.content_type == "text/html" {
text_body.disable(); }
}
text_body.push(part.part_id.clone());
html_body.push(part.part_id.clone());
if (!text_body.is_active() || !html_body.is_active())
&& is_inline_media_type(&part.content_type)
{
attachments.push(part.part_id.clone());
}
} else {
attachments.push(part.part_id.clone());
}
}
if multipart_type == "alternative" {
let tb_active = text_body.is_active();
let hb_active = html_body.is_active();
if tb_active && hb_active {
let text_now = text_body.len();
let html_now = html_body.len();
if text_length_at_entry == text_now && html_length_at_entry != html_now {
let new_ids = html_body.slice_from(html_length_at_entry);
text_body.extend(new_ids);
}
if html_length_at_entry == html_now && text_length_at_entry != text_now {
let new_ids = text_body.slice_from(text_length_at_entry);
html_body.extend(new_ids);
}
}
}
}
#[cfg(test)]
mod tests {
use crate::parse;
#[test]
fn simple_text_plain() {
let raw =
b"From: a@b.com\r\nMIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nHello\r\n";
let msg = parse(raw).expect("parse failed");
assert_eq!(msg.text_body, vec!["1".to_owned()]);
assert_eq!(msg.html_body, vec!["1".to_owned()]);
assert!(msg.attachments.is_empty(), "attachments should be empty");
}
#[test]
fn multipart_alternative_text_and_html() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/alternative; boundary=\"b\"\r\n",
"\r\n",
"--b\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"Hello text\r\n",
"--b\r\n",
"Content-Type: text/html\r\n",
"\r\n",
"<p>Hello html</p>\r\n",
"--b--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(msg.text_body, vec!["1".to_owned()]);
assert_eq!(msg.html_body, vec!["2".to_owned()]);
assert!(msg.attachments.is_empty(), "attachments should be empty");
}
#[test]
fn multipart_mixed_text_and_attachment() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/mixed; boundary=\"b\"\r\n",
"\r\n",
"--b\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"Body text\r\n",
"--b\r\n",
"Content-Type: application/pdf\r\n",
"Content-Disposition: attachment; filename=\"doc.pdf\"\r\n",
"\r\n",
"<pdf content>\r\n",
"--b--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(msg.text_body, vec!["1".to_owned()]);
assert_eq!(msg.html_body, vec!["1".to_owned()]);
assert_eq!(msg.attachments, vec!["2".to_owned()]);
}
#[test]
fn alternative_html_only_mirrors_to_text_body() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/alternative; boundary=\"b\"\r\n",
"\r\n",
"--b\r\n",
"Content-Type: text/html\r\n",
"\r\n",
"<p>HTML only</p>\r\n",
"--b--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(msg.text_body, vec!["1".to_owned()]);
assert_eq!(msg.html_body, vec!["1".to_owned()]);
assert!(msg.attachments.is_empty());
}
#[test]
fn alternative_text_only_mirrors_to_html_body() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/alternative; boundary=\"b\"\r\n",
"\r\n",
"--b\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"Text only\r\n",
"--b--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(msg.text_body, vec!["1".to_owned()]);
assert_eq!(msg.html_body, vec!["1".to_owned()]);
assert!(msg.attachments.is_empty());
}
#[test]
fn related_non_first_child_goes_to_attachments() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/related; boundary=\"b\"\r\n",
"\r\n",
"--b\r\n",
"Content-Type: text/html\r\n",
"\r\n",
"<p>HTML with inline image</p>\r\n",
"--b\r\n",
"Content-Type: image/gif\r\n",
"Content-ID: <img@example.com>\r\n",
"\r\n",
"<gif data>\r\n",
"--b--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(msg.text_body, vec!["1".to_owned()]);
assert_eq!(msg.html_body, vec!["1".to_owned()]);
assert_eq!(msg.attachments, vec!["2".to_owned()]);
}
#[test]
fn alternative_mixed_image_gif_goes_to_both_body_lists() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/alternative; boundary=\"outer\"\r\n",
"\r\n",
"--outer\r\n",
"Content-Type: multipart/mixed; boundary=\"inner\"\r\n",
"\r\n",
"--inner\r\n",
"Content-Type: image/gif\r\n",
"\r\n",
"<gif data>\r\n",
"--inner--\r\n",
"--outer--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(
msg.text_body,
vec!["1.1".to_owned()],
"image/gif inside alt→mixed should appear in text_body"
);
assert_eq!(
msg.html_body,
vec!["1.1".to_owned()],
"image/gif inside alt→mixed should appear in html_body"
);
assert!(
msg.attachments.is_empty(),
"image/gif with no attachment disposition should not be in attachments; got: {:?}",
msg.attachments
);
}
#[test]
fn octet_stream_no_disposition_goes_to_attachments() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/mixed; boundary=\"b\"\r\n",
"\r\n",
"--b\r\n",
"Content-Type: application/octet-stream\r\n",
"\r\n",
"<binary data>\r\n",
"--b--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert!(
msg.text_body.is_empty(),
"text_body must be empty; got: {:?}",
msg.text_body
);
assert!(
msg.html_body.is_empty(),
"html_body must be empty; got: {:?}",
msg.html_body
);
assert_eq!(
msg.attachments,
vec!["1".to_owned()],
"application/octet-stream without Content-Disposition must go to attachments"
);
}
#[test]
fn alternative_mixed_subtree_nullification_is_local() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/alternative; boundary=\"outer\"\r\n",
"\r\n",
"--outer\r\n",
"Content-Type: multipart/mixed; boundary=\"inner\"\r\n",
"\r\n",
"--inner\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"Plain text in mixed\r\n",
"--inner--\r\n",
"--outer\r\n",
"Content-Type: text/html\r\n",
"\r\n",
"<p>HTML at alternative level; htmlBody is still live</p>\r\n",
"--outer--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(msg.text_body, vec!["1.1".to_owned()]);
assert_eq!(
msg.html_body,
vec!["2".to_owned()],
"html_body should contain text/html part '2'; nullification in nested call is local"
);
assert!(msg.attachments.is_empty());
}
#[test]
fn alternative_mixed_both_text_types_dual_nullification() {
let raw = concat!(
"From: a@b.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/alternative; boundary=\"outer\"\r\n",
"\r\n",
"--outer\r\n",
"Content-Type: multipart/mixed; boundary=\"inner\"\r\n",
"\r\n",
"--inner\r\n",
"Content-Type: text/plain\r\n",
"\r\n",
"Plain text\r\n",
"--inner\r\n",
"Content-Type: text/html\r\n",
"\r\n",
"<p>HTML text</p>\r\n",
"--inner--\r\n",
"--outer--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse failed");
assert_eq!(
msg.text_body,
vec!["1.1".to_owned()],
"text/plain survives local nullification"
);
assert_eq!(
msg.html_body,
vec!["1.1".to_owned()],
"cross-population mirrors text/plain into html_body"
);
}
}