From 486e0f9d50fc14afeb657dfb9814e4432fc79cd2 Mon Sep 17 00:00:00 2001 From: Paolo Barbolini Date: Thu, 8 Apr 2021 10:40:07 +0200 Subject: [PATCH] Replace hyperx ContentType header with our own implementation (#598) * Replace hyperx ContentType header with our own implementation * Let's not forget ContentTypeErr * Adress code review comment --- examples/basic_html.rs | 8 +- examples/maud_html.rs | 8 +- src/message/header/content_type.rs | 127 +++++++++++++++++++++++++++++ src/message/header/mod.rs | 6 +- src/message/mimebody.rs | 101 ++++++++++------------- src/message/mod.rs | 22 ++--- testdata/email_with_png.eml | 2 +- 7 files changed, 187 insertions(+), 87 deletions(-) create mode 100644 src/message/header/content_type.rs diff --git a/examples/basic_html.rs b/examples/basic_html.rs index d865c35..e22deb8 100644 --- a/examples/basic_html.rs +++ b/examples/basic_html.rs @@ -29,16 +29,12 @@ fn main() { MultiPart::alternative() // This is composed of two parts. .singlepart( SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_PLAIN) .body(String::from("Hello from Lettre! A mailer library for Rust")), // Every message should have a plain text fallback. ) .singlepart( SinglePart::builder() - .header(header::ContentType( - "text/html; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_HTML) .body(String::from(html)), ), ) diff --git a/examples/maud_html.rs b/examples/maud_html.rs index 17d489b..8d894e0 100644 --- a/examples/maud_html.rs +++ b/examples/maud_html.rs @@ -38,16 +38,12 @@ fn main() { MultiPart::alternative() // This is composed of two parts. .singlepart( SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_PLAIN) .body(String::from("Hello from Lettre! A mailer library for Rust")), // Every message should have a plain text fallback. ) .singlepart( SinglePart::builder() - .header(header::ContentType( - "text/html; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_HTML) .body(html.into_string()), ), ) diff --git a/src/message/header/content_type.rs b/src/message/header/content_type.rs new file mode 100644 index 0000000..0d17673 --- /dev/null +++ b/src/message/header/content_type.rs @@ -0,0 +1,127 @@ +use std::{ + error::Error as StdError, + fmt::{self, Display, Result as FmtResult}, + str::{from_utf8, FromStr}, +}; + +use hyperx::{ + header::{Formatter as HeaderFormatter, Header, RawLike}, + Error as HeaderError, Result as HyperResult, +}; +use mime::Mime; + +/// `Content-Type` of the body +/// +/// Defined in [RFC2045](https://tools.ietf.org/html/rfc2045#section-5) +#[derive(Debug, Clone, PartialEq)] +pub struct ContentType(Mime); + +impl ContentType { + /// A `ContentType` of type `text/plain; charset=utf-8` + /// + /// Indicates that the body is in utf-8 encoded plain text. + pub const TEXT_PLAIN: ContentType = Self::from_mime(mime::TEXT_PLAIN_UTF_8); + + /// A `ContentType` of type `text/html; charset=utf-8` + /// + /// Indicates that the body is in utf-8 encoded html. + pub const TEXT_HTML: ContentType = Self::from_mime(mime::TEXT_HTML_UTF_8); + + /// Parse `s` into `ContentType` + pub fn parse(s: &str) -> Result { + Ok(Self::from_mime(s.parse().map_err(ContentTypeErr)?)) + } + + pub(crate) const fn from_mime(mime: Mime) -> Self { + Self(mime) + } + + pub(crate) fn as_ref(&self) -> &Mime { + &self.0 + } +} + +impl Header for ContentType { + fn header_name() -> &'static str { + "Content-Type" + } + + // FIXME HeaderError->HeaderError, same for result + fn parse_header<'a, T>(raw: &'a T) -> HyperResult + where + T: RawLike<'a>, + Self: Sized, + { + raw.one() + .ok_or(HeaderError::Header) + .and_then(|r| from_utf8(r).map_err(|_| HeaderError::Header)) + .and_then(|s| s.parse::().map(Self).map_err(|_| HeaderError::Header)) + } + + fn fmt_header(&self, f: &mut HeaderFormatter<'_, '_>) -> FmtResult { + f.fmt_line(&self.0) + } +} + +impl FromStr for ContentType { + type Err = ContentTypeErr; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +/// An error occurred while trying to [`ContentType::parse`]. +#[derive(Debug)] +pub struct ContentTypeErr(mime::FromStrError); + +impl StdError for ContentTypeErr { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.0) + } +} + +impl Display for ContentTypeErr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, f) + } +} + +#[cfg(test)] +mod test { + use hyperx::header::Headers; + + use super::ContentType; + + #[test] + fn format_content_type() { + let mut headers = Headers::new(); + + headers.set(ContentType::TEXT_PLAIN); + + assert_eq!( + format!("{}", headers), + "Content-Type: text/plain; charset=utf-8\r\n" + ); + + headers.set(ContentType::TEXT_HTML); + + assert_eq!( + format!("{}", headers), + "Content-Type: text/html; charset=utf-8\r\n" + ); + } + + #[test] + fn parse_content_type() { + let mut headers = Headers::new(); + + headers.set_raw("Content-Type", "text/plain; charset=utf-8"); + + assert_eq!(headers.get::(), Some(&ContentType::TEXT_PLAIN)); + + headers.set_raw("Content-Type", "text/html; charset=utf-8"); + + assert_eq!(headers.get::(), Some(&ContentType::TEXT_HTML)); + } +} diff --git a/src/message/header/mod.rs b/src/message/header/mod.rs index 10f9ee8..f8adb59 100644 --- a/src/message/header/mod.rs +++ b/src/message/header/mod.rs @@ -1,14 +1,16 @@ //! Headers widely used in email messages pub use hyperx::header::{ - Charset, ContentDisposition, ContentLocation, ContentType, DispositionParam, DispositionType, - Header, Headers, + Charset, ContentDisposition, ContentLocation, DispositionParam, DispositionType, Header, + Headers, }; +pub use self::content_type::{ContentType, ContentTypeErr}; pub use self::date::Date; pub use self::{content::*, mailbox::*, special::*, textual::*}; mod content; +mod content_type; mod date; mod mailbox; mod special; diff --git a/src/message/mimebody.rs b/src/message/mimebody.rs index fe535b8..2fb27e1 100644 --- a/src/message/mimebody.rs +++ b/src/message/mimebody.rs @@ -94,7 +94,7 @@ impl Default for SinglePartBuilder { /// # use std::error::Error; /// # fn main() -> Result<(), Box> { /// let part = SinglePart::builder() -/// .header(header::ContentType("text/plain; charset=utf8".parse()?)) +/// .header(header::ContentType::TEXT_PLAIN) /// .body(String::from("Текст письма в уникоде")); /// # Ok(()) /// # } @@ -174,7 +174,7 @@ fn make_boundary() -> String { } impl MultiPartKind { - fn to_mime>(&self, boundary: Option) -> Mime { + pub(crate) fn to_mime>(&self, boundary: Option) -> Mime { let boundary = boundary.map_or_else(make_boundary, Into::into); format!( @@ -217,12 +217,6 @@ impl MultiPartKind { } } -impl From for Mime { - fn from(m: MultiPartKind) -> Self { - m.to_mime::(None) - } -} - /// Multipart builder #[derive(Debug, Clone)] pub struct MultiPartBuilder { @@ -245,17 +239,17 @@ impl MultiPartBuilder { /// Set `Content-Type` header using [`MultiPartKind`] pub fn kind(self, kind: MultiPartKind) -> Self { - self.header(ContentType(kind.into())) + self.header(ContentType::from_mime(kind.to_mime::(None))) } /// Set custom boundary - pub fn boundary>(self, boundary: S) -> Self { + pub fn boundary>(self, boundary: S) -> Self { let kind = { - let mime = &self.headers.get::().unwrap().0; - MultiPartKind::from_mime(mime).unwrap() + let content_type = self.headers.get::().unwrap(); + MultiPartKind::from_mime(content_type.as_ref()).unwrap() }; - let mime = kind.to_mime(Some(boundary.as_ref())); - self.header(ContentType(mime)) + let mime = kind.to_mime(Some(boundary)); + self.header(ContentType::from_mime(mime)) } /// Creates multipart without parts @@ -356,8 +350,13 @@ impl MultiPart { /// Get the boundary of multipart contents pub fn boundary(&self) -> String { - let content_type = &self.headers.get::().unwrap().0; - content_type.get_param("boundary").unwrap().as_str().into() + let content_type = self.headers.get::().unwrap(); + content_type + .as_ref() + .get_param("boundary") + .unwrap() + .as_str() + .into() } /// Get the headers from the multipart @@ -417,16 +416,14 @@ mod test { #[test] fn single_part_binary() { let part = SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_PLAIN) .header(header::ContentTransferEncoding::Binary) .body(String::from("Текст письма в уникоде")); assert_eq!( String::from_utf8(part.formatted()).unwrap(), concat!( - "Content-Type: text/plain; charset=utf8\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Transfer-Encoding: binary\r\n", "\r\n", "Текст письма в уникоде\r\n" @@ -437,16 +434,14 @@ mod test { #[test] fn single_part_quoted_printable() { let part = SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_PLAIN) .header(header::ContentTransferEncoding::QuotedPrintable) .body(String::from("Текст письма в уникоде")); assert_eq!( String::from_utf8(part.formatted()).unwrap(), concat!( - "Content-Type: text/plain; charset=utf8\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Transfer-Encoding: quoted-printable\r\n", "\r\n", "=D0=A2=D0=B5=D0=BA=D1=81=D1=82 =D0=BF=D0=B8=D1=81=D1=8C=D0=BC=D0=B0 =D0=B2 =\r\n", @@ -458,16 +453,14 @@ mod test { #[test] fn single_part_base64() { let part = SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_PLAIN) .header(header::ContentTransferEncoding::Base64) .body(String::from("Текст письма в уникоде")); assert_eq!( String::from_utf8(part.formatted()).unwrap(), concat!( - "Content-Type: text/plain; charset=utf8\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Transfer-Encoding: base64\r\n", "\r\n", "0KLQtdC60YHRgiDQv9C40YHRjNC80LAg0LIg0YPQvdC40LrQvtC00LU=\r\n" @@ -481,17 +474,13 @@ mod test { .boundary("F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK") .part(Part::Single( SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_PLAIN) .header(header::ContentTransferEncoding::Binary) .body(String::from("Текст письма в уникоде")), )) .singlepart( SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_PLAIN) .header(header::ContentDisposition { disposition: header::DispositionType::Attachment, parameters: vec![header::DispositionParam::Filename( @@ -509,12 +498,12 @@ mod test { " boundary=\"F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\"\r\n", "\r\n", "--F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\r\n", - "Content-Type: text/plain; charset=utf8\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Transfer-Encoding: binary\r\n", "\r\n", "Текст письма в уникоде\r\n", "--F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\r\n", - "Content-Type: text/plain; charset=utf8\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Disposition: attachment; filename=\"example.c\"\r\n", "Content-Transfer-Encoding: binary\r\n", "\r\n", @@ -527,18 +516,15 @@ mod test { .boundary("F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK") .part(Part::Single( SinglePart::builder() - .header(header::ContentType( - "application/pgp-encrypted".parse().unwrap(), - )) + .header(header::ContentType::parse("application/pgp-encrypted").unwrap()) .body(String::from("Version: 1")), )) .singlepart( SinglePart::builder() - .header(ContentType( - "application/octet-stream; name=\"encrypted.asc\"" - .parse() + .header( + ContentType::parse("application/octet-stream; name=\"encrypted.asc\"") .unwrap(), - )) + ) .header(header::ContentDisposition { disposition: header::DispositionType::Inline, parameters: vec![header::DispositionParam::Filename( @@ -586,16 +572,15 @@ mod test { .boundary("F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK") .part(Part::Single( SinglePart::builder() - .header(header::ContentType("text/plain".parse().unwrap())) + .header(header::ContentType::TEXT_PLAIN) .body(String::from("Test email for signature")), )) .singlepart( SinglePart::builder() - .header(ContentType( - "application/pgp-signature; name=\"signature.asc\"" - .parse() + .header( + ContentType::parse("application/pgp-signature; name=\"signature.asc\"") .unwrap(), - )) + ) .header(header::ContentDisposition { disposition: header::DispositionType::Attachment, parameters: vec![header::DispositionParam::Filename( @@ -622,7 +607,7 @@ mod test { " micalg=\"pgp-sha256\"\r\n", "\r\n", "--F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\r\n", - "Content-Type: text/plain\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Transfer-Encoding: 7bit\r\n", "\r\n", "Test email for signature\r\n", @@ -647,11 +632,11 @@ mod test { let part = MultiPart::alternative() .boundary("F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK") .part(Part::Single(SinglePart::builder() - .header(header::ContentType("text/plain; charset=utf8".parse().unwrap())) + .header(header::ContentType::TEXT_PLAIN) .header(header::ContentTransferEncoding::Binary) .body(String::from("Текст письма в уникоде")))) .singlepart(SinglePart::builder() - .header(header::ContentType("text/html; charset=utf8".parse().unwrap())) + .header(header::ContentType::TEXT_HTML) .header(header::ContentTransferEncoding::Binary) .body(String::from("

Текст письма в уникоде

"))); @@ -660,12 +645,12 @@ mod test { " boundary=\"F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\"\r\n", "\r\n", "--F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\r\n", - "Content-Type: text/plain; charset=utf8\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Transfer-Encoding: binary\r\n", "\r\n", "Текст письма в уникоде\r\n", "--F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\r\n", - "Content-Type: text/html; charset=utf8\r\n", + "Content-Type: text/html; charset=utf-8\r\n", "Content-Transfer-Encoding: binary\r\n", "\r\n", "

Текст письма в уникоде

\r\n", @@ -679,16 +664,16 @@ mod test { .multipart(MultiPart::related() .boundary("E912L4JH3loAAAAAFu/33Gx7PEoTMmhGaxG3FlbVMQHctj96q4nHvBM+7DTtXo/im8gh") .singlepart(SinglePart::builder() - .header(header::ContentType("text/html; charset=utf8".parse().unwrap())) + .header(header::ContentType::TEXT_HTML) .header(header::ContentTransferEncoding::Binary) .body(String::from("

Текст письма в уникоде

"))) .singlepart(SinglePart::builder() - .header(header::ContentType("image/png".parse().unwrap())) + .header(header::ContentType::parse("image/png").unwrap()) .header(header::ContentLocation("/image.png".into())) .header(header::ContentTransferEncoding::Base64) .body(String::from("1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890")))) .singlepart(SinglePart::builder() - .header(header::ContentType("text/plain; charset=utf8".parse().unwrap())) + .header(header::ContentType::TEXT_PLAIN) .header(header::ContentDisposition { disposition: header::DispositionType::Attachment, parameters: vec![header::DispositionParam::Filename(header::Charset::Ext("utf-8".into()), None, "example.c".into())] @@ -705,7 +690,7 @@ mod test { " boundary=\"E912L4JH3loAAAAAFu/33Gx7PEoTMmhGaxG3FlbVMQHctj96q4nHvBM+7DTtXo/im8gh\"\r\n", "\r\n", "--E912L4JH3loAAAAAFu/33Gx7PEoTMmhGaxG3FlbVMQHctj96q4nHvBM+7DTtXo/im8gh\r\n", - "Content-Type: text/html; charset=utf8\r\n", + "Content-Type: text/html; charset=utf-8\r\n", "Content-Transfer-Encoding: binary\r\n", "\r\n", "

Текст письма в уникоде

\r\n", @@ -719,7 +704,7 @@ mod test { "NTY3ODkwMTIzNDU2Nzg5MA==\r\n", "--E912L4JH3loAAAAAFu/33Gx7PEoTMmhGaxG3FlbVMQHctj96q4nHvBM+7DTtXo/im8gh--\r\n", "--F2mTKN843loAAAAA8porEdAjCKhArPxGeahYoZYSftse1GT/84tup+O0bs8eueVuAlMK\r\n", - "Content-Type: text/plain; charset=utf8\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", "Content-Disposition: attachment; filename=\"example.c\"\r\n", "Content-Transfer-Encoding: binary\r\n", "\r\n", diff --git a/src/message/mod.rs b/src/message/mod.rs index 19c8024..479f4ef 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -75,12 +75,12 @@ //! MultiPart::alternative() //! .singlepart( //! SinglePart::builder() -//! .header(header::ContentType("text/plain; charset=utf8".parse()?)) +//! .header(header::ContentType::TEXT_PLAIN) //! .body(String::from("Hello, world! :)")), //! ) //! .singlepart( //! SinglePart::builder() -//! .header(header::ContentType("text/html; charset=utf8".parse()?)) +//! .header(header::ContentType::TEXT_HTML) //! .body(String::from( //! "

Hello, world!

", //! )), @@ -146,23 +146,21 @@ //! MultiPart::alternative() //! .singlepart( //! SinglePart::builder() -//! .header(header::ContentType("text/plain; charset=utf8".parse()?)) +//! .header(header::ContentType::TEXT_PLAIN) //! .body(String::from("Hello, world! :)")), //! ) //! .multipart( //! MultiPart::related() //! .singlepart( //! SinglePart::builder() -//! .header(header::ContentType( -//! "text/html; charset=utf8".parse()?, -//! )) +//! .header(header::ContentType::TEXT_HTML) //! .body(String::from( //! "

