diff --git a/src/header/encoding.rs b/src/header/encoding.rs index 49586e1..1e72a04 100644 --- a/src/header/encoding.rs +++ b/src/header/encoding.rs @@ -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); diff --git a/src/header/headers.rs b/src/header/headers.rs index 4463942..4d2bded 100644 --- a/src/header/headers.rs +++ b/src/header/headers.rs @@ -327,4 +327,4 @@ pub(crate) fn build_headers<'a, H: Header<'a>>( pub fn parse_headers<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(Vec, usize), CowMailError> { let (parsed, _) = mailparse::parse_headers(raw_data).map_err(parse_error)?; build_headers(raw_data, &parsed) -} \ No newline at end of file +} diff --git a/src/header/mod.rs b/src/header/mod.rs index 6d37a90..c46e90c 100644 --- a/src/header/mod.rs +++ b/src/header/mod.rs @@ -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(); @@ -442,4 +439,4 @@ fn build_header<'a, H: Header<'a>>( pub fn parse_header<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(H, usize), CowMailError> { let (parsed, _) = mailparse::parse_header(raw_data).map_err(parse_error)?; build_header(raw_data, &parsed, None) -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index c8ef7ed..46e3772 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { + 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 { + fn content_disposition(&self) -> Option { 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>( - &mut self, - content: B, - ctype: Option, - ) { + pub fn set_content_raw>(&mut self, content: B, ctype: Option) { 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 { 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)?; @@ -1698,4 +1769,4 @@ fn build_mail<'a, H: Header<'a> + 'a>( }; Ok(mail) -} \ No newline at end of file +} diff --git a/src/tests.rs b/src/tests.rs index 57466fb..7c5c9bf 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,135 +1,134 @@ - //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 { - mail.serialize().iter().flat_map(|p| p.iter()).copied().collect() +fn serialize<'a>(mail: &'a CowMail<'a, CowHeader<'a>>) -> Vec { + 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) { + 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() { + let mut mail = CowMail::::new(); + assert_eq!(mail.headers.len(), 0); + + let test_headers = [ + ("X-Test", "Test"), + ("X-Another-Test", "Another"), + ("X-Test", "Test2"), + ("X-Another-Test", "Another1"), + ("X-Test", "Test3"), + ]; + + for (key, value) in test_headers.iter() { + mail.add_header(key, value); } - /// 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" - ); + assert_eq!(mail.headers.len(), test_headers.len()); + for (idx, header) in mail.headers.iter().enumerate() { + assert_eq!(header.get_key(), test_headers[idx].0); + assert_eq!(header.get_value(), test_headers[idx].1); } - #[test] - fn test_header_mutations() { - let mut mail = CowMail::::new(); - assert_eq!(mail.headers.len(), 0); + mail.insert_header("X-Test", "Test1", 1); + assert_eq!( + mail.iter_values("X-Test").collect::>(), + ["Test", "Test1", "Test2", "Test3"] + ); - let test_headers = [ - ("X-Test", "Test"), - ("X-Another-Test", "Another"), - ("X-Test", "Test2"), - ("X-Another-Test", "Another1"), - ("X-Test", "Test3"), - ]; + mail.replace_all_headers("X-Test", "Test555"); + assert_eq!( + mail.iter_values("X-Test").collect::>(), + ["Test555", "Test555", "Test555", "Test555"] + ); - for (key, value) in test_headers.iter() { - mail.add_header(key, value); - } + mail.replace_header("X-Test", "Test", 0); + mail.replace_header("X-Test", "Test1", 1); + mail.replace_header("X-Test", "Test2", 2); + mail.replace_header("X-Test", "Test3", 3); + assert_eq!( + mail.iter_values("X-Test").collect::>(), + ["Test", "Test1", "Test2", "Test3"] + ); - assert_eq!(mail.headers.len(), test_headers.len()); - for (idx, header) in mail.headers.iter().enumerate() { - assert_eq!(header.get_key(), test_headers[idx].0); - assert_eq!(header.get_value(), test_headers[idx].1); - } + mail.remove_header("X-Test", 1); + assert_eq!( + mail.iter_values("X-Test").collect::>(), + ["Test", "Test2", "Test3"] + ); + mail.remove_all_headers("X-Test"); + assert_eq!(mail.headers.len(), 2); +} - mail.insert_header("X-Test", "Test1", 1); - assert_eq!( - mail.iter_values("X-Test").collect::>(), - ["Test", "Test1", "Test2", "Test3"] - ); +#[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(); + let ctype = mail.content_type(); + assert_eq!(ctype.mimetype(), "text/plain"); + assert_eq!(ctype.charset(), "us-ascii"); + assert!(!mail.is_multipart()); + assert_eq!(mail.get_content().unwrap(), "This is a test message"); - mail.replace_all_headers("X-Test", "Test555"); - assert_eq!( - mail.iter_values("X-Test").collect::>(), - ["Test555", "Test555", "Test555", "Test555"] - ); + mail.clear_content(); + assert!(mail.find_header("Content-Type").is_none()); + assert_eq!(mail.get_content().unwrap(), ""); + mail.set_content("This is a new test message ❤\n".to_string(), None); + let ctype = mail.content_type(); + assert_eq!(ctype.mimetype(), "text/plain"); + assert_eq!(ctype.charset(), "UTF-8"); - mail.replace_header("X-Test", "Test", 0); - mail.replace_header("X-Test", "Test1", 1); - mail.replace_header("X-Test", "Test2", 2); - mail.replace_header("X-Test", "Test3", 3); - assert_eq!( - mail.iter_values("X-Test").collect::>(), - ["Test", "Test1", "Test2", "Test3"] - ); + mail.clear_content(); + assert_eq!(mail.get_content().unwrap(), ""); + mail.set_content("This is a new test message\n".to_string(), None); + let ctype = mail.content_type(); + assert_eq!(ctype.mimetype(), "text/plain"); + assert_eq!(ctype.charset(), "us-ascii"); - mail.remove_header("X-Test", 1); - assert_eq!( - mail.iter_values("X-Test").collect::>(), - ["Test", "Test2", "Test3"] - ); - mail.remove_all_headers("X-Test"); - assert_eq!(mail.headers.len(), 2); - } + mail.add_header("From", "\"John Doe\" "); - #[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(); - let ctype = mail.content_type(); - assert_eq!(ctype.mimetype(), "text/plain"); - assert_eq!(ctype.charset(), "us-ascii"); - assert!(!mail.is_multipart()); - assert_eq!(mail.get_content().unwrap(), "This is a test message"); + let mut html = CowMail::new(); + html.set_content( + "This is a new test message ☺".to_string(), + Some(mime::TEXT_HTML), + ); - mail.clear_content(); - assert!(mail.find_header("Content-Type").is_none()); - assert_eq!(mail.get_content().unwrap(), ""); - mail.set_content("This is a new test message ❤\n".to_string(), None); - let ctype = mail.content_type(); - assert_eq!(ctype.mimetype(), "text/plain"); - assert_eq!(ctype.charset(), "UTF-8"); + mail.add_alternative(html); + let alt_boundary = mail.get_boundary().unwrap(); - mail.clear_content(); - assert_eq!(mail.get_content().unwrap(), ""); - mail.set_content("This is a new test message\n".to_string(), None); - let ctype = mail.content_type(); - assert_eq!(ctype.mimetype(), "text/plain"); - assert_eq!(ctype.charset(), "us-ascii"); + let ctype = mail.content_type(); + assert_eq!(ctype.mimetype(), "multipart/alternative"); + assert_eq!(ctype.get_param("boundary").unwrap(), alt_boundary); + assert_eq!( + mail.find_header("Content-Type").unwrap().get_value(), + format!("multipart/alternative; boundary=\"{}\"", alt_boundary) + ); - mail.add_header("From", "\"John Doe\" "); + let mut attachment = CowMail::new(); + attachment.set_content("This is a test text file".to_string(), None); + let mut disp = CowHeader::new("Content-Disposition", "attachment"); + disp.set_param("filename", "Test☺File.html"); + attachment.headers.push(disp); + mail.add_attachment(attachment); - let mut html = CowMail::new(); - html.set_content( - "This is a new test message ☺".to_string(), - Some(mime::TEXT_HTML), - ); + let mixed_boundary = mail.get_boundary().unwrap(); + let encoded = mail + .serialize() + .iter() + .flat_map(|p| p.iter()) + .cloned() + .collect::>(); - mail.add_alternative(html); - let alt_boundary = mail.get_boundary().unwrap(); - - let ctype = mail.content_type(); - assert_eq!(ctype.mimetype(), "multipart/alternative"); - assert_eq!(ctype.get_param("boundary").unwrap(), alt_boundary); - assert_eq!( - mail.find_header("Content-Type").unwrap().get_value(), - format!("multipart/alternative; boundary=\"{}\"", alt_boundary) - ); - - let mut attachment = CowMail::new(); - attachment.set_content("This is a test text file".to_string(), None); - let mut disp = CowHeader::new("Content-Disposition", "attachment"); - disp.set_param("filename", "Test☺File.html"); - attachment.headers.push(disp); - mail.add_attachment(attachment); - - let mixed_boundary = mail.get_boundary().unwrap(); - let encoded = mail - .serialize() - .iter() - .flat_map(|p| p.iter()) - .cloned() - .collect::>(); - - let expected = format!( - "From: \"John Doe\" \r\n\ + let expected = format!( + "From: \"John Doe\" \r\n\ MIME-Version: 1.0\r\n\ Content-Type: multipart/mixed;\r\n boundary=\"{mixed_boundary}\"\r\n\ \r\n\ @@ -153,950 +152,968 @@ \r\n\ This is a test text file\r\n\ --{mixed_boundary}--\r\n" - ); - let expected = expected.into_bytes(); - - assert_eq!(encoded, expected); - - /* - let mut f = std::fs::File::open("/tmp/test.eml").unwrap(); - let mut data = Vec::new(); - f.read_to_end(&mut data).unwrap(); - - let mail = CowMail::try_from(&data[..]).unwrap(); - let mut serialized = mail - .serialize() - .iter() - .flat_map(|p| p.into_iter()) - .cloned() - .collect::>(); - - assert_eq!(data, serialized); - 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(); - - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - 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(), "

HTML

"); - } - - // 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", "

HTML

").as_bytes(), - ) - .unwrap(); - - mail.set_text_body("Plain text").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - 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(), "

HTML

"); - } - - // Test: empty + set_text + set_html -> alternative[plain, html] - #[test] - fn test_empty_set_text_then_html() { - let mut mail = CowMail::::new(); - - mail.set_text_body("Plain text").unwrap(); - assert_eq!(mail.content_type().mimetype(), "text/plain"); - assert!(!mail.is_multipart()); - - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - 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() { - let mut mail = CowMail::::new(); - - mail.set_html_body("

HTML

").unwrap(); - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - assert!(mail.is_multipart()); - - mail.set_text_body("Plain text").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - 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() { - // Parse a multipart/mixed with just an attachment (no empty subpart) - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment\n", - "\n", - "attachment content\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - assert_eq!(mail.subparts.len(), 1); - - mail.set_text_body("Plain text").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment\n", - "\n", - "attachment content\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - mail.set_text_body("Plain text").unwrap(); - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - assert_eq!(mail.subparts.len(), 2); - assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/alternative"); - assert_eq!(mail.subparts[0].subparts.len(), 2); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: text/plain\n", - "\n", - "Plain text\n", - "--bound\n", - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment\n", - "\n", - "attachment\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - assert_eq!(mail.subparts.len(), 2); - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - assert_eq!(mail.subparts.len(), 2); - assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/alternative"); - assert_eq!(mail.subparts[0].subparts.len(), 2); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/related; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: text/html\n", - "\n", - "

HTML with image

\n", - "--bound\n", - "Content-Type: image/png\n", - "Content-ID: \n", - "\n", - "PNG_DATA\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/related"); - mail.set_text_body("Plain text").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - assert_eq!(mail.subparts.len(), 2); - 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() { - for encoding in ["base64", "Base64", "BASE64"] { - let raw = format!( - concat!( - "Content-Type: text/plain\n", - "Content-Transfer-Encoding: {}\n", - "\n", - "aGVsbG8gd29ybGQ=" - ), - encoding - ); - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/related; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: image/png\n", - "Content-Disposition: inline\n", - "\n", - "PNG_DATA\n", - "--bound\n", - "Content-Type: text/plain\n", - "\n", - "Plain text\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - assert!(mail.get_body_part(BodyType::PLAIN).is_some()); - mail.set_html_body("

HTML

").unwrap(); - - // the related container is wrapped in an alternative holding both bodies - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - 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(), "

HTML

"); - } - - // 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", - "\n", - "--mixed\n", - "Content-Type: multipart/related; boundary=\"related\"\n", - "\n", - "--related\n", - "Content-Type: text/html\n", - "\n", - "

HTML

\n", - "--related\n", - "Content-Type: image/png\n", - "Content-ID: \n", - "\n", - "PNG\n", - "--related--\n", - "--mixed\n", - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment\n", - "\n", - "attachment\n", - "--mixed--\n" - ) - .as_bytes(), - ) - .unwrap(); - - mail.set_text_body("Plain text").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - 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!(mail.subparts[1].is_attachment()); - } - - // 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", - "\n", - "--bound\n", - "Content-Type: text/plain\n", - "\n", - "Plain text\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/alternative; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: text/html\n", - "\n", - "

HTML

\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - mail.set_text_body("Plain text").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/alternative; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: text/plain\n", - "\n", - "Original plain\n", - "--bound\n", - "Content-Type: text/html\n", - "\n", - "

Original HTML

\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - mail.set_text_body("Updated plain").unwrap(); - mail.set_html_body("

Updated HTML

").unwrap(); - - assert_eq!(mail.subparts.len(), 2); - assert_eq!( - mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), - "Updated plain" - ); - assert_eq!( - mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), - "

Updated HTML

" - ); - } - - // 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", - "Content-Disposition: attachment; filename=\"test.bin\"\n", - "\n", - "binary data" - ) - .as_bytes(), - ) - .unwrap(); - - assert!(mail.is_attachment()); - assert!(mail.get_body_part(BodyType::PLAIN).is_none()); - - mail.set_text_body("Plain text").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - assert_eq!(mail.subparts.len(), 2); - assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain"); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment; filename=\"test.bin\"\n", - "\n", - "binary data" - ) - .as_bytes(), - ) - .unwrap(); - - assert!(mail.is_attachment()); - assert!(mail.get_body_part(BodyType::HTML).is_none()); - - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - 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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment; filename=\"test.bin\"\n", - "\n", - "binary data" - ) - .as_bytes(), - ) - .unwrap(); - - mail.set_text_body("Plain text").unwrap(); - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - assert_eq!(mail.subparts.len(), 2); - assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/alternative"); - assert_eq!(mail.subparts[0].subparts.len(), 2); - 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(), "

