diff --git a/examples/basic_html.rs b/examples/basic_html.rs index 19aca6b..7fd5261 100644 --- a/examples/basic_html.rs +++ b/examples/basic_html.rs @@ -26,18 +26,18 @@ fn main() { .multipart( MultiPart::alternative() // This is composed of two parts. .singlepart( - SinglePart::eight_bit() + SinglePart::builder() .header(header::ContentType( "text/plain; charset=utf8".parse().unwrap(), )) - .body("Hello from Lettre! A mailer library for Rust"), // Every message should have a plain text fallback. + .body(String::from("Hello from Lettre! A mailer library for Rust")), // Every message should have a plain text fallback. ) .singlepart( - SinglePart::quoted_printable() + SinglePart::builder() .header(header::ContentType( "text/html; charset=utf8".parse().unwrap(), )) - .body(html), + .body(String::from(html)), ), ) .expect("failed to build email"); diff --git a/examples/maud_html.rs b/examples/maud_html.rs index 02ffceb..ec92916 100644 --- a/examples/maud_html.rs +++ b/examples/maud_html.rs @@ -35,14 +35,14 @@ fn main() { .multipart( MultiPart::alternative() // This is composed of two parts. .singlepart( - SinglePart::eight_bit() + SinglePart::builder() .header(header::ContentType( "text/plain; charset=utf8".parse().unwrap(), )) - .body("Hello from Lettre! A mailer library for Rust"), // Every message should have a plain text fallback. + .body(String::from("Hello from Lettre! A mailer library for Rust")), // Every message should have a plain text fallback. ) .singlepart( - SinglePart::quoted_printable() + SinglePart::builder() .header(header::ContentType( "text/html; charset=utf8".parse().unwrap(), )) diff --git a/src/message/body.rs b/src/message/body.rs new file mode 100644 index 0000000..9196184 --- /dev/null +++ b/src/message/body.rs @@ -0,0 +1,408 @@ +use std::borrow::Cow; +use std::io::{self, Write}; + +use crate::message::header::ContentTransferEncoding; + +/// A [`SinglePart`][super::SinglePart] body. +#[derive(Debug, Clone)] +pub struct Body(BodyInner); + +#[derive(Debug, Clone)] +enum BodyInner { + Binary(Vec), + String(String), +} + +impl Body { + /// Returns the length of this `Body` in bytes. + #[inline] + pub fn len(&self) -> usize { + match &self.0 { + BodyInner::Binary(b) => b.len(), + BodyInner::String(s) => s.len(), + } + } + + /// Returns `true` if this `Body` has a length of zero, `false` otherwise. + #[inline] + pub fn is_empty(&self) -> bool { + match &self.0 { + BodyInner::Binary(b) => b.is_empty(), + BodyInner::String(s) => s.is_empty(), + } + } + + /// Suggests the best `Content-Transfer-Encoding` to be used for this `Body` + /// + /// If the `Body` was created from a `String` composed only of US-ASCII + /// characters, with no lines longer than 1000 characters, then 7bit + /// encoding will be used, else quoted-printable will be choosen. + /// + /// If the `Body` was instead created from a `Vec`, base64 encoding is always + /// choosen. + /// + /// `8bit` and `binary` encodings are never returned, as they may not be + /// supported by all SMTP servers. + pub fn encoding(&self) -> ContentTransferEncoding { + match &self.0 { + BodyInner::String(s) if is_7bit_encoded(s.as_ref()) => { + ContentTransferEncoding::SevenBit + } + // TODO: consider when base64 would be a better option because of output size + BodyInner::String(_) => ContentTransferEncoding::QuotedPrintable, + BodyInner::Binary(_) => ContentTransferEncoding::Base64, + } + } + + /// Encodes this `Body` using the choosen `encoding`. + /// + /// # Panic + /// + /// Panics if the choosen `Content-Transfer-Encoding` would end-up + /// creating an incorrectly encoded email. + /// + /// Could happen for example if `7bit` encoding is choosen when the + /// content isn't US-ASCII or contains lines longer than 1000 characters. + /// + /// Never panics when using an `encoding` returned by [`encoding`][Body::encoding]. + pub fn encode(&self, encoding: ContentTransferEncoding) -> Cow<'_, Body> { + match encoding { + ContentTransferEncoding::SevenBit => { + assert!( + is_7bit_encoded(self.as_ref()), + "Body isn't valid 7bit content" + ); + + Cow::Borrowed(self) + } + ContentTransferEncoding::EightBit => { + assert!( + is_8bit_encoded(self.as_ref()), + "Body isn't valid 8bit content" + ); + + Cow::Borrowed(self) + } + ContentTransferEncoding::Binary => Cow::Borrowed(self), + ContentTransferEncoding::QuotedPrintable => { + let encoded = quoted_printable::encode_to_str(self); + Cow::Owned(Body(BodyInner::String(encoded))) + } + ContentTransferEncoding::Base64 => { + let base64_len = self.len() * 4 / 3 + 4; + let base64_endings_len = base64_len + base64_len / LINE_MAX_LENGTH; + + let mut out = Vec::with_capacity(base64_endings_len); + { + let writer = LineWrappingWriter::new(&mut out, LINE_MAX_LENGTH); + let mut writer = base64::write::EncoderWriter::new(writer, base64::STANDARD); + + // TODO: use writer.write_all(self.as_ref()).expect("base64 encoding never fails"); + + // modified Write::write_all to work around base64 crate bug + // TODO: remove once https://github.com/marshallpierce/rust-base64/issues/148 is fixed + { + let mut buf: &[u8] = self.as_ref(); + while !buf.is_empty() { + match writer.write(buf) { + Ok(0) => { + // ignore 0 writes + } + Ok(n) => { + buf = &buf[n..]; + } + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => panic!("base64 encoding never fails: {}", e), + } + } + } + } + + Cow::Owned(Body(BodyInner::Binary(out))) + } + } + } +} + +impl From> for Body { + #[inline] + fn from(b: Vec) -> Self { + Self(BodyInner::Binary(b)) + } +} + +impl From for Body { + #[inline] + fn from(s: String) -> Self { + Self(BodyInner::String(s)) + } +} + +impl AsRef<[u8]> for Body { + #[inline] + fn as_ref(&self) -> &[u8] { + match &self.0 { + BodyInner::Binary(b) => b.as_ref(), + BodyInner::String(s) => s.as_ref(), + } + } +} + +/// Checks whether it contains only US-ASCII characters, +/// and no lines are longer than 1000 characters including the `\n` character. +/// +/// Most efficient content encoding available +pub(crate) fn is_7bit_encoded(buf: &[u8]) -> bool { + buf.is_ascii() && !contains_too_long_lines(buf) +} + +/// Checks that no lines are longer than 1000 characters, +/// including the `\n` character. +/// NOTE: 8bit isn't supported by all SMTP servers. +fn is_8bit_encoded(buf: &[u8]) -> bool { + !contains_too_long_lines(buf) +} + +/// Checks if there are lines that are longer than 1000 characters, +/// including the `\n` character. +fn contains_too_long_lines(buf: &[u8]) -> bool { + buf.len() > 1000 && buf.split(|&b| b == b'\n').any(|line| line.len() > 999) +} + +const LINE_SEPARATOR: &[u8] = b"\r\n"; +const LINE_MAX_LENGTH: usize = 78 - LINE_SEPARATOR.len(); + +/// A `Write`r that inserts a line separator `\r\n` every `max_line_length` bytes. +struct LineWrappingWriter<'a, W> { + writer: &'a mut W, + current_line_length: usize, + max_line_length: usize, +} + +impl<'a, W> LineWrappingWriter<'a, W> { + pub fn new(writer: &'a mut W, max_line_length: usize) -> Self { + Self { + writer, + current_line_length: 0, + max_line_length, + } + } +} + +impl<'a, W> Write for LineWrappingWriter<'a, W> +where + W: Write, +{ + fn write(&mut self, buf: &[u8]) -> io::Result { + let remaining_line_len = self.max_line_length - self.current_line_length; + let write_len = std::cmp::min(buf.len(), remaining_line_len); + + self.writer.write_all(&buf[..write_len])?; + + if remaining_line_len == write_len { + self.writer.write_all(LINE_SEPARATOR)?; + + self.current_line_length = 0; + } else { + self.current_line_length += write_len; + } + + Ok(write_len) + } + + fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } +} + +#[cfg(test)] +mod test { + use super::{Body, ContentTransferEncoding}; + + #[test] + fn seven_bit_detect() { + let input = Body::from(String::from("Hello, world!")); + + let encoding = input.encoding(); + assert_eq!(encoding, ContentTransferEncoding::SevenBit); + } + + #[test] + fn seven_bit_encode() { + let input = Body::from(String::from("Hello, world!")); + + let output = input.encode(ContentTransferEncoding::SevenBit); + assert_eq!(output.as_ref().as_ref(), b"Hello, world!"); + } + + #[test] + fn seven_bit_too_long_detect() { + let input = Body::from("Hello, world!".repeat(100)); + + let encoding = input.encoding(); + assert_eq!(encoding, ContentTransferEncoding::QuotedPrintable); + } + + #[test] + #[should_panic] + fn seven_bit_too_long_fail() { + let input = Body::from("Hello, world!".repeat(100)); + + let _ = input.encode(ContentTransferEncoding::SevenBit); + } + + #[test] + fn seven_bit_too_long_encode_quotedprintable() { + let input = Body::from("Hello, world!".repeat(100)); + + let output = input.encode(ContentTransferEncoding::QuotedPrintable); + assert_eq!( + output.as_ref().as_ref(), + concat!( + "Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, wor=\r\n", + "ld!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, =\r\n", + "world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hell=\r\n", + "o, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!H=\r\n", + "ello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, worl=\r\n", + "d!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, w=\r\n", + "orld!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello=\r\n", + ", world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!He=\r\n", + "llo, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world=\r\n", + "!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, wo=\r\n", + "rld!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello,=\r\n", + " world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hel=\r\n", + "lo, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!=\r\n", + "Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, wor=\r\n", + "ld!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, =\r\n", + "world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!Hell=\r\n", + "o, world!Hello, world!Hello, world!Hello, world!Hello, world!Hello, world!H=\r\n", + "ello, world!Hello, world!" + ) + .as_bytes() + ); + } + + #[test] + #[should_panic] + fn seven_bit_invalid() { + let input = Body::from(String::from("Привет, мир!")); + + let _ = input.encode(ContentTransferEncoding::SevenBit); + } + + #[test] + fn eight_bit_encode() { + let input = Body::from(String::from("Привет, мир!")); + + let out = input.encode(ContentTransferEncoding::EightBit); + assert_eq!(out.as_ref().as_ref(), "Привет, мир!".as_bytes()); + } + + #[test] + #[should_panic] + fn eight_bit_too_long_fail() { + let input = Body::from("Привет, мир!".repeat(200)); + + let _ = input.encode(ContentTransferEncoding::EightBit); + } + + #[test] + fn quoted_printable_detect() { + let input = Body::from(String::from("Привет, мир!")); + + let encoding = input.encoding(); + assert_eq!(encoding, ContentTransferEncoding::QuotedPrintable); + } + + #[test] + fn quoted_printable_encode_ascii() { + let input = Body::from(String::from("Hello, world!")); + + let output = input.encode(ContentTransferEncoding::QuotedPrintable); + assert_eq!(output.as_ref().as_ref(), b"Hello, world!"); + } + + #[test] + fn quoted_printable_encode_utf8() { + let input = Body::from(String::from("Привет, мир!")); + + let output = input.encode(ContentTransferEncoding::QuotedPrintable); + assert_eq!( + output.as_ref().as_ref(), + b"=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82, =D0=BC=D0=B8=D1=80!".as_ref() + ); + } + + #[test] + fn quoted_printable_encode_line_wrap() { + let input = Body::from(String::from("Текст письма в уникоде")); + + let output = input.encode(ContentTransferEncoding::QuotedPrintable); + assert_eq!( + output.as_ref().as_ref(), + concat!( + "=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", + "=D1=83=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5" + ) + .as_bytes() + ); + } + + #[test] + fn base64_detect() { + let input = Body::from(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + let encoding = input.encoding(); + assert_eq!(encoding, ContentTransferEncoding::Base64); + } + + #[test] + fn base64_encode_bytes() { + let input = Body::from(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + + let output = input.encode(ContentTransferEncoding::Base64); + assert_eq!(output.as_ref().as_ref(), b"AAECAwQFBgcICQ=="); + } + + #[test] + fn base64_encode_bytes_wrapping() { + let input = Body::from(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9].repeat(20)); + + let output = input.encode(ContentTransferEncoding::Base64); + assert_eq!( + output.as_ref().as_ref(), + concat!( + "AAECAwQFBgcICQABAgMEBQYHCAkAAQIDBAUGBwgJAAECAwQFBgcICQABAgMEBQYHCAkAAQIDBAUG\r\n", + "BwgJAAECAwQFBgcICQABAgMEBQYHCAkAAQIDBAUGBwgJAAECAwQFBgcICQABAgMEBQYHCAkAAQID\r\n", + "BAUGBwgJAAECAwQFBgcICQABAgMEBQYHCAkAAQIDBAUGBwgJAAECAwQFBgcICQABAgMEBQYHCAkA\r\n", + "AQIDBAUGBwgJAAECAwQFBgcICQABAgMEBQYHCAk=" + ) + .as_bytes() + ); + } + + #[test] + fn base64_encode_ascii() { + let input = Body::from(String::from("Hello World!")); + + let output = input.encode(ContentTransferEncoding::Base64); + assert_eq!(output.as_ref().as_ref(), b"SGVsbG8gV29ybGQh"); + } + + #[test] + fn base64_encode_ascii_wrapping() { + let input = Body::from("Hello World!".repeat(20)); + + let output = input.encode(ContentTransferEncoding::Base64); + assert_eq!( + output.as_ref().as_ref(), + concat!( + "SGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29y\r\n", + "bGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8g\r\n", + "V29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVs\r\n", + "bG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQhSGVsbG8gV29ybGQh\r\n", + "SGVsbG8gV29ybGQh" + ) + .as_bytes() + ); + } +} diff --git a/src/message/encoder.rs b/src/message/encoder.rs deleted file mode 100644 index 5e47d45..0000000 --- a/src/message/encoder.rs +++ /dev/null @@ -1,247 +0,0 @@ -use crate::message::header::ContentTransferEncoding; - -/// Encoder trait -pub trait EncoderCodec: Send { - /// Encode all data - fn encode(&mut self, input: &[u8]) -> Vec; -} - -/// 7bit codec -/// -/// WARNING: Panics when passed non-ascii chars -struct SevenBitCodec { - line_wrapper: EightBitCodec, -} - -impl SevenBitCodec { - pub fn new() -> Self { - SevenBitCodec { - line_wrapper: EightBitCodec::new(), - } - } -} - -impl EncoderCodec for SevenBitCodec { - fn encode(&mut self, input: &[u8]) -> Vec { - assert!(input.is_ascii(), "input must be valid ascii"); - - self.line_wrapper.encode(input) - } -} - -/// Quoted-Printable codec -/// -struct QuotedPrintableCodec(); - -impl QuotedPrintableCodec { - pub fn new() -> Self { - QuotedPrintableCodec() - } -} - -impl EncoderCodec for QuotedPrintableCodec { - fn encode(&mut self, input: &[u8]) -> Vec { - quoted_printable::encode(input) - } -} - -/// Base64 codec -/// -struct Base64Codec { - line_wrapper: EightBitCodec, -} - -impl Base64Codec { - pub fn new() -> Self { - Base64Codec { - // TODO probably 78, 76 is for qp - line_wrapper: EightBitCodec::new().with_limit(78 - 2), - } - } -} - -impl EncoderCodec for Base64Codec { - fn encode(&mut self, input: &[u8]) -> Vec { - self.line_wrapper.encode(base64::encode(input).as_bytes()) - } -} - -/// 8bit codec -/// -struct EightBitCodec { - max_length: usize, -} - -const DEFAULT_MAX_LINE_LENGTH: usize = 1000 - 2; - -impl EightBitCodec { - pub fn new() -> Self { - EightBitCodec { - max_length: DEFAULT_MAX_LINE_LENGTH, - } - } - - pub fn with_limit(mut self, max_length: usize) -> Self { - self.max_length = max_length; - self - } -} - -impl EncoderCodec for EightBitCodec { - fn encode(&mut self, input: &[u8]) -> Vec { - let ending = b"\r\n"; - let endings_len = input.len() / self.max_length * ending.len(); - let mut out = Vec::with_capacity(input.len() + endings_len); - - for chunk in input.chunks(self.max_length) { - // write the line ending after every chunk, except the last one - if !out.is_empty() { - out.extend_from_slice(ending); - } - - out.extend_from_slice(chunk); - } - - out - } -} - -/// Binary codec -/// -struct BinaryCodec; - -impl BinaryCodec { - pub fn new() -> Self { - BinaryCodec - } -} - -impl EncoderCodec for BinaryCodec { - fn encode(&mut self, input: &[u8]) -> Vec { - input.into() - } -} - -pub fn codec(encoding: Option<&ContentTransferEncoding>) -> Box { - use self::ContentTransferEncoding::*; - - match encoding { - Some(SevenBit) => Box::new(SevenBitCodec::new()), - Some(QuotedPrintable) => Box::new(QuotedPrintableCodec::new()), - Some(Base64) => Box::new(Base64Codec::new()), - Some(EightBit) => Box::new(EightBitCodec::new()), - Some(Binary) | None => Box::new(BinaryCodec::new()), - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn seven_bit_encode() { - let mut c = SevenBitCodec::new(); - - assert_eq!( - &String::from_utf8(c.encode(b"Hello, world!")).unwrap(), - "Hello, world!" - ); - } - - #[test] - #[should_panic] - fn seven_bit_encode_panic() { - let mut c = SevenBitCodec::new(); - c.encode("Hello, мир!".as_bytes()); - } - - #[test] - fn quoted_printable_encode() { - let mut c = QuotedPrintableCodec::new(); - - assert_eq!( - &String::from_utf8(c.encode("Привет, мир!".as_bytes())).unwrap(), - "=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82, =D0=BC=D0=B8=D1=80!" - ); - - assert_eq!(&String::from_utf8(c.encode("Текст письма в уникоде".as_bytes())).unwrap(), - "=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=D1=83=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5"); - } - - #[test] - fn base64_encode() { - let mut c = Base64Codec::new(); - - assert_eq!( - &String::from_utf8(c.encode("Привет, мир!".as_bytes())).unwrap(), - "0J/RgNC40LLQtdGCLCDQvNC40YAh" - ); - - assert_eq!( - &String::from_utf8(c.encode("Текст письма в уникоде подлиннее.".as_bytes())).unwrap(), - concat!( - "0KLQtdC60YHRgiDQv9C40YHRjNC80LAg0LIg0YPQvdC40LrQ", - "vtC00LUg0L/QvtC00LvQuNC90L3Q\r\ntdC1Lg==" - ) - ); - - assert_eq!( - &String::from_utf8(c.encode( - "Ну прямо супер-длинный текст письма в уникоде, который уж точно ну никак не поместиться в 78 байт, как ни крути, я гарантирую.".as_bytes() - )).unwrap(), - - concat!("0J3RgyDQv9GA0Y/QvNC+INGB0YPQv9C10YAt0LTQu9C40L3QvdGL0Lkg0YLQtdC60YHRgiDQv9C4\r\n", - "0YHRjNC80LAg0LIg0YPQvdC40LrQvtC00LUsINC60L7RgtC+0YDRi9C5INGD0LYg0YLQvtGH0L3Q\r\n", - "viDQvdGDINC90LjQutCw0Log0L3QtSDQv9C+0LzQtdGB0YLQuNGC0YzRgdGPINCyIDc4INCx0LDQ\r\n", - "udGCLCDQutCw0Log0L3QuCDQutGA0YPRgtC4LCDRjyDQs9Cw0YDQsNC90YLQuNGA0YPRji4=") - ); - assert_eq!( - &String::from_utf8(c.encode( - "Ну прямо супер-длинный текст письма в уникоде, который уж точно ну никак не поместиться в 78 байт, как ни крути, я гарантирую это.".as_bytes() - )).unwrap(), - - concat!("0J3RgyDQv9GA0Y/QvNC+INGB0YPQv9C10YAt0LTQu9C40L3QvdGL0Lkg0YLQtdC60YHRgiDQv9C4\r\n", - "0YHRjNC80LAg0LIg0YPQvdC40LrQvtC00LUsINC60L7RgtC+0YDRi9C5INGD0LYg0YLQvtGH0L3Q\r\n", - "viDQvdGDINC90LjQutCw0Log0L3QtSDQv9C+0LzQtdGB0YLQuNGC0YzRgdGPINCyIDc4INCx0LDQ\r\n", - "udGCLCDQutCw0Log0L3QuCDQutGA0YPRgtC4LCDRjyDQs9Cw0YDQsNC90YLQuNGA0YPRjiDRjdGC\r\n", - "0L4u") - ); - } - - #[test] - fn base64_encodeed() { - let mut c = Base64Codec::new(); - - assert_eq!(&String::from_utf8(c.encode(b"Chunk.")).unwrap(), "Q2h1bmsu"); - } - - #[test] - fn eight_bit_encode() { - let mut c = EightBitCodec::new(); - - assert_eq!( - &String::from_utf8(c.encode(b"Hello, world!")).unwrap(), - "Hello, world!" - ); - - assert_eq!( - &String::from_utf8(c.encode("Hello, мир!".as_bytes())).unwrap(), - "Hello, мир!" - ); - } - - #[test] - fn binary_encode() { - let mut c = BinaryCodec::new(); - - assert_eq!( - &String::from_utf8(c.encode(b"Hello, world!")).unwrap(), - "Hello, world!" - ); - - assert_eq!( - &String::from_utf8(c.encode("Hello, мир!".as_bytes())).unwrap(), - "Hello, мир!" - ); - } -} diff --git a/src/message/mimebody.rs b/src/message/mimebody.rs index 0832aa3..814eae2 100644 --- a/src/message/mimebody.rs +++ b/src/message/mimebody.rs @@ -1,7 +1,6 @@ use crate::message::{ - encoder::codec, header::{ContentTransferEncoding, ContentType, Header, Headers}, - EmailFormat, + Body, EmailFormat, }; use mime::Mime; use rand::Rng; @@ -69,7 +68,7 @@ impl SinglePartBuilder { } /// Build singlepart using body - pub fn body>>(self, body: T) -> SinglePart { + pub fn body>(self, body: T) -> SinglePart { SinglePart { headers: self.headers, body: body.into(), @@ -94,8 +93,7 @@ impl Default for SinglePartBuilder { /// # fn main() -> Result<(), Box> { /// let part = SinglePart::builder() /// .header(header::ContentType("text/plain; charset=utf8".parse()?)) -/// .header(header::ContentTransferEncoding::Binary) -/// .body("Текст письма в уникоде"); +/// .body(String::from("Текст письма в уникоде")); /// # Ok(()) /// # } /// ``` @@ -103,61 +101,84 @@ impl Default for SinglePartBuilder { #[derive(Debug, Clone)] pub struct SinglePart { headers: Headers, - body: Vec, + body: Body, } impl SinglePart { - /// Creates a default builder for singlepart + /// Creates a builder for singlepart + #[inline] pub fn builder() -> SinglePartBuilder { SinglePartBuilder::new() } - /// Creates a singlepart builder with 7bit encoding + /// Creates a singlepart builder using 7bit encoding /// - /// Shortcut for `SinglePart::builder().header(ContentTransferEncoding::SevenBit)`. + /// 7bit encoding is the most efficient encoding available, + /// but it requires the body only to consist of US-ASCII characters, + /// with no lines longer than 1000 characters. + /// + /// When in doubt use [`SinglePart::builder`] instead, which + /// chooses the best encoding based on the body. + /// + /// # Panics + /// + /// The above conditions are checked when the Body gets encoded + #[inline] pub fn seven_bit() -> SinglePartBuilder { Self::builder().header(ContentTransferEncoding::SevenBit) } - /// Creates a singlepart builder with quoted-printable encoding + /// Creates a singlepart builder using quoted-printable encoding /// - /// Shortcut for `SinglePart::builder().header(ContentTransferEncoding::QuotedPrintable)`. + /// quoted-printable encoding should be used when the body may contain + /// lines longer than 1000 characters or a few non US-ASCII characters may be present. + /// If the body contains many non US-ASCII characters, [`SinglePart::base64`] + /// should be used instead, since it creates a smaller output compared + /// to quoted-printable. + /// + /// When in doubt use [`SinglePart::builder`] instead, which + /// chooses the best encoding based on the body. + #[inline] pub fn quoted_printable() -> SinglePartBuilder { Self::builder().header(ContentTransferEncoding::QuotedPrintable) } - /// Creates a singlepart builder with base64 encoding + /// Creates a singlepart builder using base64 encoding /// - /// Shortcut for `SinglePart::builder().header(ContentTransferEncoding::Base64)`. + /// base64 encoding must be used when the body is composed of mainly + /// non-text data. + /// + /// When in doubt use [`SinglePart::builder`] instead, which + /// chooses the best encoding based on the body. + #[inline] pub fn base64() -> SinglePartBuilder { Self::builder().header(ContentTransferEncoding::Base64) } - /// Creates a singlepart builder with 8-bit encoding + /// Creates a singlepart builder using 8bit encoding /// - /// Shortcut for `SinglePart::builder().header(ContentTransferEncoding::EightBit)`. + /// Some SMTP servers might not support 8bit bodies. + /// + /// When in doubt use [`SinglePart::builder`] instead, which + /// chooses the best encoding based on the body. + #[inline] pub fn eight_bit() -> SinglePartBuilder { Self::builder().header(ContentTransferEncoding::EightBit) } - /// Creates a singlepart builder with binary encoding - /// - /// Shortcut for `SinglePart::builder().header(ContentTransferEncoding::Binary)`. - pub fn binary() -> SinglePartBuilder { - Self::builder().header(ContentTransferEncoding::Binary) - } - /// Get the headers from singlepart + #[inline] pub fn headers(&self) -> &Headers { &self.headers } /// Read the body from singlepart - pub fn body_ref(&self) -> &[u8] { + #[inline] + pub fn body(&self) -> &Body { &self.body } - /// Get message content formatted for SMTP + /// Get message content formatted for sending pub fn formatted(&self) -> Vec { let mut out = Vec::new(); self.format(&mut out); @@ -170,10 +191,14 @@ impl EmailFormat for SinglePart { out.extend_from_slice(self.headers.to_string().as_bytes()); out.extend_from_slice(b"\r\n"); - let encoding = self.headers.get::(); - let mut encoder = codec(encoding); + let encoding = self + .headers + .get::() + .copied() + .unwrap_or_else(|| self.body.encoding()); + let encoded = self.body.encode(encoding); - out.extend_from_slice(&encoder.encode(&self.body)); + out.extend_from_slice(encoded.as_ref().as_ref()); out.extend_from_slice(b"\r\n"); } } diff --git a/src/message/mod.rs b/src/message/mod.rs index e2e1bfb..67640bf 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -59,9 +59,8 @@ //! .singlepart( //! SinglePart::builder() //! .header(header::ContentType( -//! "text/plain; charset=utf8".parse()?, -//! )).header(header::ContentTransferEncoding::QuotedPrintable) -//! .body("Привет, мир!"), +//! "text/plain; charset=utf8".parse()?)) +//! .body(String::from("Привет, мир!")), //! )?; //! # Ok(()) //! # } @@ -101,31 +100,31 @@ //! .multipart( //! MultiPart::alternative() //! .singlepart( -//! SinglePart::quoted_printable() +//! SinglePart::builder() //! .header(header::ContentType("text/plain; charset=utf8".parse()?)) -//! .body("Hello, world! :)") +//! .body(String::from("Hello, world! :)")) //! ) //! .multipart( //! MultiPart::related() //! .singlepart( -//! SinglePart::eight_bit() +//! SinglePart::builder() //! .header(header::ContentType("text/html; charset=utf8".parse()?)) -//! .body("

Hello, world!

") +//! .body(String::from("

Hello, world!

")) //! ) //! .singlepart( -//! SinglePart::base64() +//! SinglePart::builder() //! .header(header::ContentType("image/png".parse()?)) //! .header(header::ContentDisposition { //! disposition: header::DispositionType::Inline, //! parameters: vec![], //! }) //! .header(header::ContentId("<123>".into())) -//! .body("") +//! .body(Vec::::from("")) //! ) //! ) //! ) //! .singlepart( -//! SinglePart::seven_bit() +//! SinglePart::builder() //! .header(header::ContentType("text/plain; charset=utf8".parse()?)) //! .header(header::ContentDisposition { //! disposition: header::DispositionType::Attachment, @@ -136,7 +135,7 @@ //! ) //! ] //! }) -//! .body("int main() { return 0; }") +//! .body(String::from("int main() { return 0; }")) //! ) //! )?; //! # Ok(()) @@ -185,12 +184,13 @@ //! //! ``` +pub use body::Body; pub use mailbox::*; pub use mimebody::*; pub use mime; -mod encoder; +mod body; pub mod header; mod mailbox; mod mimebody; @@ -375,7 +375,7 @@ impl MessageBuilder { // TODO: High-level methods for attachments and embedded files /// Create message from body - fn build(self, body: Body) -> Result { + fn build(self, body: MessageBody) -> Result { // Check for missing required headers // https://tools.ietf.org/html/rfc5322#section-3.6 @@ -412,30 +412,31 @@ impl MessageBuilder { // In theory having a body is optional - /// Plain ASCII body + /// Plain US-ASCII body + /// + /// Fails if is contains non ASCII characters or if it + /// contains lines longer than 1000 characters, including + /// the `\n` character. /// /// *WARNING*: Generally not what you want pub fn body>(self, body: T) -> Result { - // 998 chars by line - // CR and LF MUST only occur together as CRLF; they MUST NOT appear - // independently in the body. let body = body.into(); - if !&body.is_ascii() { + if !self::body::is_7bit_encoded(body.as_ref()) { return Err(EmailError::NonAsciiChars); } - self.build(Body::Raw(body)) + self.build(MessageBody::Raw(body)) } /// Create message using mime body ([`MultiPart`][self::MultiPart]) pub fn multipart(self, part: MultiPart) -> Result { - self.mime_1_0().build(Body::Mime(Part::Multi(part))) + self.mime_1_0().build(MessageBody::Mime(Part::Multi(part))) } /// Create message using mime body ([`SinglePart`][self::SinglePart]) pub fn singlepart(self, part: SinglePart) -> Result { - self.mime_1_0().build(Body::Mime(Part::Single(part))) + self.mime_1_0().build(MessageBody::Mime(Part::Single(part))) } } @@ -443,12 +444,12 @@ impl MessageBuilder { #[derive(Clone, Debug)] pub struct Message { headers: Headers, - body: Body, + body: MessageBody, envelope: Envelope, } #[derive(Clone, Debug)] -enum Body { +enum MessageBody { Mime(Part), Raw(String), } @@ -481,8 +482,8 @@ impl EmailFormat for Message { fn format(&self, out: &mut Vec) { out.extend_from_slice(self.headers.to_string().as_bytes()); match &self.body { - Body::Mime(p) => p.format(out), - Body::Raw(r) => { + MessageBody::Mime(p) => p.format(out), + MessageBody::Raw(r) => { out.extend_from_slice(b"\r\n"); out.extend(r.as_bytes()) } @@ -573,7 +574,9 @@ mod test { .header(header::ContentType( "text/html; charset=utf8".parse().unwrap(), )) - .body("

Hello, world!

"), + .body(String::from( + "

Hello, world!

", + )), ) .singlepart( SinglePart::base64()