Hello, world!

", //! )), //! ) //! .singlepart( //! SinglePart::builder() -//! .header(header::ContentType("image/png".parse()?)) +//! .header(header::ContentType::parse("image/png")?) //! .header(header::ContentDisposition { //! disposition: header::DispositionType::Inline, //! parameters: vec![], @@ -174,7 +172,7 @@ //! ) //! .singlepart( //! SinglePart::builder() -//! .header(header::ContentType("text/plain; charset=utf8".parse()?)) +//! .header(header::ContentType::TEXT_PLAIN) //! .header(header::ContentDisposition { //! disposition: header::DispositionType::Attachment, //! parameters: vec![header::DispositionParam::Filename( @@ -242,8 +240,6 @@ pub use body::{Body, IntoBody, MaybeString}; pub use mailbox::*; pub use mimebody::*; -pub use mime; - mod body; pub mod header; mod mailbox; @@ -637,16 +633,14 @@ mod test { MultiPart::related() .singlepart( SinglePart::builder() - .header(header::ContentType( - "text/html; charset=utf8".parse().unwrap(), - )) + .header(header::ContentType::TEXT_HTML) .body(String::from( "

Hello, world!

", )), ) .singlepart( SinglePart::builder() - .header(header::ContentType("image/png".parse().unwrap())) + .header(header::ContentType::parse("image/png").unwrap()) .header(header::ContentDisposition { disposition: header::DispositionType::Inline, parameters: vec![], diff --git a/testdata/email_with_png.eml b/testdata/email_with_png.eml index 72221ef..298e48a 100644 --- a/testdata/email_with_png.eml +++ b/testdata/email_with_png.eml @@ -7,7 +7,7 @@ MIME-Version: 1.0 Content-Type: multipart/related; boundary="0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1" --0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1 -Content-Type: text/html; charset=utf8 +Content-Type: text/html; charset=utf-8 Content-Transfer-Encoding: 7bit

Hello, world!