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.
This commit is contained in:
Wez Furlong
2025-09-10 09:17:51 +01:00
parent 466a59df44
commit 93e87fc12e
5 changed files with 124 additions and 33 deletions
+46
View File
@@ -191,6 +191,52 @@ Content-Transfer-Encoding: quoted-printable\r
<b>this is html =F0=9F=9A=80</b>\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
"#
);
}
+3 -1
View File
@@ -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'"
+54 -19
View File
@@ -38,23 +38,42 @@ pub struct MimePart<'a> {
pub struct Rfc2045Info {
pub encoding: ContentTransferEncoding,
pub charset: Charset,
pub charset: Result<Charset>,
pub content_type: Option<MimeParameters>,
pub is_text: bool,
pub is_multipart: bool,
pub attachment_options: Option<AttachmentOptions>,
pub invalid_mime_headers: bool,
}
impl Rfc2045Info {
fn new(headers: &HeaderMap) -> Result<Self> {
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<Rfc2045Info> {
pub fn rfc2045_info(&self) -> Rfc2045Info {
Rfc2045Info::new(&self.headers)
}
/// Decode transfer decoding and return the body
pub fn body(&'_ self) -> Result<DecodedBody<'_>> {
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<Self> {
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<u8>,
) -> Result<SimplifiedStructurePointers> {
let info = Rfc2045Info::new(&self.headers)?;
let info = Rfc2045Info::new(&self.headers);
let is_inline = info
.attachment_options
.as_ref()
+12 -3
View File
@@ -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]
+9 -10
View File
@@ -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)?;