diff --git a/crates/mailparsing/src/builder.rs b/crates/mailparsing/src/builder.rs index 4a695066..7cce570a 100644 --- a/crates/mailparsing/src/builder.rs +++ b/crates/mailparsing/src/builder.rs @@ -191,6 +191,52 @@ Content-Transfer-Encoding: quoted-printable\r this is html =F0=9F=9A=80\r --ma-boundary--\r +"# + ); + } + + #[test] + fn utf8_attachment_name() { + let mut b = MessageBuilder::new(); + b.set_stable_content(true); + b.set_subject("Hello there! 🍉").unwrap(); + b.text_plain("This is the body! 👻"); + b.attach( + "text/plain", + b"hello", + Some(&AttachmentOptions { + content_id: None, + file_name: Some("日本語の添付.txt".to_string()), + inline: false, + }), + ) + .unwrap(); + let msg = b.build().unwrap(); + k9::snapshot!( + msg.to_message_string(), + r#" +Content-Type: multipart/mixed;\r +\tboundary="mm-boundary"\r +Subject: =?UTF-8?q?Hello_there!_=F0=9F=8D=89?=\r +Mime-Version: 1.0\r +Date: Tue, 1 Jul 2003 10:52:37 +0200\r +\r +--mm-boundary\r +Content-Type: text/plain;\r +\tcharset="utf-8"\r +Content-Transfer-Encoding: quoted-printable\r +\r +This is the body! =F0=9F=91=BB\r +--mm-boundary\r +Content-Type: text/plain\r +Content-Transfer-Encoding: base64\r +Content-Disposition: attachment;\r +\tfilename*0*=UTF-8''%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E6%B7%BB%E4%BB%98.;\r +\tfilename*1*=txt\r +\r +aGVsbG8=\r +--mm-boundary--\r + "# ); } diff --git a/crates/mailparsing/src/header.rs b/crates/mailparsing/src/header.rs index d5220fb5..9fcfa714 100644 --- a/crates/mailparsing/src/header.rs +++ b/crates/mailparsing/src/header.rs @@ -10,7 +10,7 @@ use std::str::FromStr; bitflags::bitflags! { #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] - pub struct MessageConformance: u8 { + pub struct MessageConformance: u16 { const MISSING_COLON_VALUE = 0b0000_0001; const NON_CANONICAL_LINE_ENDINGS = 0b0000_0010; const NAME_ENDS_WITH_SPACE = 0b0000_0100; @@ -19,6 +19,7 @@ bitflags::bitflags! { const MISSING_DATE_HEADER = 0b0010_0000; const MISSING_MESSAGE_ID_HEADER = 0b0100_0000; const MISSING_MIME_VERSION = 0b1000_0000; + const INVALID_MIME_HEADERS = 0b0001_0000_0000; } } @@ -779,6 +780,7 @@ Some( k9::assert_equal!( MessageConformance::from_str("LINE_TOO_LONG|spoon").unwrap_err(), "invalid MessageConformance flag 'spoon', possible values are \ + 'INVALID_MIME_HEADERS', \ 'LINE_TOO_LONG', 'MISSING_COLON_VALUE', 'MISSING_DATE_HEADER', \ 'MISSING_MESSAGE_ID_HEADER', 'MISSING_MIME_VERSION', 'NAME_ENDS_WITH_SPACE', \ 'NEEDS_TRANSFER_ENCODING', 'NON_CANONICAL_LINE_ENDINGS'" diff --git a/crates/mailparsing/src/mimepart.rs b/crates/mailparsing/src/mimepart.rs index 473e8448..766b3ca1 100644 --- a/crates/mailparsing/src/mimepart.rs +++ b/crates/mailparsing/src/mimepart.rs @@ -38,23 +38,42 @@ pub struct MimePart<'a> { pub struct Rfc2045Info { pub encoding: ContentTransferEncoding, - pub charset: Charset, + pub charset: Result, pub content_type: Option, pub is_text: bool, pub is_multipart: bool, pub attachment_options: Option, + pub invalid_mime_headers: bool, } impl Rfc2045Info { - fn new(headers: &HeaderMap) -> Result { - let content_transfer_encoding = headers.content_transfer_encoding()?; + // This must be infallible so that a basic mime structure can be parsed + // even if the mime headers are a bit borked + fn new(headers: &HeaderMap) -> Self { + let mut invalid_mime_headers = false; + let encoding = match headers.content_transfer_encoding() { + Ok(Some(cte)) => match ContentTransferEncoding::from_str(&cte.value) { + Ok(encoding) => encoding, + Err(_) => { + invalid_mime_headers = true; + ContentTransferEncoding::SevenBit + } + }, + Ok(None) => ContentTransferEncoding::SevenBit, + Err(_) => { + invalid_mime_headers = true; + ContentTransferEncoding::SevenBit + } + }; - let encoding = match &content_transfer_encoding { - Some(cte) => ContentTransferEncoding::from_str(&cte.value)?, - None => ContentTransferEncoding::SevenBit, + let content_type = match headers.content_type() { + Ok(ct) => ct, + Err(_) => { + invalid_mime_headers = true; + None + } }; - let content_type = headers.content_type()?; let charset = if let Some(ct) = &content_type { ct.get("charset") } else { @@ -63,7 +82,7 @@ impl Rfc2045Info { let charset = charset.unwrap_or_else(|| "us-ascii".to_string()); let charset = Charset::for_label_no_replacement(charset.as_bytes()) - .ok_or_else(|| MailParsingError::BodyParse(format!("unsupported charset {charset}")))?; + .ok_or_else(|| MailParsingError::BodyParse(format!("unsupported charset {charset}"))); let (is_text, is_multipart) = if let Some(ct) = &content_type { (ct.is_text(), ct.is_multipart()) @@ -71,11 +90,23 @@ impl Rfc2045Info { (true, false) }; - let content_disposition = headers.content_disposition()?; + let content_disposition = match headers.content_disposition() { + Ok(cd) => cd, + Err(_) => { + invalid_mime_headers = true; + None + } + }; let attachment_options = match content_disposition { Some(cd) => { let inline = cd.value == "inline"; - let content_id = headers.content_id()?; + let content_id = match headers.content_id() { + Ok(cid) => cid, + Err(_) => { + invalid_mime_headers = true; + None + } + }; let file_name = cd.get("filename"); Some(AttachmentOptions { @@ -87,14 +118,15 @@ impl Rfc2045Info { None => None, }; - Ok(Self { + Self { encoding, charset, content_type, is_text, is_multipart, attachment_options, - }) + invalid_mime_headers, + } } } @@ -190,7 +222,10 @@ impl<'a> MimePart<'a> { } fn recursive_parse(&mut self) -> Result<()> { - let info = Rfc2045Info::new(&self.headers)?; + let info = Rfc2045Info::new(&self.headers); + if info.invalid_mime_headers { + self.conformance |= MessageConformance::INVALID_MIME_HEADERS; + } if let Some((boundary, true)) = info .content_type .as_ref() @@ -285,13 +320,13 @@ impl<'a> MimePart<'a> { .slice(self.body_offset..self.body_len.max(self.body_offset)) } - pub fn rfc2045_info(&self) -> Result { + pub fn rfc2045_info(&self) -> Rfc2045Info { Rfc2045Info::new(&self.headers) } /// Decode transfer decoding and return the body pub fn body(&'_ self) -> Result> { - let info = Rfc2045Info::new(&self.headers)?; + let info = Rfc2045Info::new(&self.headers); let bytes = match info.encoding { ContentTransferEncoding::Base64 => { @@ -329,7 +364,7 @@ impl<'a> MimePart<'a> { }; if info.is_text { - let (decoded, _malformed) = info.charset.decode_without_bom_handling(&bytes); + let (decoded, _malformed) = info.charset?.decode_without_bom_handling(&bytes); Ok(DecodedBody::Text(decoded.to_string().into())) } else { Ok(DecodedBody::Binary(bytes)) @@ -343,7 +378,7 @@ impl<'a> MimePart<'a> { /// but may come at the cost of "losing" the non-sensical or otherwise /// out of spec elements in the rebuilt message pub fn rebuild(&self) -> Result { - let info = Rfc2045Info::new(&self.headers)?; + let info = Rfc2045Info::new(&self.headers); let mut children = vec![]; for part in &self.parts { @@ -421,7 +456,7 @@ impl<'a> MimePart<'a> { out.write_all(self.raw_body().as_bytes()) .map_err(|_| MailParsingError::WriteMessageIOError)?; } else { - let info = Rfc2045Info::new(&self.headers)?; + let info = Rfc2045Info::new(&self.headers); let ct = info.content_type.ok_or({ MailParsingError::WriteMessageWtf( "expected to have Content-Type when there are child parts", @@ -745,7 +780,7 @@ impl<'a> MimePart<'a> { &self, my_idx: Option, ) -> Result { - let info = Rfc2045Info::new(&self.headers)?; + let info = Rfc2045Info::new(&self.headers); let is_inline = info .attachment_options .as_ref() diff --git a/crates/mailparsing/src/rfc5322_parser.rs b/crates/mailparsing/src/rfc5322_parser.rs index 9be5c71d..bbd24a34 100644 --- a/crates/mailparsing/src/rfc5322_parser.rs +++ b/crates/mailparsing/src/rfc5322_parser.rs @@ -2056,7 +2056,7 @@ impl EncodeHeaderValue for AddressList { #[cfg(test)] mod test { use super::*; - use crate::{Header, MimePart}; + use crate::{Header, MessageConformance, MimePart}; #[test] fn mailbox_encodes_at() { @@ -2347,12 +2347,21 @@ Some( #[test] fn attachment_filename_mess_aberrant() { // Quotes are missing and the = = thing is totally borked; - // this content is expected to fail to parse + // this header is expected to fail to parse let message = concat!( "Content-Disposition: attachment; filename= =?UTF-8?B?5pel5pys6Kqe44Gu5re75LuY?=\n", "\n\n" ); - MimePart::parse(message).unwrap_err(); + let msg = MimePart::parse(message).unwrap(); + assert!(msg + .conformance() + .contains(MessageConformance::INVALID_MIME_HEADERS)); + msg.headers().content_disposition().unwrap_err(); + + // There is no Content-Disposition in the rebuilt message, because + // there was no valid Content-Disposition in what we parsed + let rebuilt = msg.rebuild().unwrap(); + k9::assert_equal!(rebuilt.headers().content_disposition(), Ok(None)); } #[test] diff --git a/crates/mod-mimepart/src/mimepart.rs b/crates/mod-mimepart/src/mimepart.rs index e936c5ef..357cc6fa 100644 --- a/crates/mod-mimepart/src/mimepart.rs +++ b/crates/mod-mimepart/src/mimepart.rs @@ -127,18 +127,17 @@ impl UserData for PartRef { let mut content_id = None; let mut content_type = None; - if let Ok(info) = a_part.rfc2045_info() { - if let Some(mut opts) = info.attachment_options { - if let Some(name) = opts.file_name.take() { - file_name = name; - } - inline = opts.inline; - content_id = opts.content_id; + let info = a_part.rfc2045_info(); + if let Some(mut opts) = info.attachment_options { + if let Some(name) = opts.file_name.take() { + file_name = name; } + inline = opts.inline; + content_id = opts.content_id; + } - if let Some(ct) = info.content_type { - content_type.replace(ct.value); - } + if let Some(ct) = info.content_type { + content_type.replace(ct.value); } attach.set("file_name", file_name)?;