Skip to content

Commit

Permalink
send RFC-compliant inline attachments
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
problame committed Jan 30, 2024
1 parent aa872f4 commit 387a96e
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 13 additions & 0 deletions cron-long-output.eml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
From: root (Cron Daemon)
To: root
Subject: Cron <root@test-nullmailer> cat /root/2kononeline && false
MIME-Version: 1.0
Content-Type: text/plain; charset=US-ASCII
Content-Transfer-Encoding: 8bit
X-Cron-Env: <SHELL=/bin/sh>
X-Cron-Env: <HOME=/root>
X-Cron-Env: <PATH=/usr/bin:/bin>
X-Cron-Env: <LOGNAME=root>

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
next line
145 changes: 134 additions & 11 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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),
}
};
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -213,6 +220,8 @@ Invocation args: {args}
{invocation_ctx}
(This message was generated by forwad-as-attachment-mta)
"#
);

Expand All @@ -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<Self, Box<dyn std::error::Error + Send + Sync>>
{
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<Self> {
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!(
Expand Down Expand Up @@ -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(_)
));
}
}

0 comments on commit 387a96e

Please sign in to comment.