support LF/CRLF line endings

This commit is contained in:
2026-09-13 04:58:43 +02:00
parent c3b8d56d5a
commit bc8075960c
2 changed files with 111 additions and 5 deletions
+36 -5
View File
@@ -254,6 +254,10 @@ pub struct CowMail<'a, H: Header<'a>> {
pub body_bytes: Cow<'a, [u8]>, pub body_bytes: Cow<'a, [u8]>,
pub body_bytes_orig: &'a [u8], pub body_bytes_orig: &'a [u8],
pub subparts: Vec<CowMail<'a, H>>, pub subparts: Vec<CowMail<'a, H>>,
/// What followed `--<boundary>` 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 end_bytes: &'a [u8],
pub default_ctype: ContentType, pub default_ctype: ContentType,
} }
@@ -274,6 +278,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
body_bytes_orig: &[], body_bytes_orig: &[],
subparts: Vec::new(), subparts: Vec::new(),
end_bytes: b"", end_bytes: b"",
delimiter_end: b"\r\n",
default_ctype: ContentType::default(), default_ctype: ContentType::default(),
} }
} }
@@ -616,19 +621,27 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
pub fn serialize_headers(&self) -> Vec<Cow<'_, [u8]>> { pub fn serialize_headers(&self) -> Vec<Cow<'_, [u8]>> {
let mut res: Vec<Cow<[u8]>> = self.headers.iter().flat_map(|h| h.serialize()).collect(); let mut res: Vec<Cow<[u8]>> = 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 res
} }
pub fn serialize_body(&self) -> Vec<Cow<'_, [u8]>> { pub fn serialize_body(&self) -> Vec<Cow<'_, [u8]>> {
match self.message_type() { match self.message_type() {
MessageType::MultiPart(boundary) if !self.subparts.is_empty() => { 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(); let closing = [b"--", boundary.as_bytes(), b"--"].concat();
std::iter::once(vec![Cow::Borrowed(&*self.body_bytes)]) std::iter::once(vec![Cow::Borrowed(&*self.body_bytes)])
.chain(self.subparts.iter().map(|p| { .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.append(&mut p.serialize());
res res
})) }))
@@ -902,6 +915,15 @@ pub fn parse_mail<'a, H: Header<'a> + 'a>(raw_data: &'a [u8]) -> Result<CowMail<
build_mail(raw_data, &parsed, false) build_mail(raw_data, &parsed, false)
} }
/// The line ending `bytes` ends with, defaulting to CRLF.
fn line_ending(bytes: &[u8]) -> &'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 /// Byte offset of `inner` within `outer`. Both have to point into the same
/// buffer, which holds for every `ParsedMail::raw_bytes` relative to its /// buffer, which holds for every `ParsedMail::raw_bytes` relative to its
/// parent's. Returns `None` instead of panicking, so that a mailparse that /// 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], body_bytes_orig: &raw_data[body_idx..body_end_idx],
subparts: Vec::new(), subparts: Vec::new(),
end_bytes, 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), 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 // whose body ends in a blank line, byte-exact: the last newline is the
// part's terminator, not part of the delimiter. // part's terminator, not part of the delimiter.
let part_end_idx = extend_over_newline(raw_data, part_idx + part.raw_bytes.len()); let part_end_idx = extend_over_newline(raw_data, part_idx + part.raw_bytes.len());
mail.subparts let mut subpart = build_mail(&raw_data[part_idx..part_end_idx], part, in_multipart_digest)?;
.push(build_mail(&raw_data[part_idx..part_end_idx], part, in_multipart_digest)?);
// Whatever the delimiter line carried after `--<boundary>`.
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. // The part ends right where the next delimiter line begins.
delimiter_idx = part_end_idx; delimiter_idx = part_end_idx;
} }
+75
View File
@@ -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",
"<p>HTML</p>\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] #[test]
fn test_roundtrip_single_part() { 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\r\n");