mirror of
https://github.com/mailscope/kumomta.git
synced 2026-09-21 17:15:31 +00:00
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:
@@ -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
|
||||
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user