From bc8075960c5c728f988b9964aa675e4a685fb6ed Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sun, 13 Sep 2026 04:58:43 +0200 Subject: [PATCH] support LF/CRLF line endings --- src/lib.rs | 41 ++++++++++++++++++++++++---- src/tests.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 972c5c8..f7fd6e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -254,6 +254,10 @@ pub struct CowMail<'a, H: Header<'a>> { pub body_bytes: Cow<'a, [u8]>, pub body_bytes_orig: &'a [u8], pub subparts: Vec>, + /// What followed `--` on the delimiter line introducing this part, + /// usually `\r\n`. Kept verbatim so that a message written with bare LF, or + /// with padding after the boundary, serializes back unchanged. + pub delimiter_end: &'a [u8], pub end_bytes: &'a [u8], pub default_ctype: ContentType, } @@ -274,6 +278,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { body_bytes_orig: &[], subparts: Vec::new(), end_bytes: b"", + delimiter_end: b"\r\n", default_ctype: ContentType::default(), } } @@ -616,19 +621,27 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { pub fn serialize_headers(&self) -> Vec> { let mut res: Vec> = self.headers.iter().flat_map(|h| h.serialize()).collect(); - res.push(Cow::Borrowed(b"\r\n")); + // The blank line between headers and body follows the line ending of the + // header right before it, so that a message written with bare LF keeps its + // line endings while rewritten headers keep ours. With no headers at all, + // the parsed header block is the separator. + let separator = match res.last() { + Some(header) => line_ending(header), + None => line_ending(self.header_bytes_orig), + }; + res.push(Cow::Borrowed(separator)); res } pub fn serialize_body(&self) -> Vec> { match self.message_type() { MessageType::MultiPart(boundary) if !self.subparts.is_empty() => { - let delimiter = [b"--", boundary.as_bytes(), b"\r\n"].concat(); + let delimiter = [b"--", boundary.as_bytes()].concat(); let closing = [b"--", boundary.as_bytes(), b"--"].concat(); std::iter::once(vec![Cow::Borrowed(&*self.body_bytes)]) .chain(self.subparts.iter().map(|p| { - let mut res = vec![Cow::Owned(delimiter.clone())]; + let mut res = vec![Cow::Owned(delimiter.clone()), Cow::Borrowed(p.delimiter_end)]; res.append(&mut p.serialize()); res })) @@ -902,6 +915,15 @@ pub fn parse_mail<'a, H: Header<'a> + 'a>(raw_data: &'a [u8]) -> Result &'static [u8] { + match bytes { + [.., b'\r', b'\n'] => b"\r\n", + [.., b'\n'] => b"\n", + _ => b"\r\n", + } +} + /// Byte offset of `inner` within `outer`. Both have to point into the same /// buffer, which holds for every `ParsedMail::raw_bytes` relative to its /// parent's. Returns `None` instead of panicking, so that a mailparse that @@ -953,6 +975,9 @@ fn build_mail<'a, H: Header<'a> + 'a>( body_bytes_orig: &raw_data[body_idx..body_end_idx], subparts: Vec::new(), end_bytes, + // Set by the caller, which is the only one that can see the delimiter + // line in front of this part. + delimiter_end: b"\r\n", default_ctype: ContentType::default_conditional(in_multipart_digest), }; @@ -995,8 +1020,14 @@ fn build_mail<'a, H: Header<'a> + 'a>( // whose body ends in a blank line, byte-exact: the last newline is the // part's terminator, not part of the delimiter. let part_end_idx = extend_over_newline(raw_data, part_idx + part.raw_bytes.len()); - mail.subparts - .push(build_mail(&raw_data[part_idx..part_end_idx], part, in_multipart_digest)?); + let mut subpart = build_mail(&raw_data[part_idx..part_end_idx], part, in_multipart_digest)?; + + // Whatever the delimiter line carried after `--`. + let delimiter_end_idx = delimiter_idx + 2 + boundary.len(); + if delimiter_end_idx <= part_idx { + subpart.delimiter_end = &raw_data[delimiter_end_idx..part_idx]; + } + mail.subparts.push(subpart); // The part ends right where the next delimiter line begins. delimiter_idx = part_end_idx; } diff --git a/src/tests.rs b/src/tests.rs index 6fec279..80adbb2 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -792,6 +792,81 @@ )); } + // 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", + "--new\n", + "Content-Type: text/plain\n", + "\n", + "Plain text\n", + "--new--\n" + ) + ); + } + #[test] fn test_roundtrip_single_part() { assert_roundtrip("Subject: Test\r\n\r\nThis is a test message\r\n");