drop mediatype, match mimetypes as lowercase strings
This commit is contained in:
+43
-71
@@ -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::<MediaTypeBuf>() {
|
||||
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::<String>()
|
||||
}
|
||||
|
||||
/// 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::<MediaTypeBuf>()
|
||||
.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<BodyType>) -> BodyCheckStatus {
|
||||
match info.is_multipart() {
|
||||
fn check_body_status(&self, ctype: &ContentType, bodytype: Option<BodyType>) -> 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<usize> {
|
||||
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<usize> {
|
||||
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<usize>) -> 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<String>) {
|
||||
let info = self.part_info();
|
||||
if info.ty() == MULTIPART && info.subty() == to_type {
|
||||
fn make_multipart(&mut self, to_type: &str, boundary: Option<String>) {
|
||||
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);
|
||||
|
||||
@@ -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<p>HTML</p>\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",
|
||||
"<p>HTML</p>\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(),
|
||||
"<p>HTML</p>"
|
||||
);
|
||||
}
|
||||
|
||||
// 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<p>HTML</p>\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(),
|
||||
"<p>HTML</p>"
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user