HTML

"); - } - - // 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", - "\n", - "--bound\n", - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment\n", - "\n", - "attach1\n", - "--bound\n", - "Content-Type: text/plain\n", - "\n", - "Plain text\n", - "--bound\n", - "Content-Type: application/octet-stream\n", - "Content-Disposition: attachment\n", - "\n", - "attach2\n", - "--bound--\n" - ) - .as_bytes(), - ) - .unwrap(); - - mail.set_html_body("

HTML

").unwrap(); - - assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); - assert_eq!(mail.subparts.len(), 3); - 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() { - let raw = concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", - "\r\n", - "preamble\r\n", - "--bound\r\n", - "Content-Type: text/plain\r\n", - "\r\n", - "Plain text\r\n", - "--bound\r\n", - "Content-Type: text/html\r\n", - "\r\n", - "

HTML

\r\n", - "\r\n", - "--bound--\r\n", - "epilogue\r\n" - ); - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - - assert_eq!(mail.raw_body(), b"preamble\r\n"); - assert_eq!(mail.end_bytes, b"\r\nepilogue\r\n"); - assert_eq!(mail.subparts.len(), 2); - - assert_eq!(mail.subparts[0].raw_body(), b"Plain text"); - assert_eq!(mail.subparts[0].end_bytes, b"\r\n"); - // A body ending in a blank line keeps both CRLFs: one for the body, one - // as the part terminator. - assert_eq!(mail.subparts[1].raw_body(), b"

