From 93e87fc12e34d78378c5ea05183754cc5e84bfa0 Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Wed, 10 Sep 2025 09:01:18 +0100 Subject: [PATCH] mailparsing: make Rfc2045Info::new infallible If it fails, then the overall message parse attempt fails out, but we want to allow operating on the borked message at some basic level. This commit adjusts the logic to assume reasonable fallback values in case the various `Content-XXX` header parsing fails, and to defer eg: detection of an unsupported charset until the point where the content conversion is attempted. The invalid headers are recorded as a new INVALID_MIME_HEADERS conformance flag which causes check_fix_conformance to decide that the message needs to be rebuilt. The rebuild process will come up with its own Content-XXX headers for the rebuilt message through its existing logic. --- crates/mailparsing/src/builder.rs | 46 +++++++++++++++ crates/mailparsing/src/header.rs | 4 +- crates/mailparsing/src/mimepart.rs | 73 ++++++++++++++++++------ crates/mailparsing/src/rfc5322_parser.rs | 15 ++++- crates/mod-mimepart/src/mimepart.rs | 19 +++--- 5 files changed, 124 insertions(+), 33 deletions(-) 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)?;