Skip to content

Commit 387a96e

Browse files
committed
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.
1 parent aa872f4 commit 387a96e

File tree

3 files changed

+148
-12
lines changed

3 files changed

+148
-12
lines changed

.cargo/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[profile.debug]
22
# faster uploads
3-
strip = "debuginfo"
3+
#strip = "debuginfo"
44

55
[target.x86_64-unknown-linux-musl]
66
# install via: brew install FiloSottile/musl-cross/musl-cross

cron-long-output.eml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
From: root (Cron Daemon)
2+
To: root
3+
Subject: Cron <root@test-nullmailer> cat /root/2kononeline && false
4+
MIME-Version: 1.0
5+
Content-Type: text/plain; charset=US-ASCII
6+
Content-Transfer-Encoding: 8bit
7+
X-Cron-Env: <SHELL=/bin/sh>
8+
X-Cron-Env: <HOME=/root>
9+
X-Cron-Env: <PATH=/usr/bin:/bin>
10+
X-Cron-Env: <LOGNAME=root>
11+
12+
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
13+
next line

src/main.rs

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
use core::panic;
22
use lettre::address::Envelope;
3-
use lettre::message::{Attachment, MaybeString, MultiPart, SinglePart};
3+
use lettre::message::header::{
4+
ContentDisposition, ContentTransferEncoding, ContentType, HeaderName, HeaderValue,
5+
};
6+
use lettre::message::{Body, MaybeString, MultiPart, SinglePart};
47
use lettre::{Message, Transport};
8+
59
use mailparse::MailHeaderMap;
610
use regex::Regex;
711
use std::borrow::Cow;
@@ -79,7 +83,10 @@ fn main() {
7983
let stdin_raw: OriginalMessageBody = {
8084
let mut stdin_content = Vec::new();
8185
match io::stdin().read_to_end(&mut stdin_content) {
82-
Ok(_) => OriginalMessageBody::Read(stdin_content),
86+
Ok(_) => {
87+
std::fs::write("/tmp/debug.eml", &stdin_content).expect("IO error");
88+
OriginalMessageBody::Read(stdin_content)
89+
}
8390
Err(e) => OriginalMessageBody::Error(e),
8491
}
8592
};
@@ -158,7 +165,7 @@ fn main() {
158165
(None, None) => "???".to_owned(),
159166
}
160167
};
161-
let summary = match original_parsed {
168+
let summary = match &original_parsed {
162169
Some(parsed) => match parsed.get_headers().get_all_values("Subject").as_slice() {
163170
[unambiguous] => unambiguous.clone(),
164171
_x => "(multiple Subject headers)".to_owned(),
@@ -213,6 +220,8 @@ Invocation args: {args}
213220
{invocation_ctx}
214221
215222
(This message was generated by forwad-as-attachment-mta)
223+
224+
216225
"#
217226
);
218227

@@ -226,14 +235,119 @@ Invocation args: {args}
226235
.to(config.recipient_email.into())
227236
.subject(subject)
228237
.envelope(envelope)
229-
.multipart(
230-
MultiPart::mixed()
231-
.singlepart(SinglePart::plain(body))
232-
.singlepart(
233-
Attachment::new_inline("stdin_message".to_owned())
234-
.body(stdin_raw, "message/rfc822".parse().unwrap()),
235-
),
236-
)
238+
.multipart({
239+
let mut mp_builder = MultiPart::mixed().singlepart(SinglePart::plain(body));
240+
241+
// Try to create an inline attachment for the receivers's convenience of not
242+
// having to double-click the attachment.
243+
//
244+
// This is surprisingly tricky, as the message/rfc822 MIME type only allows
245+
// Content-Transfer-Encoding 7bit, 8bit or binary.
246+
// Any other encoding (quoted-printable, base64) will break in
247+
// Gmail and AppleMail, probably elsewhere. The exact kind of breakage depends:
248+
// in AppleMail, only the `From`, `To`, and `Subject`
249+
// headers are shown inline, and the rest of the message is not visible / accessible.
250+
// In Gmail, it always shows as an attachment and one gets an error when clicking on it.
251+
//
252+
// So, try to re-encode the message body. If that doesn't work, the user can fallback
253+
// to the attachment.
254+
mp_builder = {
255+
let re_encoded = (|| {
256+
let Some(original_parsed) = original_parsed else {
257+
debug!("not parseable");
258+
return None;
259+
};
260+
if original_parsed.ctype.mimetype != "text/plain" {
261+
debug!("not text/plain content-type");
262+
return None;
263+
}
264+
let mut builder = SinglePart::builder();
265+
for header in &original_parsed.headers {
266+
#[derive(Clone)]
267+
struct RawHeader(HeaderName, String);
268+
impl lettre::message::header::Header for RawHeader {
269+
fn name() -> HeaderName {
270+
unimplemented!("not needed, we only use display")
271+
}
272+
273+
fn parse(
274+
_: &str,
275+
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>>
276+
{
277+
unimplemented!("not needed, we only use display")
278+
}
279+
280+
fn display(&self) -> lettre::message::header::HeaderValue {
281+
HeaderValue::new(self.0.clone(), self.1.clone())
282+
}
283+
}
284+
impl RawHeader {
285+
fn new(hdr: &mailparse::MailHeader) -> Option<Self> {
286+
let header_name = HeaderName::new_from_ascii(hdr.get_key())
287+
.ok()
288+
.or_else(|| {
289+
debug!(hdr=?hdr.get_key(), "header is not ascii");
290+
None
291+
})?;
292+
let header_value = hdr.get_value_utf8().ok().or_else(|| {
293+
debug!(hdr=?hdr, "header value is not utf-8");
294+
None
295+
})?;
296+
Some(Self(header_name, header_value))
297+
}
298+
}
299+
builder = builder.header(RawHeader::new(header).or_else(|| {
300+
debug!("can't adapt libraries into each other");
301+
None
302+
})?);
303+
}
304+
Some(
305+
builder.body(
306+
Body::new_with_encoding(
307+
original_parsed.get_body().ok().or_else(|| {
308+
debug!("cannot get body");
309+
None
310+
})?,
311+
lettre::message::header::ContentTransferEncoding::Base64,
312+
)
313+
.unwrap(),
314+
),
315+
)
316+
})();
317+
318+
if let Some(re_encoded) = re_encoded {
319+
mp_builder.singlepart(
320+
SinglePart::builder()
321+
.header(ContentType::parse("message/rfc822").unwrap())
322+
.header(ContentDisposition::inline())
323+
// Not dangerous because we used Base64 encoding to build the `re_encoded` => EigthBit safe
324+
.body(Body::dangerous_pre_encoded(
325+
re_encoded.formatted(),
326+
ContentTransferEncoding::EightBit,
327+
)),
328+
)
329+
} else {
330+
debug!("can't inline the attachment, see previous log messages");
331+
mp_builder
332+
}
333+
};
334+
335+
mp_builder = mp_builder.singlepart(
336+
SinglePart::builder()
337+
// (Stdin may not necessarily be a correct email to begin with, so, octet-stream is a reasonable default.)
338+
.header(ContentType::parse("application/octet-stream").unwrap())
339+
.header(ContentDisposition::attachment("stdin.eml"))
340+
.body(
341+
Body::new_with_encoding(
342+
stdin_raw,
343+
lettre::message::header::ContentTransferEncoding::Base64,
344+
)
345+
.unwrap(),
346+
),
347+
);
348+
349+
mp_builder
350+
})
237351
.expect("Failed to attach stdin email message");
238352

239353
debug!(
@@ -305,4 +419,13 @@ mod tests {
305419
Cow::<'_, str>::Owned(r"foo\(bar\)".to_owned())
306420
);
307421
}
422+
423+
#[test]
424+
fn test_long_lines() {
425+
let msg = mailparse::parse_mail(include_bytes!("../cron-long-output.eml")).unwrap();
426+
assert!(matches!(
427+
msg.get_body_encoded(),
428+
mailparse::body::Body::EightBit(_)
429+
));
430+
}
308431
}

0 commit comments

Comments
 (0)