own ContentDisposition, drop the last mailparse re-export

This commit is contained in:
2026-10-10 23:03:11 +02:00
parent 0d0caf09da
commit f6e5009a5f
5 changed files with 1160 additions and 1072 deletions
+5 -2
View File
@@ -1,7 +1,7 @@
//! RFC 2047 encoding and folding of header values.
use std::fmt::Write;
use email_encoding::headers::writer::EmailWriter;
use std::fmt::Write;
fn allowed_str(s: &str) -> bool {
s.bytes().all(allowed_char)
@@ -59,7 +59,10 @@ pub fn encode_header_value(key: &str, value: &str) -> String {
// This word only contains allowed characters
// the next word is allowed, but we may have accumulated some words to encode
encode(&mut encode_buf, &mut writer);
writer.folding().write_str(next_word).expect("String is always writable");
writer
.folding()
.write_str(next_word)
.expect("String is always writable");
} else {
// This word contains unallowed characters
encode_buf.push_str(next_word);
+2 -5
View File
@@ -12,8 +12,8 @@ mod encoding;
mod headers;
pub use encoding::encode_header_value;
pub use headers::{HeaderMap, parse_headers};
pub(crate) use headers::build_headers;
pub use headers::{HeaderMap, parse_headers};
use std::borrow::Cow;
use std::collections::BTreeMap;
@@ -361,7 +361,6 @@ impl Display for CowHeader<'_> {
}
}
/// End of the — possibly folded — header line whose value ends at `value_end`:
/// past the first newline that is not followed by continuation whitespace. Only
/// `\r`, `\n`, spaces and tabs can appear between the end of a value and the end
@@ -392,9 +391,7 @@ fn build_header<'a, H: Header<'a>>(
parsed: &MailHeader<'a>,
next: Option<&MailHeader<'a>>,
) -> Result<(H, usize), CowMailError> {
let offset_of = |bytes: &[u8]| {
offset_in(raw_data, bytes).ok_or(CowMailError::HeaderNotASubslice)
};
let offset_of = |bytes: &[u8]| offset_in(raw_data, bytes).ok_or(CowMailError::HeaderNotASubslice);
let key = parsed.get_key_raw();
let value = parsed.get_value_raw();
+98 -27
View File
@@ -11,15 +11,11 @@ pub mod header;
mod tests;
pub use body::{BodyContent, ContentTransferEncoding};
pub use header::{CowHeader, Header, HeaderMap, parse_header, parse_headers};
use header::build_headers;
pub use header::{CowHeader, Header, HeaderMap, parse_header, parse_headers};
/// Re-exported from [`mailparse`], which parses the MIME structure for this crate: these types
/// appear in this crate's public API, so depending on `mailparse` separately is neither necessary
/// nor advisable — the version re-exported here is the one these signatures refer to.
pub use mailparse::{DispositionType, ParsedContentDisposition};
use mailparse::{
ParsedContentType, ParsedMail, body::Body, parse_content_disposition, parse_content_type,
ParsedContentDisposition, ParsedContentType, ParsedMail, body::Body, parse_content_disposition, parse_content_type,
};
use mediatype::{MediaTypeBuf, Name, names::*};
@@ -215,11 +211,7 @@ impl ContentType {
/// Both are lowercased. For a type that [`mime`] already has a constant for, use that instead.
pub fn new(ty: &str, subty: &str) -> Self {
Self {
mimetype: Cow::Owned(format!(
"{}/{}",
ty.to_ascii_lowercase(),
subty.to_ascii_lowercase()
)),
mimetype: Cow::Owned(format!("{}/{}", ty.to_ascii_lowercase(), subty.to_ascii_lowercase())),
params: Vec::new(),
}
}
@@ -393,14 +385,98 @@ pub mod mime {
/// `multipart/mixed`, a part and its attachments.
pub const MULTIPART_MIXED: ContentType = ContentType::from_static("multipart/mixed");
/// `multipart/alternative`, the same content in two representations.
pub const MULTIPART_ALTERNATIVE: ContentType =
ContentType::from_static("multipart/alternative");
pub const MULTIPART_ALTERNATIVE: ContentType = ContentType::from_static("multipart/alternative");
/// `multipart/related`, a part and the resources it refers to.
pub const MULTIPART_RELATED: ContentType = ContentType::from_static("multipart/related");
/// `multipart/digest`, a collection of messages.
pub const MULTIPART_DIGEST: ContentType = ContentType::from_static("multipart/digest");
}
/// What a part's `Content-Disposition` header says to do with it.
///
/// Read it off a part with [`ContentDisposition::disposition`], or ask
/// [`Mail::is_attachment`] directly for the only distinction most callers care about.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum DispositionType {
/// `inline`: display the part as part of the message. The default, and what a
/// `Content-Disposition` with no recognizable disposition means.
#[default]
Inline,
/// `attachment`: a part that is not meant to be displayed inline.
Attachment,
/// `form-data`: a part of a submitted form, per RFC 7578.
FormData,
/// Any other disposition, lowercased.
Other(String),
}
/// A `Content-Disposition`: the disposition plus its parameters.
///
/// Obtained from a part with [`Mail::content_disposition`], which re-parses the header on every
/// call. The parameter most worth asking for has its own accessor,
/// [`filename`](Self::filename).
///
/// # Examples
///
/// ```
/// use cow_mail::{CowMail, DispositionType, HeaderMap, Mail};
///
/// let mut part = CowMail::default();
/// part.add_header("Content-Disposition", "attachment; filename=\"report.csv\"");
///
/// let disp = part.content_disposition().unwrap();
/// assert_eq!(disp.disposition(), &DispositionType::Attachment);
/// assert_eq!(disp.filename().unwrap(), "report.csv");
/// assert!(part.is_attachment());
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ContentDisposition {
disposition: DispositionType,
/// Keys lowercase, values unquoted.
params: Vec<(String, String)>,
}
impl ContentDisposition {
/// What to do with the part.
pub fn disposition(&self) -> &DispositionType {
&self.disposition
}
/// The parameters, in the order the header declared them.
pub fn params(&self) -> impl Iterator<Item = (&str, &str)> {
self.params.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
/// The parameter `key`, matched case-insensitively.
pub fn get_param(&self, key: &str) -> Option<&str> {
self.params
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.as_str())
}
/// The `filename` parameter, if the header declares one.
///
/// [`CowMail::get_filename`] is the fuller answer: it falls back to the deprecated `name`
/// parameter of `Content-Type`.
pub fn filename(&self) -> Option<&str> {
self.get_param("filename")
}
/// Build from what the parser produced.
pub(crate) fn from_parsed(disp: ParsedContentDisposition) -> Self {
Self {
disposition: match disp.disposition {
mailparse::DispositionType::Inline => DispositionType::Inline,
mailparse::DispositionType::Attachment => DispositionType::Attachment,
mailparse::DispositionType::FormData => DispositionType::FormData,
mailparse::DispositionType::Extension(other) => DispositionType::Other(other),
},
params: disp.params.into_iter().collect(),
}
}
}
/// The MIME view of a message or one of its parts, on top of its headers.
///
/// Implementors supply storage — the body bytes, the default content type, and the two ways of
@@ -487,13 +563,13 @@ pub trait Mail<'a, T: Header<'a> + 'a>: HeaderMap<'a, T> {
/// The parsed `Content-Disposition` header, or `None` when there is none.
///
/// Match [`ParsedContentDisposition::disposition`] against [`DispositionType`] for the
/// `inline` / `attachment` distinction, or read `params` for `filename` — see
/// Match [`ContentDisposition::disposition`] against [`DispositionType`] for the
/// `inline` / `attachment` distinction, or ask for the `filename` — see
/// [`CowMail::get_filename`].
fn content_disposition(&self) -> Option<ParsedContentDisposition> {
fn content_disposition(&self) -> Option<ContentDisposition> {
if let Some(header) = self.find_header("Content-Disposition") {
let value = header.get_value();
return Some(parse_content_disposition(&value));
return Some(ContentDisposition::from_parsed(parse_content_disposition(&value)));
}
None
}
@@ -503,7 +579,7 @@ pub trait Mail<'a, T: Header<'a> + 'a>: HeaderMap<'a, T> {
/// A part with no `Content-Disposition` header, or an `inline` one, is not an attachment.
fn is_attachment(&self) -> bool {
match self.content_disposition() {
Some(disp) => matches!(disp.disposition, DispositionType::Attachment),
Some(disp) => matches!(disp.disposition(), DispositionType::Attachment),
None => false,
}
}
@@ -833,11 +909,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
/// left off entirely when `None`. Binary content always ends up base64-encoded; pass a
/// [`BodyContent::Text`] (or a `&str`/`String`, which convert into one) to allow the more
/// compact encodings.
pub fn set_content_raw<B: Into<BodyContent>>(
&mut self,
content: B,
ctype: Option<ContentType>,
) {
pub fn set_content_raw<B: Into<BodyContent>>(&mut self, content: B, ctype: Option<ContentType>) {
self.clear_content();
if let Some(ctype) = ctype {
@@ -859,7 +931,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
/// parameter of `Content-Type`.
pub fn get_filename(&self) -> Option<String> {
self.content_disposition()
.and_then(|disp| disp.params.get("filename").cloned())
.and_then(|disp| disp.filename().map(str::to_string))
.or_else(|| self.content_type().get_param("name").map(str::to_string))
}
@@ -1652,9 +1724,8 @@ fn build_mail<'a, H: Header<'a> + 'a>(
.get("boundary")
.ok_or(CowMailError::MissingBoundary)?;
let offset_of = |part: &ParsedMail<'a>| {
offset_in(raw_data, part.raw_bytes).ok_or(CowMailError::SubpartNotASubslice)
};
let offset_of =
|part: &ParsedMail<'a>| offset_in(raw_data, part.raw_bytes).ok_or(CowMailError::SubpartNotASubslice);
// The preamble ends where the delimiter line before the first part begins.
let first_part_idx = offset_of(first_part)?;
+226 -209
View File
@@ -1,22 +1,22 @@
//use std::io::{Read, Write};
use crate::{BodyType, CowHeader, CowMail, Header, HeaderMap, Mail, mime};
//use std::io::{Read, Write};
use crate::{BodyType, CowHeader, CowMail, Header, HeaderMap, Mail, mime};
fn serialize<'a>(mail: &'a CowMail<'a, CowHeader<'a>>) -> Vec<u8> {
fn serialize<'a>(mail: &'a CowMail<'a, CowHeader<'a>>) -> Vec<u8> {
mail.serialize().iter().flat_map(|p| p.iter()).copied().collect()
}
}
/// Parse `raw` and assert it serializes back to exactly the same bytes.
fn assert_roundtrip(raw: &str) {
/// Parse `raw` and assert it serializes back to exactly the same bytes.
fn assert_roundtrip(raw: &str) {
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(
String::from_utf8_lossy(&serialize(&mail)),
raw,
"round-trip is not byte-exact"
);
}
}
#[test]
fn test_header_mutations() {
#[test]
fn test_header_mutations() {
let mut mail = CowMail::<CowHeader>::new();
assert_eq!(mail.headers.len(), 0);
@@ -66,13 +66,12 @@
);
mail.remove_all_headers("X-Test");
assert_eq!(mail.headers.len(), 2);
}
}
#[test]
fn test_body_mutations() {
#[test]
fn test_body_mutations() {
let mut mail =
CowMail::try_from(concat!("Content-Type: text/plain\n", "\n", "This is a test message").as_bytes())
.unwrap();
CowMail::try_from(concat!("Content-Type: text/plain\n", "\n", "This is a test message").as_bytes()).unwrap();
let ctype = mail.content_type();
assert_eq!(ctype.mimetype(), "text/plain");
assert_eq!(ctype.charset(), "us-ascii");
@@ -175,15 +174,12 @@
let mut f = std::fs::File::create("/tmp/test1.eml").unwrap();
f.write_all(&mut serialized).unwrap();
*/
}
}
// Test: single-part text/plain + set_html_body -> alternative[plain, html]
#[test]
fn test_single_plain_set_html() {
let mut mail = CowMail::try_from(
concat!("Content-Type: text/plain\n", "\n", "Plain text").as_bytes(),
)
.unwrap();
// Test: single-part text/plain + set_html_body -> alternative[plain, html]
#[test]
fn test_single_plain_set_html() {
let mut mail = CowMail::try_from(concat!("Content-Type: text/plain\n", "\n", "Plain text").as_bytes()).unwrap();
mail.set_html_body("<p>HTML</p>").unwrap();
@@ -191,17 +187,20 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[1].content_type().mimetype(), "text/html");
assert_eq!(mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), "Plain text");
assert_eq!(mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), "<p>HTML</p>");
}
assert_eq!(
mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(),
"Plain text"
);
assert_eq!(
mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(),
"<p>HTML</p>"
);
}
// Test: single-part text/html + set_text_body -> alternative[plain, html]
#[test]
fn test_single_html_set_plain() {
let mut mail = CowMail::try_from(
concat!("Content-Type: text/html\n", "\n", "<p>HTML</p>").as_bytes(),
)
.unwrap();
// Test: single-part text/html + set_text_body -> alternative[plain, html]
#[test]
fn test_single_html_set_plain() {
let mut mail = CowMail::try_from(concat!("Content-Type: text/html\n", "\n", "<p>HTML</p>").as_bytes()).unwrap();
mail.set_text_body("Plain text").unwrap();
@@ -209,13 +208,19 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[1].content_type().mimetype(), "text/html");
assert_eq!(mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), "Plain text");
assert_eq!(mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), "<p>HTML</p>");
}
assert_eq!(
mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(),
"Plain text"
);
assert_eq!(
mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(),
"<p>HTML</p>"
);
}
// Test: empty + set_text + set_html -> alternative[plain, html]
#[test]
fn test_empty_set_text_then_html() {
// Test: empty + set_text + set_html -> alternative[plain, html]
#[test]
fn test_empty_set_text_then_html() {
let mut mail = CowMail::<CowHeader>::new();
mail.set_text_body("Plain text").unwrap();
@@ -228,11 +233,11 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[1].content_type().mimetype(), "text/html");
}
}
// Test: empty + set_html + set_text -> alternative[plain, html]
#[test]
fn test_empty_set_html_then_text() {
// Test: empty + set_html + set_text -> alternative[plain, html]
#[test]
fn test_empty_set_html_then_text() {
let mut mail = CowMail::<CowHeader>::new();
mail.set_html_body("<p>HTML</p>").unwrap();
@@ -245,11 +250,11 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[1].content_type().mimetype(), "text/html");
}
}
// Test: mixed[attachment] + set_text -> mixed[plain, attachment]
#[test]
fn test_mixed_attachment_set_text() {
// Test: mixed[attachment] + set_text -> mixed[plain, attachment]
#[test]
fn test_mixed_attachment_set_text() {
// Parse a multipart/mixed with just an attachment (no empty subpart)
let mut mail = CowMail::try_from(
concat!(
@@ -275,11 +280,11 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert!(mail.subparts[1].is_attachment());
}
}
// Test: mixed[attachment] + set_text + set_html -> mixed[alternative[plain,html], attachment]
#[test]
fn test_mixed_attachment_set_text_then_html() {
// Test: mixed[attachment] + set_text + set_html -> mixed[alternative[plain,html], attachment]
#[test]
fn test_mixed_attachment_set_text_then_html() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\n",
@@ -305,11 +310,11 @@
assert_eq!(mail.subparts[0].subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[0].subparts[1].content_type().mimetype(), "text/html");
assert!(mail.subparts[1].is_attachment());
}
}
// Test: mixed[plain, attachment] + set_html -> mixed[alternative[plain,html], attachment]
#[test]
fn test_mixed_plain_attachment_set_html() {
// Test: mixed[plain, attachment] + set_html -> mixed[alternative[plain,html], attachment]
#[test]
fn test_mixed_plain_attachment_set_html() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\n",
@@ -339,11 +344,11 @@
assert_eq!(mail.subparts[0].subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[0].subparts[1].content_type().mimetype(), "text/html");
assert!(mail.subparts[1].is_attachment());
}
}
// Test: related[html, image] + set_text -> alternative[plain, related[html, image]]
#[test]
fn test_related_html_set_text() {
// Test: related[html, image] + set_text -> alternative[plain, related[html, image]]
#[test]
fn test_related_html_set_text() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/related; boundary=\"bound\"\n",
@@ -371,11 +376,11 @@
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[1].content_type().mimetype(), "multipart/related");
assert_eq!(mail.subparts[1].subparts.len(), 2);
}
}
// RFC 2045 section 6.1: Content-Transfer-Encoding values are case insensitive
#[test]
fn test_uppercase_transfer_encoding_is_decoded() {
// RFC 2045 section 6.1: Content-Transfer-Encoding values are case insensitive
#[test]
fn test_uppercase_transfer_encoding_is_decoded() {
for encoding in ["base64", "Base64", "BASE64"] {
let raw = format!(
concat!(
@@ -390,12 +395,12 @@
assert_eq!(mail.get_content().unwrap(), "hello world", "encoding: {encoding}");
assert_eq!(mail.get_content_raw().unwrap(), b"hello world", "encoding: {encoding}");
}
}
}
// Test: related[img, plain] without a `start` param + set_html.
// The body is not the first subpart, which used to hit unreachable!().
#[test]
fn test_related_body_not_first_set_html() {
// Test: related[img, plain] without a `start` param + set_html.
// The body is not the first subpart, which used to hit unreachable!().
#[test]
fn test_related_body_not_first_set_html() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/related; boundary=\"bound\"\n",
@@ -423,13 +428,19 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/related");
assert_eq!(mail.subparts[1].content_type().mimetype(), "text/html");
assert_eq!(mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), "Plain text\n");
assert_eq!(mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), "<p>HTML</p>");
}
assert_eq!(
mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(),
"Plain text\n"
);
assert_eq!(
mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(),
"<p>HTML</p>"
);
}
// Test: mixed[related[html,img], attachment] + set_text
#[test]
fn test_mixed_related_attachment_set_text() {
// Test: mixed[related[html,img], attachment] + set_text
#[test]
fn test_mixed_related_attachment_set_text() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/mixed; boundary=\"mixed\"\n",
@@ -464,13 +475,16 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/alternative");
assert_eq!(mail.subparts[0].subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[0].subparts[1].content_type().mimetype(), "multipart/related");
assert_eq!(
mail.subparts[0].subparts[1].content_type().mimetype(),
"multipart/related"
);
assert!(mail.subparts[1].is_attachment());
}
}
// Test: alternative[plain] + set_html -> alternative[plain, html]
#[test]
fn test_alternative_plain_set_html() {
// Test: alternative[plain] + set_html -> alternative[plain, html]
#[test]
fn test_alternative_plain_set_html() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/alternative; boundary=\"bound\"\n",
@@ -491,11 +505,11 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[1].content_type().mimetype(), "text/html");
}
}
// Test: alternative[html] + set_text -> alternative[plain, html]
#[test]
fn test_alternative_html_set_text() {
// Test: alternative[html] + set_text -> alternative[plain, html]
#[test]
fn test_alternative_html_set_text() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/alternative; boundary=\"bound\"\n",
@@ -516,11 +530,11 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[1].content_type().mimetype(), "text/html");
}
}
// Test: update existing body in alternative[plain, html]
#[test]
fn test_update_existing_body() {
// Test: update existing body in alternative[plain, html]
#[test]
fn test_update_existing_body() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/alternative; boundary=\"bound\"\n",
@@ -551,11 +565,11 @@
mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(),
"<p>Updated HTML</p>"
);
}
}
// Test: single-part attachment + set_text -> mixed[plain, attachment]
#[test]
fn test_single_attachment_set_text() {
// Test: single-part attachment + set_text -> mixed[plain, attachment]
#[test]
fn test_single_attachment_set_text() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: application/octet-stream\n",
@@ -578,11 +592,11 @@
assert_eq!(mail.subparts[0].get_content().unwrap(), "Plain text");
assert!(mail.subparts[1].is_attachment());
assert_eq!(mail.subparts[1].get_content().unwrap(), "binary data");
}
}
// Test: single-part attachment + set_html -> mixed[html, attachment]
#[test]
fn test_single_attachment_set_html() {
// Test: single-part attachment + set_html -> mixed[html, attachment]
#[test]
fn test_single_attachment_set_html() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: application/octet-stream\n",
@@ -603,11 +617,11 @@
assert_eq!(mail.subparts.len(), 2);
assert_eq!(mail.subparts[0].content_type().mimetype(), "text/html");
assert!(mail.subparts[1].is_attachment());
}
}
// Test: single-part attachment + set_text + set_html -> mixed[alternative[plain,html], attachment]
#[test]
fn test_single_attachment_set_text_then_html() {
// Test: single-part attachment + set_text + set_html -> mixed[alternative[plain,html], attachment]
#[test]
fn test_single_attachment_set_text_then_html() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: application/octet-stream\n",
@@ -629,13 +643,19 @@
assert_eq!(mail.subparts[0].subparts[0].content_type().mimetype(), "text/plain");
assert_eq!(mail.subparts[0].subparts[1].content_type().mimetype(), "text/html");
assert!(mail.subparts[1].is_attachment());
assert_eq!(mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), "Plain text");
assert_eq!(mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), "<p>HTML</p>");
}
assert_eq!(
mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(),
"Plain text"
);
assert_eq!(
mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(),
"<p>HTML</p>"
);
}
// Test: preserve order in mixed[attach1, plain, attach2] + set_html
#[test]
fn test_preserve_order_mixed() {
// Test: preserve order in mixed[attach1, plain, attach2] + set_html
#[test]
fn test_preserve_order_mixed() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\n",
@@ -667,12 +687,12 @@
assert!(mail.subparts[0].is_attachment());
assert_eq!(mail.subparts[1].content_type().mimetype(), "multipart/alternative");
assert!(mail.subparts[2].is_attachment());
}
}
// Test: the parser splits preamble, parts, terminators and epilogue the way
// serialization expects them back.
#[test]
fn test_parse_part_boundaries() {
// Test: the parser splits preamble, parts, terminators and epilogue the way
// serialization expects them back.
#[test]
fn test_parse_part_boundaries() {
let raw = concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
"\r\n",
@@ -701,12 +721,12 @@
// as the part terminator.
assert_eq!(mail.subparts[1].raw_body(), b"<p>HTML</p>\r\n");
assert_eq!(mail.subparts[1].end_bytes, b"\r\n");
}
}
// Test: an empty part keeps its terminator instead of swallowing the header
// separator.
#[test]
fn test_parse_empty_part() {
// Test: an empty part keeps its terminator instead of swallowing the header
// separator.
#[test]
fn test_parse_empty_part() {
let raw = concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
"\r\n",
@@ -721,12 +741,12 @@
assert_eq!(mail.subparts[0].bytes_orig, b"Subject: empty\r\n\r\n");
assert_eq!(mail.subparts[0].raw_body(), b"");
assert_eq!(mail.subparts[0].end_bytes, b"");
}
}
// Test: a declared boundary that never appears must not lose the body
// (mailparse drops it in that case).
#[test]
fn test_parse_missing_boundary_keeps_body() {
// Test: a declared boundary that never appears must not lose the body
// (mailparse drops it in that case).
#[test]
fn test_parse_missing_boundary_keeps_body() {
let raw = concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
"\r\n",
@@ -737,11 +757,11 @@
assert!(mail.subparts.is_empty());
assert_eq!(mail.raw_body(), b"no boundary here");
assert_eq!(mail.end_bytes, b"\r\n");
}
}
// Test: a part of a multipart/digest defaults to message/rfc822
#[test]
fn test_parse_digest_default_ctype() {
// Test: a part of a multipart/digest defaults to message/rfc822
#[test]
fn test_parse_digest_default_ctype() {
let raw = concat!(
"Content-Type: multipart/digest; boundary=\"bound\"\r\n",
"\r\n",
@@ -755,12 +775,12 @@
assert_eq!(mail.subparts.len(), 1);
assert_eq!(mail.subparts[0].content_type().mimetype(), "message/rfc822");
}
}
// Test: a nested multipart with a preamble and an epilogue round-trips
// byte-exact.
#[test]
fn test_roundtrip_nested_multipart() {
// Test: a nested multipart with a preamble and an epilogue round-trips
// byte-exact.
#[test]
fn test_roundtrip_nested_multipart() {
assert_roundtrip(concat!(
"From: \"John Doe\" <john.doe@example.com>\r\n",
"MIME-Version: 1.0\r\n",
@@ -790,12 +810,12 @@
"--outer--\r\n",
"epilogue\r\n"
));
}
}
// Test: a message written with bare LF keeps its line endings, including on
// the delimiter lines.
#[test]
fn test_roundtrip_lf_line_endings() {
// Test: a message written with bare LF keeps its line endings, including on
// the delimiter lines.
#[test]
fn test_roundtrip_lf_line_endings() {
assert_roundtrip(concat!(
"Content-Type: multipart/mixed; boundary=\"outer\"\n",
"\n",
@@ -814,11 +834,11 @@
"--inner--\n",
"--outer--\n"
));
}
}
// Test: padding after the boundary on a delimiter line is preserved.
#[test]
fn test_roundtrip_delimiter_padding() {
// Test: padding after the boundary on a delimiter line is preserved.
#[test]
fn test_roundtrip_delimiter_padding() {
assert_roundtrip(concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
"\r\n",
@@ -828,12 +848,12 @@
"Plain text\r\n",
"--bound-- \r\n"
));
}
}
// Test: the stored delimiter tail is boundary-agnostic, so renaming the
// boundary keeps the line endings and rewrites every delimiter.
#[test]
fn test_set_boundary_keeps_line_endings() {
// Test: the stored delimiter tail is boundary-agnostic, so renaming the
// boundary keeps the line endings and rewrites every delimiter.
#[test]
fn test_set_boundary_keeps_line_endings() {
let mut mail = CowMail::try_from(
concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\n",
@@ -865,17 +885,17 @@
"--new--\n"
)
);
}
}
#[test]
fn test_roundtrip_single_part() {
#[test]
fn test_roundtrip_single_part() {
assert_roundtrip("Subject: Test\r\n\r\nThis is a test message\r\n");
assert_roundtrip("Subject: Test\r\n\r\nThis is a test message");
assert_roundtrip("Subject: Test\r\n\r\n");
}
}
#[test]
fn test_roundtrip_edge_parts() {
#[test]
fn test_roundtrip_edge_parts() {
// Empty part, and a part whose body ends in a blank line.
assert_roundtrip(concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
@@ -906,12 +926,12 @@
"\r\n",
"no boundary here\r\n"
));
}
}
// Test: a message cut off before its closing delimiter keeps every byte it
// had; serialization completes it with the delimiter that is missing.
#[test]
fn test_roundtrip_truncated_message() {
// Test: a message cut off before its closing delimiter keeps every byte it
// had; serialization completes it with the delimiter that is missing.
#[test]
fn test_roundtrip_truncated_message() {
let raw = concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
"\r\n",
@@ -921,15 +941,12 @@
"Plain text\r\n"
);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(
String::from_utf8_lossy(&serialize(&mail)),
format!("{raw}--bound--")
);
}
assert_eq!(String::from_utf8_lossy(&serialize(&mail)), format!("{raw}--bound--"));
}
// Test: an empty body emits no terminator of its own, content supplies one.
#[test]
fn test_empty_body_has_no_terminator() {
// Test: an empty body emits no terminator of its own, content supplies one.
#[test]
fn test_empty_body_has_no_terminator() {
let mut mail = CowMail::default();
mail.add_header("Subject", "Test");
assert_eq!(String::from_utf8_lossy(&serialize(&mail)), "Subject: Test\r\n\r\n");
@@ -939,12 +956,12 @@
String::from_utf8_lossy(&serialize(&mail)),
"Subject: Test\r\nContent-Type: text/plain\r\n\r\nPlain text\r\n"
);
}
}
// Test: a closing delimiter at the very end of the buffer, with no trailing
// CRLF, used to be missed by the hand-rolled boundary search.
#[test]
fn test_parse_closing_boundary_at_eof() {
// Test: a closing delimiter at the very end of the buffer, with no trailing
// CRLF, used to be missed by the hand-rolled boundary search.
#[test]
fn test_parse_closing_boundary_at_eof() {
let raw = concat!(
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
"\r\n",
@@ -960,13 +977,13 @@
assert_eq!(mail.subparts[0].raw_body(), b"Plain text");
assert_eq!(mail.subparts[0].end_bytes, b"\r\n");
assert_eq!(mail.end_bytes, b"");
}
}
// The header lines below are rebuilt from the key/value slices mailparse
// hands back, so each of these pins a case where the line extends past the
// value: folding, a stray CR, an empty value, or the end of the buffer.
#[test]
fn test_roundtrip_folded_header() {
// The header lines below are rebuilt from the key/value slices mailparse
// hands back, so each of these pins a case where the line extends past the
// value: folding, a stray CR, an empty value, or the end of the buffer.
#[test]
fn test_roundtrip_folded_header() {
let raw = concat!(
"Subject: a\r\n",
" continued\r\n",
@@ -982,12 +999,12 @@
assert_eq!(mail.headers[0].get_value_raw(), b"a\r\n continued");
assert_eq!(mail.headers[0].get_value(), "a continued");
assert_eq!(mail.headers[1].get_key_raw(), b"To");
}
}
// A fold whose last line holds nothing but whitespace: the value ends before
// the line does, for the last header as well as for one in the middle.
#[test]
fn test_roundtrip_header_folded_onto_blank_line() {
// A fold whose last line holds nothing but whitespace: the value ends before
// the line does, for the last header as well as for one in the middle.
#[test]
fn test_roundtrip_header_folded_onto_blank_line() {
let raw = concat!("Subject: x\r\n", " \r\n", "To: y\r\n", " \r\n", "\r\n", "body\r\n");
assert_roundtrip(raw);
@@ -996,12 +1013,12 @@
assert_eq!(mail.headers[0].get_key_raw(), b"Subject");
assert_eq!(mail.headers[1].get_key_raw(), b"To");
assert_eq!(mail.raw_body(), b"body");
}
}
// Invalid, but it happens in the wild: mailparse treats the whole line as the
// key and leaves the value empty. Note that the key keeps the trailing CR.
#[test]
fn test_roundtrip_header_without_colon() {
// Invalid, but it happens in the wild: mailparse treats the whole line as the
// key and leaves the value empty. Note that the key keeps the trailing CR.
#[test]
fn test_roundtrip_header_without_colon() {
let raw = concat!("Subject\r\n", "To: y\r\n", "\r\n", "body\r\n");
assert_roundtrip(raw);
@@ -1010,10 +1027,10 @@
assert_eq!(mail.headers[0].get_key_raw(), b"Subject\r");
assert_eq!(mail.headers[0].get_value_raw(), b"");
assert_eq!(mail.headers[1].get_value_raw(), b"y");
}
}
#[test]
fn test_roundtrip_empty_header_value() {
#[test]
fn test_roundtrip_empty_header_value() {
let raw = concat!("X-Empty:\r\n", "Subject: x\r\n", "\r\n", "body\r\n");
assert_roundtrip(raw);
@@ -1021,33 +1038,33 @@
assert_eq!(mail.headers[0].get_key_raw(), b"X-Empty");
assert_eq!(mail.headers[0].get_value_raw(), b"");
assert_eq!(mail.headers[1].get_value_raw(), b"x");
}
}
#[test]
fn test_roundtrip_no_headers() {
#[test]
fn test_roundtrip_no_headers() {
assert_roundtrip("\r\nbody\r\n");
assert_roundtrip("\nbody\n");
let mail = CowMail::try_from("\r\nbody\r\n".as_bytes()).unwrap();
assert!(mail.headers.is_empty());
assert_eq!(mail.raw_body(), b"body");
}
}
#[test]
fn test_roundtrip_lf_headers() {
#[test]
fn test_roundtrip_lf_headers() {
let raw = concat!("Subject: a\n", " continued\n", "To: y\n", "\n", "body\n");
assert_roundtrip(raw);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(mail.headers[0].get_value_raw(), b"a\n continued");
assert_eq!(mail.headers[1].get_key_raw(), b"To");
}
}
// A message that ends with its header block, with and without the final
// newline. The blank line separating headers from body is always written, so
// these are the one case that does not round-trip byte-exact.
#[test]
fn test_header_block_without_blank_line() {
// A message that ends with its header block, with and without the final
// newline. The blank line separating headers from body is always written, so
// these are the one case that does not round-trip byte-exact.
#[test]
fn test_header_block_without_blank_line() {
let mail = CowMail::try_from("Subject: x\r\nTo: y\r\n".as_bytes()).unwrap();
assert_eq!(mail.headers.len(), 2);
assert_eq!(mail.headers[1].get_value_raw(), b"y");
@@ -1062,13 +1079,13 @@
assert_eq!(mail.headers[0].get_value_raw(), b"x");
assert_eq!(mail.raw_body(), b"");
assert_eq!(String::from_utf8_lossy(&serialize(&mail)), "Subject: x\r\n");
}
}
// The header line is rebuilt from pointer offsets, not by searching for the
// key and value bytes, so a value identical to its key still lands at the
// right place.
#[test]
fn test_roundtrip_header_value_equal_to_key() {
// The header line is rebuilt from pointer offsets, not by searching for the
// key and value bytes, so a value identical to its key still lands at the
// right place.
#[test]
fn test_roundtrip_header_value_equal_to_key() {
for raw in [
"Test: Test\r\n\r\nbody\r\n",
"Test:Test\r\n\r\nbody\r\n",
@@ -1086,12 +1103,12 @@
}
assert_eq!(mail.headers[0].get_raw().as_ref(), b"Test: Test\r\n");
assert_eq!(mail.headers[1].get_raw().as_ref(), b"Test:Test\r\n");
}
}
// A header line that is nothing but a colon: both the key and the value are
// empty, and they sit at different offsets.
#[test]
fn test_roundtrip_header_colon_only() {
// A header line that is nothing but a colon: both the key and the value are
// empty, and they sit at different offsets.
#[test]
fn test_roundtrip_header_colon_only() {
let raw = ":\r\nTo: y\r\n\r\nbody\r\n";
assert_roundtrip(raw);
@@ -1099,4 +1116,4 @@
assert_eq!(mail.headers[0].get_key_raw(), b"");
assert_eq!(mail.headers[0].get_value_raw(), b"");
assert_eq!(mail.headers[0].get_raw().as_ref(), b":\r\n");
}
}