HTML

\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() { - let raw = concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", - "\r\n", - "--bound\r\n", - "Subject: empty\r\n", - "\r\n", - "--bound--\r\n" - ); - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - - assert_eq!(mail.subparts.len(), 1); - 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() { - let raw = concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", - "\r\n", - "no boundary here\r\n" - ); - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - - 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() { - let raw = concat!( - "Content-Type: multipart/digest; boundary=\"bound\"\r\n", - "\r\n", - "--bound\r\n", - "\r\n", - "Subject: forwarded\r\n", - "\r\n", - "--bound--\r\n" - ); - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - - 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() { - assert_roundtrip(concat!( - "From: \"John Doe\" \r\n", - "MIME-Version: 1.0\r\n", - "Content-Type: multipart/mixed;\r\n boundary=\"outer\"\r\n", - "\r\n", - "This is a MIME message.\r\n", - "\r\n", - "--outer\r\n", - "Content-Type: multipart/alternative; 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", - "

HTML

\r\n", - "\r\n", - "--inner--\r\n", - "--outer\r\n", - "Content-Type: application/pdf\r\n", - "Content-Disposition: attachment; filename=\"test.pdf\"\r\n", - "Content-Transfer-Encoding: base64\r\n", - "\r\n", - "JVBERi0xLjQK\r\n", - "--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() { - assert_roundtrip(concat!( - "Content-Type: multipart/mixed; boundary=\"outer\"\n", + ); + let expected = expected.into_bytes(); + + assert_eq!(encoded, expected); + + /* + let mut f = std::fs::File::open("/tmp/test.eml").unwrap(); + let mut data = Vec::new(); + f.read_to_end(&mut data).unwrap(); + + let mail = CowMail::try_from(&data[..]).unwrap(); + let mut serialized = mail + .serialize() + .iter() + .flat_map(|p| p.into_iter()) + .cloned() + .collect::>(); + + assert_eq!(data, serialized); + 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(); + + mail.set_html_body("

HTML

").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + 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(), + "

HTML

" + ); +} + +// 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", "

HTML

").as_bytes()).unwrap(); + + mail.set_text_body("Plain text").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + 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(), + "

HTML

" + ); +} + +// Test: empty + set_text + set_html -> alternative[plain, html] +#[test] +fn test_empty_set_text_then_html() { + let mut mail = CowMail::::new(); + + mail.set_text_body("Plain text").unwrap(); + assert_eq!(mail.content_type().mimetype(), "text/plain"); + assert!(!mail.is_multipart()); + + mail.set_html_body("

HTML

").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + 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() { + let mut mail = CowMail::::new(); + + mail.set_html_body("

HTML

").unwrap(); + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + assert!(mail.is_multipart()); + + mail.set_text_body("Plain text").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + 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() { + // Parse a multipart/mixed with just an attachment (no empty subpart) + let mut mail = CowMail::try_from( + concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\n", "\n", - "preamble\n", - "--outer\n", - "Content-Type: multipart/alternative; boundary=\"inner\"\n", + "--bound\n", + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment\n", "\n", - "--inner\n", + "attachment content\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + assert_eq!(mail.subparts.len(), 1); + + mail.set_text_body("Plain text").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + 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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\n", + "\n", + "--bound\n", + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment\n", + "\n", + "attachment content\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + mail.set_text_body("Plain text").unwrap(); + mail.set_html_body("

HTML

").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + assert_eq!(mail.subparts.len(), 2); + assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/alternative"); + assert_eq!(mail.subparts[0].subparts.len(), 2); + 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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\n", + "\n", + "--bound\n", "Content-Type: text/plain\n", "\n", "Plain text\n", - "--inner\n", + "--bound\n", + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment\n", + "\n", + "attachment\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + assert_eq!(mail.subparts.len(), 2); + mail.set_html_body("

HTML

").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + assert_eq!(mail.subparts.len(), 2); + assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/alternative"); + assert_eq!(mail.subparts[0].subparts.len(), 2); + 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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: multipart/related; boundary=\"bound\"\n", + "\n", + "--bound\n", + "Content-Type: text/html\n", + "\n", + "

HTML with image

\n", + "--bound\n", + "Content-Type: image/png\n", + "Content-ID: \n", + "\n", + "PNG_DATA\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/related"); + mail.set_text_body("Plain text").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + assert_eq!(mail.subparts.len(), 2); + 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() { + for encoding in ["base64", "Base64", "BASE64"] { + let raw = format!( + concat!( + "Content-Type: text/plain\n", + "Content-Transfer-Encoding: {}\n", + "\n", + "aGVsbG8gd29ybGQ=" + ), + encoding + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + 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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: multipart/related; boundary=\"bound\"\n", + "\n", + "--bound\n", + "Content-Type: image/png\n", + "Content-Disposition: inline\n", + "\n", + "PNG_DATA\n", + "--bound\n", + "Content-Type: text/plain\n", + "\n", + "Plain text\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + assert!(mail.get_body_part(BodyType::PLAIN).is_some()); + mail.set_html_body("

HTML

").unwrap(); + + // the related container is wrapped in an alternative holding both bodies + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + 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(), + "

HTML

" + ); +} + +// 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", + "\n", + "--mixed\n", + "Content-Type: multipart/related; boundary=\"related\"\n", + "\n", + "--related\n", "Content-Type: text/html\n", "\n", "

HTML

\n", - "--inner--\n", - "--outer--\n" - )); - } - - // 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", - "--bound \r\n", - "Content-Type: text/plain\r\n", - "\r\n", - "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() { - let mut mail = CowMail::try_from( - concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\n", - "\n", - "--bound\n", - "Content-Type: text/plain\n", - "\n", - "Plain text\n", - "--bound--\n" - ) - .as_bytes(), + "--related\n", + "Content-Type: image/png\n", + "Content-ID: \n", + "\n", + "PNG\n", + "--related--\n", + "--mixed\n", + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment\n", + "\n", + "attachment\n", + "--mixed--\n" ) - .unwrap(); + .as_bytes(), + ) + .unwrap(); - mail.set_boundary("new").unwrap(); + mail.set_text_body("Plain text").unwrap(); - assert_eq!( - String::from_utf8_lossy(&serialize(&mail)), - concat!( - // The rewritten header, and the blank line after it, are written - // the way we write headers; the delimiter lines keep the line - // endings the message had. - "Content-Type: multipart/mixed; boundary=\"new\"\r\n", - "\r\n", - "--new\n", - "Content-Type: text/plain\n", - "\n", - "Plain text\n", - "--new--\n" - ) - ); - } + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + 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!(mail.subparts[1].is_attachment()); +} - #[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: 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", + "\n", + "--bound\n", + "Content-Type: text/plain\n", + "\n", + "Plain text\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); - #[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", - "\r\n", - "--bound\r\n", - "Subject: empty\r\n", - "\r\n", - "--bound\r\n", - "Content-Type: text/plain\r\n", - "\r\n", - "Plain text\r\n", - "\r\n", - "--bound--\r\n" - )); - // Closing delimiter at the end of the buffer, no trailing CRLF. - assert_roundtrip(concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", - "\r\n", - "--bound\r\n", - "Content-Type: text/plain\r\n", - "\r\n", - "Plain text\r\n", - "--bound--" - )); - // Declared boundary that never appears: the body is kept as it is. - assert_roundtrip(concat!( - "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", - "\r\n", - "no boundary here\r\n" - )); - } + mail.set_html_body("

HTML

").unwrap(); - // 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", - "--bound\r\n", - "Content-Type: text/plain\r\n", - "\r\n", - "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!(mail.content_type().mimetype(), "multipart/alternative"); + 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: 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"); +// 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", + "\n", + "--bound\n", + "Content-Type: text/html\n", + "\n", + "

HTML

\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); - mail.set_text_body("Plain text").unwrap(); - assert_eq!( - String::from_utf8_lossy(&serialize(&mail)), - "Subject: Test\r\nContent-Type: text/plain\r\n\r\nPlain text\r\n" - ); - } + mail.set_text_body("Plain text").unwrap(); - // 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", + assert_eq!(mail.content_type().mimetype(), "multipart/alternative"); + 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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: multipart/alternative; boundary=\"bound\"\n", + "\n", + "--bound\n", + "Content-Type: text/plain\n", + "\n", + "Original plain\n", + "--bound\n", + "Content-Type: text/html\n", + "\n", + "

Original HTML

\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + mail.set_text_body("Updated plain").unwrap(); + mail.set_html_body("

Updated HTML

").unwrap(); + + assert_eq!(mail.subparts.len(), 2); + assert_eq!( + mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), + "Updated plain" + ); + assert_eq!( + mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), + "

Updated HTML

" + ); +} + +// 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", + "Content-Disposition: attachment; filename=\"test.bin\"\n", + "\n", + "binary data" + ) + .as_bytes(), + ) + .unwrap(); + + assert!(mail.is_attachment()); + assert!(mail.get_body_part(BodyType::PLAIN).is_none()); + + mail.set_text_body("Plain text").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + assert_eq!(mail.subparts.len(), 2); + assert_eq!(mail.subparts[0].content_type().mimetype(), "text/plain"); + 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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment; filename=\"test.bin\"\n", + "\n", + "binary data" + ) + .as_bytes(), + ) + .unwrap(); + + assert!(mail.is_attachment()); + assert!(mail.get_body_part(BodyType::HTML).is_none()); + + mail.set_html_body("

HTML

").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + 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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment; filename=\"test.bin\"\n", + "\n", + "binary data" + ) + .as_bytes(), + ) + .unwrap(); + + mail.set_text_body("Plain text").unwrap(); + mail.set_html_body("

HTML

").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + assert_eq!(mail.subparts.len(), 2); + assert_eq!(mail.subparts[0].content_type().mimetype(), "multipart/alternative"); + assert_eq!(mail.subparts[0].subparts.len(), 2); + 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(), + "

HTML

" + ); +} + +// 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", + "\n", + "--bound\n", + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment\n", + "\n", + "attach1\n", + "--bound\n", + "Content-Type: text/plain\n", + "\n", + "Plain text\n", + "--bound\n", + "Content-Type: application/octet-stream\n", + "Content-Disposition: attachment\n", + "\n", + "attach2\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + mail.set_html_body("

HTML

").unwrap(); + + assert_eq!(mail.content_type().mimetype(), "multipart/mixed"); + assert_eq!(mail.subparts.len(), 3); + 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() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "preamble\r\n", + "--bound\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Plain text\r\n", + "--bound\r\n", + "Content-Type: text/html\r\n", + "\r\n", + "

HTML

\r\n", + "\r\n", + "--bound--\r\n", + "epilogue\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert_eq!(mail.raw_body(), b"preamble\r\n"); + assert_eq!(mail.end_bytes, b"\r\nepilogue\r\n"); + assert_eq!(mail.subparts.len(), 2); + + assert_eq!(mail.subparts[0].raw_body(), b"Plain text"); + assert_eq!(mail.subparts[0].end_bytes, b"\r\n"); + // A body ending in a blank line keeps both CRLFs: one for the body, one + // as the part terminator. + assert_eq!(mail.subparts[1].raw_body(), b"

HTML

\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() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "Subject: empty\r\n", + "\r\n", + "--bound--\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert_eq!(mail.subparts.len(), 1); + 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() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "no boundary here\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + 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() { + let raw = concat!( + "Content-Type: multipart/digest; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "\r\n", + "Subject: forwarded\r\n", + "\r\n", + "--bound--\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + 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() { + assert_roundtrip(concat!( + "From: \"John Doe\" \r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: multipart/mixed;\r\n boundary=\"outer\"\r\n", + "\r\n", + "This is a MIME message.\r\n", + "\r\n", + "--outer\r\n", + "Content-Type: multipart/alternative; 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", + "

HTML

\r\n", + "\r\n", + "--inner--\r\n", + "--outer\r\n", + "Content-Type: application/pdf\r\n", + "Content-Disposition: attachment; filename=\"test.pdf\"\r\n", + "Content-Transfer-Encoding: base64\r\n", + "\r\n", + "JVBERi0xLjQK\r\n", + "--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() { + assert_roundtrip(concat!( + "Content-Type: multipart/mixed; boundary=\"outer\"\n", + "\n", + "preamble\n", + "--outer\n", + "Content-Type: multipart/alternative; boundary=\"inner\"\n", + "\n", + "--inner\n", + "Content-Type: text/plain\n", + "\n", + "Plain text\n", + "--inner\n", + "Content-Type: text/html\n", + "\n", + "

HTML

\n", + "--inner--\n", + "--outer--\n" + )); +} + +// 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", + "--bound \r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "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() { + let mut mail = CowMail::try_from( + concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\n", + "\n", + "--bound\n", + "Content-Type: text/plain\n", + "\n", + "Plain text\n", + "--bound--\n" + ) + .as_bytes(), + ) + .unwrap(); + + mail.set_boundary("new").unwrap(); + + assert_eq!( + String::from_utf8_lossy(&serialize(&mail)), + concat!( + // The rewritten header, and the blank line after it, are written + // the way we write headers; the delimiter lines keep the line + // endings the message had. + "Content-Type: multipart/mixed; boundary=\"new\"\r\n", "\r\n", - "--bound\r\n", - "Content-Type: text/plain\r\n", - "\r\n", - "Plain text\r\n", - "--bound--" - ); - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + "--new\n", + "Content-Type: text/plain\n", + "\n", + "Plain text\n", + "--new--\n" + ) + ); +} - assert_eq!(mail.subparts.len(), 1); - 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""); - } +#[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"); +} - // 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", - "To: john@example.com\r\n", - "\r\n", - "body\r\n" - ); +#[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", + "\r\n", + "--bound\r\n", + "Subject: empty\r\n", + "\r\n", + "--bound\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Plain text\r\n", + "\r\n", + "--bound--\r\n" + )); + // Closing delimiter at the end of the buffer, no trailing CRLF. + assert_roundtrip(concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Plain text\r\n", + "--bound--" + )); + // Declared boundary that never appears: the body is kept as it is. + assert_roundtrip(concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\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() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Plain text\r\n" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + 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() { + let mut mail = CowMail::default(); + mail.add_header("Subject", "Test"); + assert_eq!(String::from_utf8_lossy(&serialize(&mail)), "Subject: Test\r\n\r\n"); + + mail.set_text_body("Plain text").unwrap(); + assert_eq!( + 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() { + let raw = concat!( + "Content-Type: multipart/mixed; boundary=\"bound\"\r\n", + "\r\n", + "--bound\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Plain text\r\n", + "--bound--" + ); + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + + assert_eq!(mail.subparts.len(), 1); + 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() { + let raw = concat!( + "Subject: a\r\n", + " continued\r\n", + "To: john@example.com\r\n", + "\r\n", + "body\r\n" + ); + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert_eq!(mail.headers.len(), 2); + assert_eq!(mail.headers[0].get_key_raw(), b"Subject"); + 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() { + let raw = concat!("Subject: x\r\n", " \r\n", "To: y\r\n", " \r\n", "\r\n", "body\r\n"); + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert_eq!(mail.headers.len(), 2); + 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() { + let raw = concat!("Subject\r\n", "To: y\r\n", "\r\n", "body\r\n"); + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert_eq!(mail.headers.len(), 2); + 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() { + let raw = concat!("X-Empty:\r\n", "Subject: x\r\n", "\r\n", "body\r\n"); + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + 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() { + 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() { + 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() { + 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"); + assert_eq!(mail.raw_body(), b""); + assert_eq!( + String::from_utf8_lossy(&serialize(&mail)), + "Subject: x\r\nTo: y\r\n\r\n" + ); + + let mail = CowMail::try_from("Subject: x".as_bytes()).unwrap(); + assert_eq!(mail.headers.len(), 1); + 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() { + for raw in [ + "Test: Test\r\n\r\nbody\r\n", + "Test:Test\r\n\r\nbody\r\n", + "Test: Test\r\n Test\r\n\r\nbody\r\n", + "T: T\r\nT: T\r\n\r\nbody\r\n", + ] { assert_roundtrip(raw); - - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - assert_eq!(mail.headers.len(), 2); - assert_eq!(mail.headers[0].get_key_raw(), b"Subject"); - 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() { - let raw = concat!("Subject: x\r\n", " \r\n", "To: y\r\n", " \r\n", "\r\n", "body\r\n"); - assert_roundtrip(raw); - - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - assert_eq!(mail.headers.len(), 2); - 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"); + let mail = CowMail::try_from("Test: Test\r\nTest:Test\r\n\r\nbody\r\n".as_bytes()).unwrap(); + assert_eq!(mail.headers.len(), 2); + for header in &mail.headers { + assert_eq!(header.get_key_raw(), b"Test"); + assert_eq!(header.get_value_raw(), b"Test"); } + 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"); +} - // 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); +// 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); - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - assert_eq!(mail.headers.len(), 2); - 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() { - let raw = concat!("X-Empty:\r\n", "Subject: x\r\n", "\r\n", "body\r\n"); - assert_roundtrip(raw); - - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - 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() { - 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() { - 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() { - 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"); - assert_eq!(mail.raw_body(), b""); - assert_eq!( - String::from_utf8_lossy(&serialize(&mail)), - "Subject: x\r\nTo: y\r\n\r\n" - ); - - let mail = CowMail::try_from("Subject: x".as_bytes()).unwrap(); - assert_eq!(mail.headers.len(), 1); - 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() { - for raw in [ - "Test: Test\r\n\r\nbody\r\n", - "Test:Test\r\n\r\nbody\r\n", - "Test: Test\r\n Test\r\n\r\nbody\r\n", - "T: T\r\nT: T\r\n\r\nbody\r\n", - ] { - assert_roundtrip(raw); - } - - let mail = CowMail::try_from("Test: Test\r\nTest:Test\r\n\r\nbody\r\n".as_bytes()).unwrap(); - assert_eq!(mail.headers.len(), 2); - for header in &mail.headers { - assert_eq!(header.get_key_raw(), b"Test"); - assert_eq!(header.get_value_raw(), b"Test"); - } - 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() { - let raw = ":\r\nTo: y\r\n\r\nbody\r\n"; - assert_roundtrip(raw); - - let mail = CowMail::try_from(raw.as_bytes()).unwrap(); - 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"); - } + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + 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"); +}