summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorufoscout <ufoscout@gmail.com>2019-05-28 13:16:13 +0200
committerKartikaya Gupta <kats@trevize.staktrace.com>2019-06-04 16:36:21 -0400
commita4a0e917580cf465ffd1aa8ae6723860aab23c6f (patch)
treea80b872fe63634c20d1507723e4d720dab973370
parentb47729baba3af4994a46e101c429e79023b347b2 (diff)
downloadmailparse-a4a0e917580cf465ffd1aa8ae6723860aab23c6f.tar.gz
mailparse-a4a0e917580cf465ffd1aa8ae6723860aab23c6f.tar.xz
Allow access to encoded body
-rw-r--r--.gitattributes1
-rw-r--r--src/body.rs152
-rw-r--r--src/lib.rs205
-rw-r--r--tests/files/test_email_01.txt86
-rw-r--r--tests/files/test_email_01_sample.pdf198
5 files changed, 616 insertions, 26 deletions
diff --git a/.gitattributes b/.gitattributes
index f795e12..36e2df7 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
target/doc/* linguist-generated=true
+tests/files/test_email_01_sample.pdf -text -diff
diff --git a/src/body.rs b/src/body.rs
new file mode 100644
index 0000000..ed1b617
--- /dev/null
+++ b/src/body.rs
@@ -0,0 +1,152 @@
+use charset::{decode_ascii, Charset};
+use {MailParseError, ParsedContentType};
+
+/// Represents the body of an email (or mail subpart)
+pub enum Body<'a> {
+ /// A body with 'base64' Content-Transfer-Encoding.
+ Base64(EncodedBody<'a>),
+ /// A body with 'quoted-printable' Content-Transfer-Encoding.
+ QuotedPrintable(EncodedBody<'a>),
+ /// A body with '7bit' Content-Transfer-Encoding.
+ SevenBit(TextBody<'a>),
+ /// A body with '8bit' Content-Transfer-Encoding.
+ EightBit(TextBody<'a>),
+ /// A body with 'binary' Content-Transfer-Encoding.
+ Binary(BinaryBody<'a>),
+}
+
+impl<'a> Body<'a> {
+ pub fn new(
+ body: &'a [u8],
+ ctype: &'a ParsedContentType,
+ transfer_encoding: &Option<String>,
+ ) -> Body<'a> {
+ transfer_encoding
+ .as_ref()
+ .map(|encoding| match encoding.as_ref() {
+ "base64" => Body::Base64(EncodedBody {
+ decoder: decode_base64,
+ body,
+ ctype,
+ }),
+ "quoted-printable" => Body::QuotedPrintable(EncodedBody {
+ decoder: decode_quoted_printable,
+ body,
+ ctype,
+ }),
+ "7bit" => Body::SevenBit(TextBody { body, ctype }),
+ "8bit" => Body::EightBit(TextBody { body, ctype }),
+ "binary" => Body::Binary(BinaryBody { body, ctype }),
+ _ => Body::get_default(body, ctype),
+ })
+ .unwrap_or_else(|| Body::get_default(body, ctype))
+ }
+
+ fn get_default(body: &'a [u8], ctype: &'a ParsedContentType) -> Body<'a> {
+ Body::SevenBit(TextBody { body, ctype })
+ }
+}
+
+/// Struct that holds the encoded body representation of the message (or message subpart).
+pub struct EncodedBody<'a> {
+ decoder: fn(&[u8]) -> Result<Vec<u8>, MailParseError>,
+ ctype: &'a ParsedContentType,
+ body: &'a [u8],
+}
+
+impl<'a> EncodedBody<'a> {
+ /// Get the body Content-Type
+ pub fn get_content_type(&self) -> &'a ParsedContentType {
+ self.ctype
+ }
+
+ /// Get the raw body of the message exactly as it is written in the message (or message subpart).
+ pub fn get_raw(&self) -> &'a [u8] {
+ self.body
+ }
+
+ /// Get the decoded body of the message (or message subpart).
+ pub fn get_decoded(&self) -> Result<Vec<u8>, MailParseError> {
+ (self.decoder)(self.body)
+ }
+
+ /// Get the body of the message as a Rust string.
+ /// This function tries to decode the body and then converts
+ /// the result into a Rust UTF-8 string using the charset in the Content-Type
+ /// (or "us-ascii" if the charset was missing or not recognized).
+ /// This operation returns a valid result only if the decoded body
+ /// has a text format.
+ pub fn get_decoded_as_string(&self) -> Result<String, MailParseError> {
+ get_body_as_string(&self.get_decoded()?, &self.ctype)
+ }
+}
+
+/// Struct that holds the textual body representation of the message (or message subpart).
+pub struct TextBody<'a> {
+ ctype: &'a ParsedContentType,
+ body: &'a [u8],
+}
+
+impl<'a> TextBody<'a> {
+ /// Get the body Content-Type
+ pub fn get_content_type(&self) -> &'a ParsedContentType {
+ self.ctype
+ }
+
+ /// Get the raw body of the message exactly as it is written in the message (or message subpart).
+ pub fn get_raw(&self) -> &'a [u8] {
+ self.body
+ }
+
+ /// Get the body of the message as a Rust string.
+ /// This function converts the body into a Rust UTF-8 string using the charset
+ /// in the Content-Type
+ /// (or "us-ascii" if the charset was missing or not recognized).
+ pub fn get_as_string(&self) -> Result<String, MailParseError> {
+ get_body_as_string(self.body, &self.ctype)
+ }
+}
+
+/// Struct that holds a binary body representation of the message (or message subpart).
+pub struct BinaryBody<'a> {
+ ctype: &'a ParsedContentType,
+ body: &'a [u8],
+}
+
+impl<'a> BinaryBody<'a> {
+ /// Get the body Content-Type
+ pub fn get_content_type(&self) -> &'a ParsedContentType {
+ self.ctype
+ }
+
+ /// Get the raw body of the message exactly as it is written in the message (or message subpart).
+ pub fn get_raw(&self) -> &'a [u8] {
+ self.body
+ }
+}
+
+fn decode_base64(body: &[u8]) -> Result<Vec<u8>, MailParseError> {
+ let cleaned = body
+ .iter()
+ .filter(|c| !c.is_ascii_whitespace())
+ .cloned()
+ .collect::<Vec<u8>>();
+ Ok(base64::decode(&cleaned)?)
+}
+
+fn decode_quoted_printable(body: &[u8]) -> Result<Vec<u8>, MailParseError> {
+ Ok(quoted_printable::decode(
+ body,
+ quoted_printable::ParseMode::Robust,
+ )?)
+}
+
+fn get_body_as_string(body: &[u8], ctype: &ParsedContentType) -> Result<String, MailParseError> {
+ let cow = if let Some(charset) = Charset::for_label(ctype.charset.as_bytes()) {
+ let (cow, _, _) = charset.decode(body);
+ cow
+ } else {
+ decode_ascii(body)
+ };
+ Ok(cow.into_owned())
+}
diff --git a/src/lib.rs b/src/lib.rs
index c727c58..7af26bc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -7,12 +7,13 @@ use std::error;
use std::fmt;
use std::ops::Deref;
-use charset::decode_ascii;
use charset::decode_latin1;
use charset::Charset;
+pub mod body;
mod dateparse;
+use body::Body;
pub use dateparse::dateparse;
/// An error type that represents the different kinds of errors that may be
@@ -650,14 +651,13 @@ impl<'a> ParsedMail<'a> {
/// assert_eq!(p.get_body().unwrap(), "This is the body");
/// ```
pub fn get_body(&self) -> Result<String, MailParseError> {
- let decoded = self.get_body_raw()?;
- let cow = if let Some(charset) = Charset::for_label(self.ctype.charset.as_bytes()) {
- let (cow, _, _) = charset.decode(&decoded);
- cow
- } else {
- decode_ascii(&decoded)
- };
- Ok(cow.into_owned())
+ match self.get_body_encoded()? {
+ Body::Base64(body) | Body::QuotedPrintable(body) => body.get_decoded_as_string(),
+ Body::SevenBit(body) | Body::EightBit(body) => body.get_as_string(),
+ Body::Binary(_) => Err(MailParseError::Generic(
+ "Message body of type binary body cannot be parsed into a string",
+ )),
+ }
}
/// Get the body of the message as a Rust Vec<u8>. This function tries to
@@ -675,27 +675,59 @@ impl<'a> ParsedMail<'a> {
/// assert_eq!(p.get_body_raw().unwrap(), b"This is the body");
/// ```
pub fn get_body_raw(&self) -> Result<Vec<u8>, MailParseError> {
- let transfer_coding = self
+ match self.get_body_encoded()? {
+ Body::Base64(body) | Body::QuotedPrintable(body) => body.get_decoded(),
+ Body::SevenBit(body) | Body::EightBit(body) => Ok(Vec::<u8>::from(body.get_raw())),
+ Body::Binary(body) => Ok(Vec::<u8>::from(body.get_raw())),
+ }
+ }
+
+ /// Get the body of the message.
+ /// This function returns original the body without attempting to
+ /// unapply the Content-Transfer-Encoding.
+ ///
+ /// # Examples
+ /// ```
+ /// use mailparse::parse_mail;
+ /// use mailparse::body::Body;
+ ///
+ /// let mail = parse_mail(b"Content-Transfer-Encoding: base64\r\n\r\naGVsbG 8gd\r\n29ybGQ=").unwrap();
+ ///
+ /// match mail.get_body_encoded().unwrap() {
+ /// Body::Base64(body) => {
+ /// assert_eq!(body.get_raw(), b"aGVsbG 8gd\r\n29ybGQ=");
+ /// assert_eq!(body.get_decoded().unwrap(), b"hello world");
+ /// assert_eq!(body.get_decoded_as_string().unwrap(), "hello world");
+ /// },
+ /// _ => assert!(false),
+ /// };
+ ///
+ ///
+ /// // An email whose body encoding is not known upfront
+ /// let another_mail = parse_mail(b"").unwrap();
+ ///
+ /// match another_mail.get_body_encoded().unwrap() {
+ /// Body::Base64(body) | Body::QuotedPrintable(body) => {
+ /// println!("mail body encoded: {:?}", body.get_raw());
+ /// println!("mail body decoded: {:?}", body.get_decoded().unwrap());
+ /// println!("mail body decoded as string: {}", body.get_decoded_as_string().unwrap());
+ /// },
+ /// Body::SevenBit(body) | Body::EightBit(body) => {
+ /// println!("mail body: {:?}", body.get_raw());
+ /// println!("mail body as string: {}", body.get_as_string().unwrap());
+ /// },
+ /// Body::Binary(body) => {
+ /// println!("mail body binary: {:?}", body.get_raw());
+ /// }
+ /// }
+ /// ```
+ pub fn get_body_encoded(&'a self) -> Result<Body<'a>, MailParseError> {
+ let transfer_encoding = self
.headers
.get_first_value("Content-Transfer-Encoding")?
.map(|s| s.to_lowercase());
- let decoded = match transfer_coding {
- Some(ref enc) if enc == "base64" => {
- let cleaned = self
- .body
- .iter()
- .filter(|c| !c.is_ascii_whitespace())
- .cloned()
- .collect::<Vec<u8>>();
- base64::decode(&cleaned)?
- }
- Some(ref enc) if enc == "quoted-printable" => {
- quoted_printable::decode(self.body, quoted_printable::ParseMode::Robust)?
- }
- _ => Vec::<u8>::from(self.body),
- };
- Ok(decoded)
+ Ok(Body::new(self.body, &self.ctype, &transfer_encoding))
}
/// Returns a struct containing a parsed representation of the
@@ -1265,4 +1297,125 @@ mod tests {
let parsed = parse_param_content(r#"Content-Type: application/octet-stream; name=""#);
assert_eq!(parsed.params["name"], "\"");
}
+
+ #[test]
+ fn test_default_content_encoding() {
+ let mail = parse_mail(b"Content-Type: text/plain; charset=UTF-7\r\n\r\n+JgM-").unwrap();
+ let body = mail.get_body_encoded().unwrap();
+ match body {
+ Body::SevenBit(body) => {
+ assert_eq!(body.get_raw(), b"+JgM-");
+ assert_eq!(body.get_as_string().unwrap(), "\u{2603}");
+ }
+ _ => assert!(false),
+ };
+ }
+
+ #[test]
+ fn test_7bit_content_encoding() {
+ let mail = parse_mail(b"Content-Type: text/plain; charset=UTF-7\r\nContent-Transfer-Encoding: 7bit\r\n\r\n+JgM-").unwrap();
+ let body = mail.get_body_encoded().unwrap();
+ match body {
+ Body::SevenBit(body) => {
+ assert_eq!(body.get_raw(), b"+JgM-");
+ assert_eq!(body.get_as_string().unwrap(), "\u{2603}");
+ }
+ _ => assert!(false),
+ };
+ }
+
+ #[test]
+ fn test_8bit_content_encoding() {
+ let mail = parse_mail(b"Content-Type: text/plain; charset=UTF-7\r\nContent-Transfer-Encoding: 8bit\r\n\r\n+JgM-").unwrap();
+ let body = mail.get_body_encoded().unwrap();
+ match body {
+ Body::EightBit(body) => {
+ assert_eq!(body.get_raw(), b"+JgM-");
+ assert_eq!(body.get_as_string().unwrap(), "\u{2603}");
+ }
+ _ => assert!(false),
+ };
+ }
+
+ #[test]
+ fn test_quoted_printable_content_encoding() {
+ let mail = parse_mail(
+ b"Content-Type: text/plain; charset=UTF-7\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n+JgM-",
+ ).unwrap();
+ match mail.get_body_encoded().unwrap() {
+ Body::QuotedPrintable(body) => {
+ assert_eq!(body.get_raw(), b"+JgM-");
+ assert_eq!(body.get_decoded().unwrap(), b"+JgM-");
+ assert_eq!(body.get_decoded_as_string().unwrap(), "\u{2603}");
+ }
+ _ => assert!(false),
+ };
+ }
+
+ #[test]
+ fn test_base64_content_encoding() {
+ let mail =
+ parse_mail(b"Content-Transfer-Encoding: base64\r\n\r\naGVsbG 8gd\r\n29ybGQ=").unwrap();
+ match mail.get_body_encoded().unwrap() {
+ Body::Base64(body) => {
+ assert_eq!(body.get_raw(), b"aGVsbG 8gd\r\n29ybGQ=");
+ assert_eq!(body.get_decoded().unwrap(), b"hello world");
+ assert_eq!(body.get_decoded_as_string().unwrap(), "hello world");
+ }
+ _ => assert!(false),
+ };
+ }
+
+ #[test]
+ fn test_binary_content_encoding() {
+ let mail = parse_mail(b"Content-Transfer-Encoding: binary\r\n\r\n######").unwrap();
+ let body = mail.get_body_encoded().unwrap();
+ match body {
+ Body::Binary(body) => {
+ assert_eq!(body.get_raw(), b"######");
+ }
+ _ => assert!(false),
+ };
+ }
+
+ #[test]
+ fn test_body_content_encoding_with_multipart() {
+ let mail_filepath = "./tests/files/test_email_01.txt";
+ let mail = std::fs::read(mail_filepath)
+ .expect(&format!("Unable to open the file [{}]", mail_filepath));
+ let mail = parse_mail(&mail).unwrap();
+
+ let subpart_0 = mail.subparts.get(0).unwrap();
+ match subpart_0.get_body_encoded().unwrap() {
+ Body::SevenBit(body) => {
+ assert_eq!(
+ body.get_as_string().unwrap().trim(),
+ "<html>Test with attachments</html>"
+ );
+ }
+ _ => assert!(false),
+ };
+
+ let subpart_1 = mail.subparts.get(1).unwrap();
+ match subpart_1.get_body_encoded().unwrap() {
+ Body::Base64(body) => {
+ let pdf_filepath = "./tests/files/test_email_01_sample.pdf";
+ let original_pdf = std::fs::read(pdf_filepath)
+ .expect(&format!("Unable to open the file [{}]", pdf_filepath));
+ assert_eq!(body.get_decoded().unwrap(), original_pdf);
+ }
+ _ => assert!(false),
+ };
+
+ let subpart_2 = mail.subparts.get(2).unwrap();
+ match subpart_2.get_body_encoded().unwrap() {
+ Body::Base64(body) => {
+ assert_eq!(
+ body.get_decoded_as_string().unwrap(),
+ "txt file context for email collector\n1234567890987654321\n"
+ );
+ }
+ _ => assert!(false),
+ };
+ }
}
diff --git a/tests/files/test_email_01.txt b/tests/files/test_email_01.txt
new file mode 100644
index 0000000..286c089
--- /dev/null
+++ b/tests/files/test_email_01.txt
@@ -0,0 +1,86 @@
+Subject: Test with attachments
+Content-Type: multipart/mixed;
+ boundary="------------E5401F4DD68F2F7A872C2A83"
+Content-Language: en-US
+
+This is a multi-part message in MIME format.
+--------------E5401F4DD68F2F7A872C2A83
+Content-Type: text/html; charset=utf-8
+Content-Transfer-Encoding: 7bit
+
+<html>Test with attachments</html>
+
+--------------E5401F4DD68F2F7A872C2A83
+Content-Type: application/pdf;
+ name="sample.pdf"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="sample.pdf"
+
+JVBERi0xLjMNCiXi48/TDQoNCjEgMCBvYmoNCjw8DQovVHlwZSAvQ2F0YWxvZw0KL091dGxp
+bmVzIDIgMCBSDQovUGFnZXMgMyAwIFINCj4+DQplbmRvYmoNCg0KMiAwIG9iag0KPDwNCi9U
+eXBlIC9PdXRsaW5lcw0KL0NvdW50IDANCj4+DQplbmRvYmoNCg0KMyAwIG9iag0KPDwNCi9U
+eXBlIC9QYWdlcw0KL0NvdW50IDINCi9LaWRzIFsgNCAwIFIgNiAwIFIgXSANCj4+DQplbmRv
+YmoNCg0KNCAwIG9iag0KPDwNCi9UeXBlIC9QYWdlDQovUGFyZW50IDMgMCBSDQovUmVzb3Vy
+Y2VzIDw8DQovRm9udCA8PA0KL0YxIDkgMCBSIA0KPj4NCi9Qcm9jU2V0IDggMCBSDQo+Pg0K
+L01lZGlhQm94IFswIDAgNjEyLjAwMDAgNzkyLjAwMDBdDQovQ29udGVudHMgNSAwIFINCj4+
+DQplbmRvYmoNCg0KNSAwIG9iag0KPDwgL0xlbmd0aCAxMDc0ID4+DQpzdHJlYW0NCjIgSg0K
+QlQNCjAgMCAwIHJnDQovRjEgMDAyNyBUZg0KNTcuMzc1MCA3MjIuMjgwMCBUZA0KKCBBIFNp
+bXBsZSBQREYgRmlsZSApIFRqDQpFVA0KQlQNCi9GMSAwMDEwIFRmDQo2OS4yNTAwIDY4OC42
+MDgwIFRkDQooIFRoaXMgaXMgYSBzbWFsbCBkZW1vbnN0cmF0aW9uIC5wZGYgZmlsZSAtICkg
+VGoNCkVUDQpCVA0KL0YxIDAwMTAgVGYNCjY5LjI1MDAgNjY0LjcwNDAgVGQNCigganVzdCBm
+b3IgdXNlIGluIHRoZSBWaXJ0dWFsIE1lY2hhbmljcyB0dXRvcmlhbHMuIE1vcmUgdGV4dC4g
+QW5kIG1vcmUgKSBUag0KRVQNCkJUDQovRjEgMDAxMCBUZg0KNjkuMjUwMCA2NTIuNzUyMCBU
+ZA0KKCB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0
+LiApIFRqDQpFVA0KQlQNCi9GMSAwMDEwIFRmDQo2OS4yNTAwIDYyOC44NDgwIFRkDQooIEFu
+ZCBtb3JlIHRleHQuIEFuZCBtb3JlIHRleHQuIEFuZCBtb3JlIHRleHQuIEFuZCBtb3JlIHRl
+eHQuIEFuZCBtb3JlICkgVGoNCkVUDQpCVA0KL0YxIDAwMTAgVGYNCjY5LjI1MDAgNjE2Ljg5
+NjAgVGQNCiggdGV4dC4gQW5kIG1vcmUgdGV4dC4gQm9yaW5nLCB6enp6ei4gQW5kIG1vcmUg
+dGV4dC4gQW5kIG1vcmUgdGV4dC4gQW5kICkgVGoNCkVUDQpCVA0KL0YxIDAwMTAgVGYNCjY5
+LjI1MDAgNjA0Ljk0NDAgVGQNCiggbW9yZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9y
+ZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiApIFRqDQpFVA0KQlQNCi9G
+MSAwMDEwIFRmDQo2OS4yNTAwIDU5Mi45OTIwIFRkDQooIEFuZCBtb3JlIHRleHQuIEFuZCBt
+b3JlIHRleHQuICkgVGoNCkVUDQpCVA0KL0YxIDAwMTAgVGYNCjY5LjI1MDAgNTY5LjA4ODAg
+VGQNCiggQW5kIG1vcmUgdGV4dC4gQW5kIG1vcmUgdGV4dC4gQW5kIG1vcmUgdGV4dC4gQW5k
+IG1vcmUgdGV4dC4gQW5kIG1vcmUgKSBUag0KRVQNCkJUDQovRjEgMDAxMCBUZg0KNjkuMjUw
+MCA1NTcuMTM2MCBUZA0KKCB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiBF
+dmVuIG1vcmUuIENvbnRpbnVlZCBvbiBwYWdlIDIgLi4uKSBUag0KRVQNCmVuZHN0cmVhbQ0K
+ZW5kb2JqDQoNCjYgMCBvYmoNCjw8DQovVHlwZSAvUGFnZQ0KL1BhcmVudCAzIDAgUg0KL1Jl
+c291cmNlcyA8PA0KL0ZvbnQgPDwNCi9GMSA5IDAgUiANCj4+DQovUHJvY1NldCA4IDAgUg0K
+Pj4NCi9NZWRpYUJveCBbMCAwIDYxMi4wMDAwIDc5Mi4wMDAwXQ0KL0NvbnRlbnRzIDcgMCBS
+DQo+Pg0KZW5kb2JqDQoNCjcgMCBvYmoNCjw8IC9MZW5ndGggNjc2ID4+DQpzdHJlYW0NCjIg
+Sg0KQlQNCjAgMCAwIHJnDQovRjEgMDAyNyBUZg0KNTcuMzc1MCA3MjIuMjgwMCBUZA0KKCBT
+aW1wbGUgUERGIEZpbGUgMiApIFRqDQpFVA0KQlQNCi9GMSAwMDEwIFRmDQo2OS4yNTAwIDY4
+OC42MDgwIFRkDQooIC4uLmNvbnRpbnVlZCBmcm9tIHBhZ2UgMS4gWWV0IG1vcmUgdGV4dC4g
+QW5kIG1vcmUgdGV4dC4gQW5kIG1vcmUgdGV4dC4gKSBUag0KRVQNCkJUDQovRjEgMDAxMCBU
+Zg0KNjkuMjUwMCA2NzYuNjU2MCBUZA0KKCBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0
+LiBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9yZSB0ZXh0LiBBbmQgbW9yZSApIFRqDQpFVA0KQlQN
+Ci9GMSAwMDEwIFRmDQo2OS4yNTAwIDY2NC43MDQwIFRkDQooIHRleHQuIE9oLCBob3cgYm9y
+aW5nIHR5cGluZyB0aGlzIHN0dWZmLiBCdXQgbm90IGFzIGJvcmluZyBhcyB3YXRjaGluZyAp
+IFRqDQpFVA0KQlQNCi9GMSAwMDEwIFRmDQo2OS4yNTAwIDY1Mi43NTIwIFRkDQooIHBhaW50
+IGRyeS4gQW5kIG1vcmUgdGV4dC4gQW5kIG1vcmUgdGV4dC4gQW5kIG1vcmUgdGV4dC4gQW5k
+IG1vcmUgdGV4dC4gKSBUag0KRVQNCkJUDQovRjEgMDAxMCBUZg0KNjkuMjUwMCA2NDAuODAw
+MCBUZA0KKCBCb3JpbmcuICBNb3JlLCBhIGxpdHRsZSBtb3JlIHRleHQuIFRoZSBlbmQsIGFu
+ZCBqdXN0IGFzIHdlbGwuICkgVGoNCkVUDQplbmRzdHJlYW0NCmVuZG9iag0KDQo4IDAgb2Jq
+DQpbL1BERiAvVGV4dF0NCmVuZG9iag0KDQo5IDAgb2JqDQo8PA0KL1R5cGUgL0ZvbnQNCi9T
+dWJ0eXBlIC9UeXBlMQ0KL05hbWUgL0YxDQovQmFzZUZvbnQgL0hlbHZldGljYQ0KL0VuY29k
+aW5nIC9XaW5BbnNpRW5jb2RpbmcNCj4+DQplbmRvYmoNCg0KMTAgMCBvYmoNCjw8DQovQ3Jl
+YXRvciAoUmF2ZSBcKGh0dHA6Ly93d3cubmV2cm9uYS5jb20vcmF2ZVwpKQ0KL1Byb2R1Y2Vy
+IChOZXZyb25hIERlc2lnbnMpDQovQ3JlYXRpb25EYXRlIChEOjIwMDYwMzAxMDcyODI2KQ0K
+Pj4NCmVuZG9iag0KDQp4cmVmDQowIDExDQowMDAwMDAwMDAwIDY1NTM1IGYNCjAwMDAwMDAw
+MTkgMDAwMDAgbg0KMDAwMDAwMDA5MyAwMDAwMCBuDQowMDAwMDAwMTQ3IDAwMDAwIG4NCjAw
+MDAwMDAyMjIgMDAwMDAgbg0KMDAwMDAwMDM5MCAwMDAwMCBuDQowMDAwMDAxNTIyIDAwMDAw
+IG4NCjAwMDAwMDE2OTAgMDAwMDAgbg0KMDAwMDAwMjQyMyAwMDAwMCBuDQowMDAwMDAyNDU2
+IDAwMDAwIG4NCjAwMDAwMDI1NzQgMDAwMDAgbg0KDQp0cmFpbGVyDQo8PA0KL1NpemUgMTEN
+Ci9Sb290IDEgMCBSDQovSW5mbyAxMCAwIFINCj4+DQoNCnN0YXJ0eHJlZg0KMjcxNA0KJSVF
+T0YNCg==
+--------------E5401F4DD68F2F7A872C2A83
+Content-Type: text/plain; charset=UTF-8;
+ name="sample.txt"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="sample.txt"
+
+dHh0IGZpbGUgY29udGV4dCBmb3IgZW1haWwgY29sbGVjdG9yCjEyMzQ1Njc4OTA5ODc2NTQz
+MjEK
+--------------E5401F4DD68F2F7A872C2A83--
diff --git a/tests/files/test_email_01_sample.pdf b/tests/files/test_email_01_sample.pdf
new file mode 100644
index 0000000..dbf091d
--- /dev/null
+++ b/tests/files/test_email_01_sample.pdf
@@ -0,0 +1,198 @@
+%PDF-1.3
+%
+
+1 0 obj
+<<
+/Type /Catalog
+/Outlines 2 0 R
+/Pages 3 0 R
+>>
+endobj
+
+2 0 obj
+<<
+/Type /Outlines
+/Count 0
+>>
+endobj
+
+3 0 obj
+<<
+/Type /Pages
+/Count 2
+/Kids [ 4 0 R 6 0 R ]
+>>
+endobj
+
+4 0 obj
+<<
+/Type /Page
+/Parent 3 0 R
+/Resources <<
+/Font <<
+/F1 9 0 R
+>>
+/ProcSet 8 0 R
+>>
+/MediaBox [0 0 612.0000 792.0000]
+/Contents 5 0 R
+>>
+endobj
+
+5 0 obj
+<< /Length 1074 >>
+stream
+2 J
+BT
+0 0 0 rg
+/F1 0027 Tf
+57.3750 722.2800 Td
+( A Simple PDF File ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 688.6080 Td
+( This is a small demonstration .pdf file - ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 664.7040 Td
+( just for use in the Virtual Mechanics tutorials. More text. And more ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 652.7520 Td
+( text. And more text. And more text. And more text. ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 628.8480 Td
+( And more text. And more text. And more text. And more text. And more ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 616.8960 Td
+( text. And more text. Boring, zzzzz. And more text. And more text. And ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 604.9440 Td
+( more text. And more text. And more text. And more text. And more text. ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 592.9920 Td
+( And more text. And more text. ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 569.0880 Td
+( And more text. And more text. And more text. And more text. And more ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 557.1360 Td
+( text. And more text. And more text. Even more. Continued on page 2 ...) Tj
+ET
+endstream
+endobj
+
+6 0 obj
+<<
+/Type /Page
+/Parent 3 0 R
+/Resources <<
+/Font <<
+/F1 9 0 R
+>>
+/ProcSet 8 0 R
+>>
+/MediaBox [0 0 612.0000 792.0000]
+/Contents 7 0 R
+>>
+endobj
+
+7 0 obj
+<< /Length 676 >>
+stream
+2 J
+BT
+0 0 0 rg
+/F1 0027 Tf
+57.3750 722.2800 Td
+( Simple PDF File 2 ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 688.6080 Td
+( ...continued from page 1. Yet more text. And more text. And more text. ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 676.6560 Td
+( And more text. And more text. And more text. And more text. And more ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 664.7040 Td
+( text. Oh, how boring typing this stuff. But not as boring as watching ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 652.7520 Td
+( paint dry. And more text. And more text. And more text. And more text. ) Tj
+ET
+BT
+/F1 0010 Tf
+69.2500 640.8000 Td
+( Boring. More, a little more text. The end, and just as well. ) Tj
+ET
+endstream
+endobj
+
+8 0 obj
+[/PDF /Text]
+endobj
+
+9 0 obj
+<<
+/Type /Font
+/Subtype /Type1
+/Name /F1
+/BaseFont /Helvetica
+/Encoding /WinAnsiEncoding
+>>
+endobj
+
+10 0 obj
+<<
+/Creator (Rave \(http://www.nevrona.com/rave\))
+/Producer (Nevrona Designs)
+/CreationDate (D:20060301072826)
+>>
+endobj
+
+xref
+0 11
+0000000000 65535 f
+0000000019 00000 n
+0000000093 00000 n
+0000000147 00000 n
+0000000222 00000 n
+0000000390 00000 n
+0000001522 00000 n
+0000001690 00000 n
+0000002423 00000 n
+0000002456 00000 n
+0000002574 00000 n
+
+trailer
+<<
+/Size 11
+/Root 1 0 R
+/Info 10 0 R
+>>
+
+startxref
+2714
+%%EOF