diff --git a/Cargo.toml b/Cargo.toml index 17bb206..2caed3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ codegen-units = 1 [dependencies] email = "*" email-encoding = "*" -lettre = {version="*", features=["builder"]} +quoted_printable = "*" charset = "*" mailparse = "*" mediatype = "*" diff --git a/src/body.rs b/src/body.rs new file mode 100644 index 0000000..c77a8b1 --- /dev/null +++ b/src/body.rs @@ -0,0 +1,136 @@ +//! Body transfer-encoding. + +use std::fmt::{self, Display}; +use std::mem; + +use email_encoding::body::{Encoding, base64}; + +/// The `Content-Transfer-Encoding` of a body, as defined in RFC 2045 section 6. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentTransferEncoding { + SevenBit, + EightBit, + QuotedPrintable, + Base64, + Binary, +} + +impl Display for ContentTransferEncoding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::SevenBit => "7bit", + Self::EightBit => "8bit", + Self::QuotedPrintable => "quoted-printable", + Self::Base64 => "base64", + Self::Binary => "binary", + }) + } +} + +/// Body content to be encoded. +/// +/// Pass [`BodyContent::Text`] whenever the content is valid UTF-8: it allows the more +/// efficient `7bit` and `quoted-printable` encodings to be chosen, while binary content +/// always ends up as `base64`. +#[derive(Debug, Clone)] +pub enum BodyContent { + Text(String), + Binary(Vec), +} + +impl From for BodyContent { + fn from(content: String) -> Self { + Self::Text(content) + } +} + +impl From<&str> for BodyContent { + fn from(content: &str) -> Self { + Self::Text(content.to_owned()) + } +} + +impl From> for BodyContent { + fn from(content: Vec) -> Self { + Self::Binary(content) + } +} + +impl From<&[u8]> for BodyContent { + fn from(content: &[u8]) -> Self { + Self::Binary(content.to_vec()) + } +} + +impl From for Vec { + fn from(content: BodyContent) -> Self { + match content { + BodyContent::Text(content) => content.into_bytes(), + BodyContent::Binary(content) => content, + } + } +} + +/// Encode `content`, choosing the most compact of `7bit`, `quoted-printable` and `base64`. +/// +/// Line endings of text content are normalized to CRLF. +pub fn encode(content: impl Into) -> (Vec, ContentTransferEncoding) { + let mut content = content.into(); + + // Chosen before normalizing line endings, since inserting `\r` changes line lengths. + let encoding = match &content { + BodyContent::Text(content) => Encoding::choose(content.as_str(), false), + BodyContent::Binary(content) => Encoding::choose(content.as_slice(), false), + }; + + if let BodyContent::Text(content) = &mut content { + crlf_line_endings(content); + } + let content = Vec::from(content); + + match encoding { + Encoding::SevenBit => (content, ContentTransferEncoding::SevenBit), + Encoding::EightBit => (content, ContentTransferEncoding::EightBit), + Encoding::QuotedPrintable => ( + quoted_printable::encode(content), + ContentTransferEncoding::QuotedPrintable, + ), + Encoding::Base64 => { + let mut encoded = String::with_capacity(base64::encoded_len(content.len())); + base64::encode(&content, &mut encoded).expect("String is always writable"); + (encoded.into_bytes(), ContentTransferEncoding::Base64) + } + } +} + +/// In place conversion to CRLF line endings. +/// +/// Ported from `lettre::message::body` (MIT licensed). +fn crlf_line_endings(content: &mut String) { + // relies on the indices being in reverse order + for ix in lone_lf_indices(content) { + content.insert(ix, '\r'); + } +} + +/// Find, in reverse order, the indices at which a `\r` has to be inserted to give +/// `content` CRLF line endings. +fn lone_lf_indices(content: &str) -> Vec { + let mut indices = Vec::new(); + + let mut found_lf = false; + for (ix, c) in content.char_indices().rev() { + if mem::take(&mut found_lf) && c != '\r' { + // the previous character was a `\n`, but this one is not a `\r` + indices.push(ix + c.len_utf8()); + } + found_lf = c == '\n'; + } + + if found_lf { + // the first character is a `\n` + indices.push(0); + } + + indices +} diff --git a/src/lib.rs b/src/lib.rs index 22e2bdd..169a9cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,15 @@ #![forbid(unsafe_code)] +pub mod body; pub mod header; #[cfg(test)] mod tests; +pub use body::{BodyContent, ContentTransferEncoding}; use header::CowHeader; pub use header::{Header, HeaderMap, parse_headers}; -use lettre::message::MaybeString; -use lettre::message::header::ContentTransferEncoding; use mailparse::{ DispositionType, MailParseError, ParsedContentDisposition, ParsedContentType, body::Body, parse_content_disposition, parse_content_type, @@ -268,29 +268,27 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { self.headers.add_header("Content-Type", ctype); - let body = lettre::message::Body::new(content); - let encoding = body.encoding(); + let (bytes, encoding) = body::encode(content); if encoding != ContentTransferEncoding::SevenBit { self.headers.add_header("Content-Transfer-Encoding", encoding); } - self.body_bytes = Cow::Owned(body.into_vec()); + self.body_bytes = Cow::Owned(bytes); } - pub fn set_content_raw>(&mut self, content: B, ctype: Option) { + pub fn set_content_raw>(&mut self, content: B, ctype: Option) { self.clear_content(); - let body = lettre::message::Body::new(content); if let Some(ctype) = ctype { self.headers.add_header("Content-Type", ctype); } - let encoding = body.encoding(); + let (bytes, encoding) = body::encode(content); if encoding != ContentTransferEncoding::SevenBit { self.headers.add_header("Content-Transfer-Encoding", encoding); } - self.body_bytes = Cow::Owned(body.into_vec()); + self.body_bytes = Cow::Owned(bytes); } pub fn get_filename(&self) -> Option {