use mime_tree::{decode_body_value, parse, ParseError, TransferEncoding};
#[test]
fn test_simple_plain_text() {
let raw = b"From: alice@example.com\r\n\
To: bob@example.com\r\n\
Subject: Hello\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Hello, World!\r\n";
let msg = parse(raw).expect("parse of valid RFC 5322 message must succeed");
assert_eq!(msg.part_index.content_type, "text/plain");
assert_eq!(msg.part_index.charset, Some("utf-8".to_owned()));
assert_eq!(msg.text_body, vec!["1".to_owned()]);
assert_eq!(msg.html_body, vec!["1".to_owned()]);
assert!(msg.attachments.is_empty(), "no attachments expected");
assert!(
msg.warnings.is_empty(),
"unexpected warnings: {:?}",
msg.warnings
);
let has_from = msg.headers.iter().any(|h| h.name == "From");
assert!(has_from, "From header must be present in parsed headers");
}
#[test]
fn test_multipart_alternative() {
let raw = concat!(
"From: alice@example.com\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/alternative; boundary=\"boundary\"\r\n",
"\r\n",
"--boundary\r\n",
"Content-Type: text/plain; charset=utf-8\r\n",
"\r\n",
"Plain text body\r\n",
"--boundary\r\n",
"Content-Type: text/html; charset=utf-8\r\n",
"\r\n",
"<html><body>HTML body</body></html>\r\n",
"--boundary--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse must succeed");
assert_eq!(
msg.text_body,
vec!["1".to_owned()],
"text/plain child must be in text_body"
);
assert_eq!(
msg.html_body,
vec!["2".to_owned()],
"text/html child must be in html_body"
);
assert!(msg.attachments.is_empty(), "no attachments expected");
assert_eq!(
msg.part_index.children.len(),
2,
"root must have 2 children"
);
assert_eq!(msg.part_index.children[0].part_id, "1");
assert_eq!(msg.part_index.children[0].content_type, "text/plain");
assert_eq!(msg.part_index.children[1].part_id, "2");
assert_eq!(msg.part_index.children[1].content_type, "text/html");
}
#[test]
fn test_multipart_mixed_with_attachment() {
let raw = concat!(
"From: alice@example.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",
"Main body text\r\n",
"--b\r\n",
"Content-Type: application/pdf\r\n",
"Content-Disposition: attachment; filename=\"doc.pdf\"\r\n",
"Content-Transfer-Encoding: base64\r\n",
"\r\n",
"SGVsbG8=\r\n",
"--b--\r\n"
)
.as_bytes();
let msg = parse(raw).expect("parse must succeed");
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()]);
let pdf_part = &msg.part_index.children[1];
assert_eq!(
pdf_part.disposition,
Some("attachment".to_owned()),
"disposition must be 'attachment'"
);
assert_eq!(
pdf_part.filename,
Some("doc.pdf".to_owned()),
"filename must be 'doc.pdf'"
);
}
#[test]
fn test_byte_range_validity() {
let raw = b"From: test@example.com\r\n\r\nBody content\r\n";
let msg = parse(raw).expect("parse must succeed");
let (body_off, body_len) = msg.part_index.body_range;
let (hdr_off, hdr_len) = msg.part_index.header_range;
assert!(
(body_off as usize).saturating_add(body_len as usize) <= raw.len(),
"body_range ({body_off}, {body_len}) exceeds raw.len()={}",
raw.len()
);
assert!(
(hdr_off as usize).saturating_add(hdr_len as usize) <= raw.len(),
"header_range ({hdr_off}, {hdr_len}) exceeds raw.len()={}",
raw.len()
);
let body_slice = &raw[body_off as usize..(body_off + body_len) as usize];
assert!(
std::str::from_utf8(body_slice)
.expect("body must be valid UTF-8")
.contains("Body content"),
"body slice does not contain expected text; got: {:?}",
std::str::from_utf8(body_slice)
);
}
#[test]
fn test_empty_input_error() {
let result = parse(b"");
assert!(
matches!(result, Err(ParseError::EmptyInput)),
"empty input must return EmptyInput, got: {:?}",
result
);
}
#[test]
fn test_decode_body_value_base64() {
let raw = b"From: test@example.com\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
SGVsbG8sIFdvcmxkIQ==\r\n";
let msg = parse(raw).expect("parse must succeed");
let decoded = decode_body_value(raw, &msg.part_index, None)
.expect("decode_body_value must succeed for valid base64");
assert_eq!(decoded.value, "Hello, World!");
assert!(!decoded.is_truncated, "no truncation limit was applied");
assert!(
!decoded.is_encoding_problem,
"valid base64 + utf-8 must not report encoding problems"
);
}
#[test]
fn test_smime_parts_are_opaque_leaves() {
let raw = b"From: test@example.com\r\n\
Content-Type: application/pkcs7-mime; smime-type=enveloped-data\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
SGVsbG8=\r\n";
let msg = parse(raw).expect("parse must succeed");
assert_eq!(
msg.part_index.content_type, "application/pkcs7-mime",
"content-type must be preserved as registered IANA type"
);
assert!(
!msg.text_body.contains(&"1".to_owned()),
"S/MIME part must not appear in text_body"
);
assert!(
!msg.html_body.contains(&"1".to_owned()),
"S/MIME part must not appear in html_body"
);
assert_eq!(
msg.attachments,
vec!["1".to_owned()],
"S/MIME part must go to attachments"
);
}
#[test]
fn test_no_content_type_defaults_to_text_plain() {
let raw = b"From: alice@example.com\r\n\
MIME-Version: 1.0\r\n\
\r\n\
Hello, this is a bare body with no Content-Type header.\r\n";
let msg = parse(raw).expect("parse must succeed");
assert_eq!(
msg.part_index.content_type, "text/plain",
"missing Content-Type must default to text/plain per RFC 2045 §5.2"
);
assert_eq!(
msg.part_index.charset,
Some("us-ascii".to_owned()),
"missing Content-Type must default to charset=us-ascii per RFC 2045 §5.2"
);
assert!(
msg.text_body.contains(&msg.part_index.part_id),
"bare-body part must appear in text_body; text_body={:?}",
msg.text_body
);
}
#[test]
fn test_rfc2047_utf8_base64_subject() {
let raw = b"From: alice@example.com\r\n\
Subject: =?utf-8?b?SGVsbG8=?=\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
body\r\n";
let msg = parse(raw).expect("parse must succeed");
let subject = msg
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("Subject"))
.map(|h| h.value.as_str())
.unwrap_or("");
assert_eq!(
subject, "Hello",
"RFC 2047 UTF-8/base64 encoded-word must be decoded to 'Hello', got: {subject:?}"
);
}
#[test]
fn test_rfc2047_iso8859_qp_subject() {
let raw = b"From: alice@example.com\r\n\
Subject: =?iso-8859-1?q?caf=E9?=\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
body\r\n";
let msg = parse(raw).expect("parse must succeed");
let subject = msg
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("Subject"))
.map(|h| h.value.as_str())
.unwrap_or("");
assert_eq!(
subject, "café",
"RFC 2047 ISO-8859-1/QP encoded-word must be decoded to 'café', got: {subject:?}"
);
}
#[test]
fn test_plain_subject_unchanged() {
let raw = b"From: alice@example.com\r\n\
Subject: Just a plain subject\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
body\r\n";
let msg = parse(raw).expect("parse must succeed");
let subject = msg
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("Subject"))
.map(|h| h.value.as_str())
.unwrap_or("");
assert_eq!(
subject, "Just a plain subject",
"plain Subject must be preserved unchanged, got: {subject:?}"
);
}
fn make_cte_message(cte_value: &str) -> Vec<u8> {
format!(
"From: alice@example.com\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: {cte_value}\r\n\
\r\n\
body\r\n"
)
.into_bytes()
}
fn make_no_cte_message() -> Vec<u8> {
b"From: alice@example.com\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
body\r\n"
.to_vec()
}
#[test]
fn test_cte_unknown_x_gzip_warns() {
let raw = make_cte_message("x-gzip");
let msg = parse(&raw).expect("parse must succeed");
assert!(
!msg.warnings.is_empty(),
"x-gzip CTE must produce a warning"
);
let w = msg.warnings.join(" ");
assert!(
w.contains("x-gzip"),
"warning must mention 'x-gzip'; warnings={:?}",
msg.warnings
);
assert_eq!(
msg.part_index.transfer_encoding,
TransferEncoding::Identity,
"unrecognised CTE must map to Identity"
);
}
#[test]
fn test_cte_unknown_x_bzip2_warns() {
let raw = make_cte_message("x-bzip2");
let msg = parse(&raw).expect("parse must succeed");
assert!(
!msg.warnings.is_empty(),
"x-bzip2 CTE must produce a warning"
);
let w = msg.warnings.join(" ");
assert!(
w.contains("x-bzip2"),
"warning must mention 'x-bzip2'; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::Identity);
}
#[test]
fn test_no_cte_header_no_warning() {
let raw = make_no_cte_message();
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"absent CTE must not produce a warning; warnings={:?}",
msg.warnings
);
}
#[test]
fn test_cte_7bit_no_warning() {
let raw = make_cte_message("7bit");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"7bit CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::SevenBit);
}
#[test]
fn test_cte_8bit_no_warning() {
let raw = make_cte_message("8bit");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"8bit CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::EightBit);
}
#[test]
fn test_cte_binary_no_warning() {
let raw = make_cte_message("binary");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"binary CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::Binary);
}
#[test]
fn test_cte_quoted_printable_no_warning() {
let raw = make_cte_message("quoted-printable");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"quoted-printable CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(
msg.part_index.transfer_encoding,
TransferEncoding::QuotedPrintable
);
}
#[test]
fn test_cte_base64_no_warning() {
let raw = make_cte_message("base64");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"base64 CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::Base64);
}
#[test]
fn test_cte_x_uuencode_no_warning() {
let raw = make_cte_message("x-uuencode");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"x-uuencode CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::UUEncode);
}
#[test]
fn test_cte_x_uue_no_warning() {
let raw = make_cte_message("x-uue");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"x-uue CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::UUEncode);
}
#[test]
fn test_cte_uuencode_no_warning() {
let raw = make_cte_message("uuencode");
let msg = parse(&raw).expect("parse must succeed");
assert!(
msg.warnings.is_empty(),
"uuencode CTE must not produce a warning; warnings={:?}",
msg.warnings
);
assert_eq!(msg.part_index.transfer_encoding, TransferEncoding::UUEncode);
}
#[test]
fn test_no_headers_returns_error() {
let raw = b"This is just some text with no headers at all.\r\n";
let err = parse(raw).unwrap_err();
assert_eq!(err, ParseError::NoHeaders);
}
#[test]
fn test_decode_body_value_overflow_returns_invalid_range() {
let raw = b"From: a@b.c\r\nContent-Type: text/plain\r\n\r\nHello\r\n";
let msg = parse(raw).expect("parse must succeed");
let mut part = msg.part_index.clone();
part.body_range = (u32::MAX, u32::MAX);
let err = decode_body_value(raw, &part, None).unwrap_err();
assert!(
matches!(err, ParseError::InvalidRange { .. }),
"overflowing body_range must return InvalidRange, got: {err:?}"
);
}
#[test]
fn test_decode_body_value_past_end_returns_invalid_range() {
let raw = b"From: a@b.c\r\nContent-Type: text/plain\r\n\r\nHi\r\n";
let msg = parse(raw).expect("parse must succeed");
let mut part = msg.part_index.clone();
part.body_range = (0, raw.len() as u32 + 100);
let err = decode_body_value(raw, &part, None).unwrap_err();
assert!(
matches!(err, ParseError::InvalidRange { .. }),
"out-of-bounds body_range must return InvalidRange, got: {err:?}"
);
}
#[test]
fn test_related_image_jpeg_first_child_in_both_body_lists() {
let raw = concat!(
"From: a@b.c\r\n",
"MIME-Version: 1.0\r\n",
"Content-Type: multipart/related; boundary=\"rel\"\r\n",
"\r\n",
"--rel\r\n",
"Content-Type: image/jpeg\r\n",
"\r\n",
"jpeg-data-placeholder\r\n",
"--rel\r\n",
"Content-Type: image/png\r\n",
"\r\n",
"png-data-placeholder\r\n",
"--rel--\r\n",
);
let msg = parse(raw.as_bytes()).expect("parse must succeed");
assert!(
msg.text_body.contains(&"1".to_owned()),
"image/jpeg at i=0 must be in text_body; got: {:?}",
msg.text_body
);
assert!(
msg.html_body.contains(&"1".to_owned()),
"image/jpeg at i=0 must be in html_body; got: {:?}",
msg.html_body
);
assert!(
msg.attachments.contains(&"2".to_owned()),
"image/png at i=1 in related must be in attachments; got: {:?}",
msg.attachments
);
}
#[test]
fn test_preview_text_plain() {
let raw = b"From: a@b.c\r\n\
Content-Type: text/plain\r\n\
\r\n\
Hello, World!\r\n";
let msg = parse(raw).expect("parse must succeed");
let preview = msg.preview.expect("text message must have a preview");
assert!(
preview.starts_with("Hello, World!"),
"preview must start with the decoded text body; got: {:?}",
preview
);
}
#[test]
fn test_preview_binary_only_is_none() {
let raw = b"From: a@b.c\r\n\
Content-Type: application/octet-stream\r\n\
Content-Transfer-Encoding: base64\r\n\
\r\n\
AAAA\r\n";
let msg = parse(raw).expect("parse must succeed");
assert!(
msg.preview.is_none(),
"binary-only message must have no preview; got: {:?}",
msg.preview
);
}
#[test]
fn test_preview_truncates_to_256_chars() {
let body = "A".repeat(500);
let raw = format!(
"From: a@b.c\r\nContent-Type: text/plain\r\n\r\n{}\r\n",
body
);
let msg = parse(raw.as_bytes()).expect("parse must succeed");
let preview = msg.preview.expect("text message must have a preview");
assert_eq!(
preview.chars().count(),
256,
"preview must be exactly 256 chars; got {}",
preview.chars().count()
);
assert!(
preview.chars().all(|c| c == 'A'),
"preview must contain only 'A' characters"
);
}