diff --git a/Cargo.toml b/Cargo.toml index a4df585..2226f04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,5 +18,4 @@ email-encoding = "*" quoted_printable = "*" charset = "*" mailparse = "*" -mediatype = "*" rand = "*" diff --git a/src/lib.rs b/src/lib.rs index 1e82f27..1e92580 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,6 @@ use mailparse::{ ParsedContentDisposition, ParsedContentType, ParsedMail, body::Body, parse_content_disposition, parse_content_type, }; -use mediatype::{MediaTypeBuf, Name, names::*}; use rand::{RngExt, distr::Alphanumeric, rng}; use std::borrow::Cow; use std::fmt::Display; @@ -111,6 +110,17 @@ impl std::fmt::Display for CowMailError { } } +// The mimetype halves the crate matches against. Every mimetype the crate holds is lowercase — +// `ContentType::mimetype` guarantees it — so matching one is a plain string comparison. Not to be +// confused with the `mime` constants, which are whole `ContentType`s. +const TEXT: &str = "text"; +const MULTIPART: &str = "multipart"; +const PLAIN: &str = "plain"; +const HTML: &str = "html"; +const MIXED: &str = "mixed"; +const ALTERNATIVE: &str = "alternative"; +const RELATED: &str = "related"; + /// Which of the two text bodies of a message is meant. /// /// A message can carry a `text/plain` body, a `text/html` one, or both as the alternatives of a @@ -133,7 +143,7 @@ impl BodyType { } /// The subtype a part of this body type declares. - fn name(self) -> Name<'static> { + fn name(self) -> &'static str { match self { Self::PLAIN => PLAIN, Self::HTML => HTML, @@ -531,21 +541,12 @@ pub trait Mail<'a, T: Header<'a> + 'a>: HeaderMap<'a, T> { /// Whether the part carries subparts, and the boundary separating them. /// - /// `multipart/*` without a `boundary` parameter counts as - /// [`SinglePart`](MessageType::SinglePart), as does an unparseable content type. + /// A content type that is not `multipart/*`, or one without a `boundary` parameter, counts as + /// [`SinglePart`](MessageType::SinglePart). fn message_type(&self) -> MessageType { - let ctype = self.content_type(); - match ctype.mimetype().parse::() { - Ok(mtype) => { - let mut res = MessageType::SinglePart; - if mtype.ty() == MULTIPART - && let Some(boundary) = ctype.get_param("boundary") - { - res = MessageType::MultiPart(boundary.to_string()); - } - res - } - Err(_) => MessageType::SinglePart, + match multipart_boundary(&self.content_type()) { + Some(boundary) => MessageType::MultiPart(boundary.to_string()), + None => MessageType::SinglePart, } } @@ -655,32 +656,14 @@ pub fn generate_boundary() -> String { .collect::() } -/// The parsed Content-Type of a part, used to prevent excessive calls go get_mimetype during traversal. -struct PartInfo { - ctype: ContentType, - media: MediaTypeBuf, -} - -impl PartInfo { - fn ty(&self) -> Name<'_> { - self.media.ty() - } - - fn subty(&self) -> Name<'_> { - self.media.subty() - } - - fn boundary(&self) -> Option<&str> { - self.ctype.get_param("boundary") - } - - /// The `start` parameter of a multipart/related, naming its root part. - fn start(&self) -> Option<&str> { - self.ctype.get_param("start") - } - - fn is_multipart(&self) -> bool { - self.ty() == MULTIPART && self.boundary().is_some() +/// The boundary of a part that declares subparts: `multipart/*` carrying a `boundary` parameter. +/// +/// The single place that rule is written. It is the same one mailparse applies when it decides +/// where the parts are, so the two cannot disagree. +fn multipart_boundary(ctype: &ContentType) -> Option<&str> { + match ctype.ty() == MULTIPART { + true => ctype.get_param("boundary"), + false => None, } } @@ -860,15 +843,6 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { } } - fn part_info(&self) -> PartInfo { - let ctype = self.content_type(); - let media = ctype - .mimetype() - .parse::() - .unwrap_or_else(|_| MediaTypeBuf::new(TEXT, PLAIN)); - PartInfo { ctype, media } - } - /// Replace the content of this part with text. /// /// Drops the existing content first — all `Content-*` headers, the body and the subparts, see @@ -964,16 +938,16 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { } /// Check if this MIME part is a text body (plain/html), not a body, or needs traversal. - fn check_body_status(&self, info: &PartInfo, bodytype: Option) -> BodyCheckStatus { - match info.is_multipart() { + fn check_body_status(&self, ctype: &ContentType, bodytype: Option) -> BodyCheckStatus { + match multipart_boundary(ctype).is_some() { true if self.subparts.is_empty() => BodyCheckStatus::NotBody, true => BodyCheckStatus::NeedsTraversal, false => { - if info.ty() != TEXT || self.is_attachment() { + if ctype.ty() != TEXT || self.is_attachment() { return BodyCheckStatus::NotBody; } - let subtype = info.subty(); + let subtype = ctype.subty(); let is_body = match bodytype { Some(bodytype) => subtype == bodytype.name(), None => subtype == PLAIN || subtype == HTML, @@ -987,8 +961,9 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { } } - fn get_related_candidate(&self, info: &PartInfo) -> Option { - info.start().and_then(|start| { + /// The subpart a `multipart/related` names with its `start` parameter, by `Content-ID`. + fn get_related_candidate(&self, ctype: &ContentType) -> Option { + ctype.get_param("start").and_then(|start| { self.subparts.iter().position(|part| { part.headers .find_value("Content-ID") @@ -1006,15 +981,15 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { } fn push_body_path(&self, bodytype: BodyType, path: &mut Vec) -> bool { - let info = self.part_info(); - match self.check_body_status(&info, Some(bodytype)) { + let ctype = self.content_type(); + match self.check_body_status(&ctype, Some(bodytype)) { BodyCheckStatus::IsBody => true, BodyCheckStatus::NotBody => false, BodyCheckStatus::NeedsTraversal => { // A multipart/related names its root part with the `start` parameter. // When it resolves, that part is the only candidate. - if info.subty() == RELATED - && let Some(candidate) = self.get_related_candidate(&info) + if ctype.subty() == RELATED + && let Some(candidate) = self.get_related_candidate(&ctype) { path.push(candidate); if self.subparts[candidate].push_body_path(bodytype, path) { @@ -1080,9 +1055,9 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { Some(self.get_part_at_path_mut(&path)) } - fn make_multipart(&mut self, to_type: Name, boundary: Option) { - let info = self.part_info(); - if info.ty() == MULTIPART && info.subty() == to_type { + fn make_multipart(&mut self, to_type: &str, boundary: Option) { + let ctype = self.content_type(); + if ctype.ty() == MULTIPART && ctype.subty() == to_type { return; } @@ -1172,7 +1147,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { /// `make_multipart` already returns early when this part is that multipart /// subtype, so it covers both wrapping and appending to an existing container. - fn add_multipart(&mut self, subpart: CowMail<'a, H>, subtype: Name) { + fn add_multipart(&mut self, subpart: CowMail<'a, H>, subtype: &str) { self.make_multipart(subtype, None); self.subparts.push(subpart); } @@ -1267,10 +1242,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { self.make_mixed(None); } - let info = self.part_info(); - let subtype = info.subty(); - - if subtype == RELATED && body_type == BodyType::PLAIN { + if self.content_type().subty() == RELATED && body_type == BodyType::PLAIN { self.make_alternative(None); } @@ -1297,8 +1269,8 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { }; let container = self.get_part_at_path_mut(container_path); - let info = container.part_info(); - let subtype = info.subty(); + let container_ctype = container.content_type(); + let subtype = container_ctype.subty(); if subtype == ALTERNATIVE { container.insert_new_part(new_part, body_type); diff --git a/src/tests.rs b/src/tests.rs index 7c5c9bf..55db65f 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1117,3 +1117,101 @@ fn test_roundtrip_header_colon_only() { assert_eq!(mail.headers[0].get_value_raw(), b""); assert_eq!(mail.headers[0].get_raw().as_ref(), b":\r\n"); } + +// The crate matches mimetype halves as plain lowercase strings, with no +// RFC 5322 name validation in between. A malformed mimetype is therefore taken +// at face value instead of falling back to text/plain, so none of these parts +// is a body. +#[test] +fn test_malformed_mimetype_is_not_a_body() { + for raw in [ + "Content-Type: text/\r\n\r\nbody\r\n", + "Content-Type: foo bar/baz\r\n\r\nbody\r\n", + "Content-Type: text\r\n\r\nbody\r\n", + ] { + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert!(mail.get_body_part(BodyType::PLAIN).is_none(), "{raw}"); + assert!(mail.get_body_part(BodyType::HTML).is_none(), "{raw}"); + } +} + +// A structured-syntax suffix is part of the subtype, not stripped from it, so +// text/html+foo is not the text/html body. +#[test] +fn test_mimetype_suffix_is_part_of_the_subtype() { + let raw = "Content-Type: text/html+foo\r\n\r\n

