From 387a96ebc8ece0cddea353e5b7ad0ec21c58f04c Mon Sep 17 00:00:00 2001 From: Christian Schwarz Date: Tue, 30 Jan 2024 01:10:38 +0100 Subject: [PATCH] send RFC-compliant inline attachments Before this PR, we'd send the inline attachment using a non-compliant `Content-Transfer-Encoding`. Turns out it's a surprisingly deep rabbit hole to get this right. Take the easy way out: try to re-encode a subset of valid messages, all other messages will be attachment only. --- .cargo/config.toml | 2 +- cron-long-output.eml | 13 ++++ src/main.rs | 145 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 cron-long-output.eml diff --git a/.cargo/config.toml b/.cargo/config.toml index ee96893..06ac541 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,6 @@ [profile.debug] # faster uploads -strip = "debuginfo" +#strip = "debuginfo" [target.x86_64-unknown-linux-musl] # install via: brew install FiloSottile/musl-cross/musl-cross diff --git a/cron-long-output.eml b/cron-long-output.eml new file mode 100644 index 0000000..3f4ee7c --- /dev/null +++ b/cron-long-output.eml @@ -0,0 +1,13 @@ +From: root (Cron Daemon) +To: root +Subject: Cron cat /root/2kononeline && false +MIME-Version: 1.0 +Content-Type: text/plain; charset=US-ASCII +Content-Transfer-Encoding: 8bit +X-Cron-Env: +X-Cron-Env: +X-Cron-Env: +X-Cron-Env: + +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +next line diff --git a/src/main.rs b/src/main.rs index 72b1cd2..b5bbda1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,11 @@ use core::panic; use lettre::address::Envelope; -use lettre::message::{Attachment, MaybeString, MultiPart, SinglePart}; +use lettre::message::header::{ + ContentDisposition, ContentTransferEncoding, ContentType, HeaderName, HeaderValue, +}; +use lettre::message::{Body, MaybeString, MultiPart, SinglePart}; use lettre::{Message, Transport}; + use mailparse::MailHeaderMap; use regex::Regex; use std::borrow::Cow; @@ -79,7 +83,10 @@ fn main() { let stdin_raw: OriginalMessageBody = { let mut stdin_content = Vec::new(); match io::stdin().read_to_end(&mut stdin_content) { - Ok(_) => OriginalMessageBody::Read(stdin_content), + Ok(_) => { + std::fs::write("/tmp/debug.eml", &stdin_content).expect("IO error"); + OriginalMessageBody::Read(stdin_content) + } Err(e) => OriginalMessageBody::Error(e), } }; @@ -158,7 +165,7 @@ fn main() { (None, None) => "???".to_owned(), } }; - let summary = match original_parsed { + let summary = match &original_parsed { Some(parsed) => match parsed.get_headers().get_all_values("Subject").as_slice() { [unambiguous] => unambiguous.clone(), _x => "(multiple Subject headers)".to_owned(), @@ -213,6 +220,8 @@ Invocation args: {args} {invocation_ctx} (This message was generated by forwad-as-attachment-mta) + + "# ); @@ -226,14 +235,119 @@ Invocation args: {args} .to(config.recipient_email.into()) .subject(subject) .envelope(envelope) - .multipart( - MultiPart::mixed() - .singlepart(SinglePart::plain(body)) - .singlepart( - Attachment::new_inline("stdin_message".to_owned()) - .body(stdin_raw, "message/rfc822".parse().unwrap()), - ), - ) + .multipart({ + let mut mp_builder = MultiPart::mixed().singlepart(SinglePart::plain(body)); + + // Try to create an inline attachment for the receivers's convenience of not + // having to double-click the attachment. + // + // This is surprisingly tricky, as the message/rfc822 MIME type only allows + // Content-Transfer-Encoding 7bit, 8bit or binary. + // Any other encoding (quoted-printable, base64) will break in + // Gmail and AppleMail, probably elsewhere. The exact kind of breakage depends: + // in AppleMail, only the `From`, `To`, and `Subject` + // headers are shown inline, and the rest of the message is not visible / accessible. + // In Gmail, it always shows as an attachment and one gets an error when clicking on it. + // + // So, try to re-encode the message body. If that doesn't work, the user can fallback + // to the attachment. + mp_builder = { + let re_encoded = (|| { + let Some(original_parsed) = original_parsed else { + debug!("not parseable"); + return None; + }; + if original_parsed.ctype.mimetype != "text/plain" { + debug!("not text/plain content-type"); + return None; + } + let mut builder = SinglePart::builder(); + for header in &original_parsed.headers { + #[derive(Clone)] + struct RawHeader(HeaderName, String); + impl lettre::message::header::Header for RawHeader { + fn name() -> HeaderName { + unimplemented!("not needed, we only use display") + } + + fn parse( + _: &str, + ) -> Result> + { + unimplemented!("not needed, we only use display") + } + + fn display(&self) -> lettre::message::header::HeaderValue { + HeaderValue::new(self.0.clone(), self.1.clone()) + } + } + impl RawHeader { + fn new(hdr: &mailparse::MailHeader) -> Option { + let header_name = HeaderName::new_from_ascii(hdr.get_key()) + .ok() + .or_else(|| { + debug!(hdr=?hdr.get_key(), "header is not ascii"); + None + })?; + let header_value = hdr.get_value_utf8().ok().or_else(|| { + debug!(hdr=?hdr, "header value is not utf-8"); + None + })?; + Some(Self(header_name, header_value)) + } + } + builder = builder.header(RawHeader::new(header).or_else(|| { + debug!("can't adapt libraries into each other"); + None + })?); + } + Some( + builder.body( + Body::new_with_encoding( + original_parsed.get_body().ok().or_else(|| { + debug!("cannot get body"); + None + })?, + lettre::message::header::ContentTransferEncoding::Base64, + ) + .unwrap(), + ), + ) + })(); + + if let Some(re_encoded) = re_encoded { + mp_builder.singlepart( + SinglePart::builder() + .header(ContentType::parse("message/rfc822").unwrap()) + .header(ContentDisposition::inline()) + // Not dangerous because we used Base64 encoding to build the `re_encoded` => EigthBit safe + .body(Body::dangerous_pre_encoded( + re_encoded.formatted(), + ContentTransferEncoding::EightBit, + )), + ) + } else { + debug!("can't inline the attachment, see previous log messages"); + mp_builder + } + }; + + mp_builder = mp_builder.singlepart( + SinglePart::builder() + // (Stdin may not necessarily be a correct email to begin with, so, octet-stream is a reasonable default.) + .header(ContentType::parse("application/octet-stream").unwrap()) + .header(ContentDisposition::attachment("stdin.eml")) + .body( + Body::new_with_encoding( + stdin_raw, + lettre::message::header::ContentTransferEncoding::Base64, + ) + .unwrap(), + ), + ); + + mp_builder + }) .expect("Failed to attach stdin email message"); debug!( @@ -305,4 +419,13 @@ mod tests { Cow::<'_, str>::Owned(r"foo\(bar\)".to_owned()) ); } + + #[test] + fn test_long_lines() { + let msg = mailparse::parse_mail(include_bytes!("../cron-long-output.eml")).unwrap(); + assert!(matches!( + msg.get_body_encoded(), + mailparse::body::Body::EightBit(_) + )); + } }