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)?;