HTML

\r\n"; + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert_eq!(mail.content_type().subty(), "html+foo"); + assert!(mail.get_body_part(BodyType::HTML).is_none()); + assert!(mail.get_body_part(BodyType::PLAIN).is_none()); +} + +// `multipart` without a subtype still splits into subparts, so is_multipart() +// has to agree with the parts that are actually there. +#[test] +fn test_multipart_without_subtype() { + let raw = concat!( + "Content-Type: multipart; boundary=b\r\n", + "\r\n", + "--b\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "plain\r\n", + "--b\r\n", + "Content-Type: text/html\r\n", + "\r\n", + "

HTML

\r\n", + "--b--\r\n", + ); + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert!(mail.is_multipart()); + assert_eq!(mail.subparts.len(), 2); + assert_eq!(mail.get_body_part(BodyType::PLAIN).unwrap().get_content().unwrap(), "plain"); + assert_eq!( + mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), + "

HTML

" + ); +} + +// The mimetype is lowercased on the way in, which is what makes the plain +// string comparisons case-insensitive. +#[test] +fn test_mimetype_matching_is_case_insensitive() { + let raw = "Content-Type: TEXT/HTML\r\n\r\n

HTML

\r\n"; + assert_roundtrip(raw); + + let mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert_eq!(mail.content_type().mimetype(), "text/html"); + assert_eq!( + mail.get_body_part(BodyType::HTML).unwrap().get_content().unwrap(), + "

HTML

" + ); + assert!(mail.get_body_part(BodyType::PLAIN).is_none()); +} + +// MULTIPART/MIXED declares a container just as multipart/mixed does, and +// make_mixed must see it as one rather than nesting another level. +#[test] +fn test_make_mixed_is_case_insensitive() { + let raw = concat!( + "Content-Type: MULTIPART/MIXED; boundary=b\r\n", + "\r\n", + "--b\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "plain\r\n", + "--b--\r\n", + ); + let mut mail = CowMail::try_from(raw.as_bytes()).unwrap(); + assert!(mail.is_multipart()); + + mail.make_mixed(None); + assert_eq!(mail.subparts.len(), 1); + assert_eq!(String::from_utf8_lossy(&serialize(&mail)), raw); +}