From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id EA3C81FF0E2 for ; Thu, 30 Jul 2026 16:02:26 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id B99FD214C5; Thu, 30 Jul 2026 16:02:26 +0200 (CEST) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Thu, 30 Jul 2026 16:02:21 +0200 Message-Id: To: "Shannon Sterz" , Subject: Re: [PATCH proxmox] fix #7814: send-mail: normalize LF line endings to CRLF endings X-Mailer: aerc 0.20.0 References: <20260714084719.68076-1-s.sterz@proxmox.com> In-Reply-To: <20260714084719.68076-1-s.sterz@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1785420133392 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.167 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: H7KXUDN2GXFH6NENOQM55PTH46YXCPMA X-Message-ID-Hash: H7KXUDN2GXFH6NENOQM55PTH46YXCPMA X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Superseded-by: https://lore.proxmox.com/pbs-devel/20260730140126.444424-1-s= .sterz@proxmox.com/T/#u On Tue Jul 14, 2026 at 10:47 AM CEST, Shannon Sterz wrote: > RFC5321 Section 2.3.8 requires that mails do not contain bare CR or LF > bytes. this patch normalizes unix style LF line endings to CRLF line > endings. it also removes bare CR bytes. > > when text would be base64 encoded anyway, CR bytes will be dropped, to > keep the behavior for encoded and non-encoded text aligned. however, > since this code was already extensively tested against multiple MUAs, > encoded LF line endings are kept as-is. > > also adds tests to encode this behavior. > > Signed-off-by: Shannon Sterz > --- > proxmox-sendmail/src/lib.rs | 246 ++++++++++++++++++++++++++++++++---- > 1 file changed, 220 insertions(+), 26 deletions(-) > > diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs > index b3ff56e5..a73e8eb7 100644 > --- a/proxmox-sendmail/src/lib.rs > +++ b/proxmox-sendmail/src/lib.rs > @@ -50,7 +50,10 @@ fn encode_base64_formatted>(raw: T) -> = String { > } > > fn encode_filename_formatted(filename: &str) -> String { > - let encoded =3D format!("UTF-8''{}", utf8_percent_encode(filename, R= FC5987SET)); > + let encoded =3D format!( > + "UTF-8''{}", > + utf8_percent_encode(&filename.replace('\r', ""), RFC5987SET) > + ); > > let format_prefix =3D |l| { > if let Some(l) =3D l { > @@ -63,6 +66,18 @@ fn encode_filename_formatted(filename: &str) -> String= { > format_encoded_text(&encoded, format_prefix, ";", 0, true) > } > > +/// Strip bare CR bytes, than base64 encode. This is useful to make sure= we treat encoded (header) > +/// values the same as non-encoded one. Stripping CR bytes is necessary = according to RFC 5321 Section > +/// Section 2.3.8 (https://datatracker.ietf.org/doc/html/rfc5321#section= -2.3.8). > +fn strip_cr_base64>(raw: T) -> String { > + let normalized: Vec =3D raw > + .as_ref() > + .iter() > + .filter_map(|b| (*b !=3D b'\r').then_some(*b)) > + .collect(); > + proxmox_base64::encode(&normalized) > +} > + > /// Helper function used to format text to not exceed line limits impose= d by standards such as RFC > /// 2045 Section 6.8 that specifies that Base64 content transfer encoded= text "must be represented > /// in lines of no more than 76 characters each". It also allows taking = into account any necessary > @@ -166,11 +181,7 @@ impl Recipient { > fn format_recipient(&self) -> String { > if let Some(name) =3D &self.name { > if !name.is_ascii() { > - format!( > - "=3D?utf-8?B?{}?=3D <{}>", > - proxmox_base64::encode(name), > - self.email > - ) > + format!("=3D?utf-8?B?{}?=3D <{}>", strip_cr_base64(name)= , self.email) > } else { > format!("{name} <{}>", self.email) > } > @@ -199,7 +210,7 @@ impl Attachment<'_> { > if self.filename.is_ascii() { > let _ =3D write!(attachment, "{}", &self.filename); > } else { > - let encoded =3D proxmox_base64::encode(&self.filename); > + let encoded =3D strip_cr_base64(&self.filename); > let prefix_fn =3D |line| { > if Some(0) =3D=3D line { > "=3D?utf-8?B?" > @@ -451,7 +462,10 @@ impl<'a> Mail<'a> { > > /// Forwards an email message to a given list of recipients. > /// > - /// `message` must be compatible with ``sendmail`` (the message is p= iped into stdin unmodified). > + /// `message` must be compatible with ``sendmail``. The message is p= iped into `stdin` > + /// unmodified. One exception is stripping bare CR bytes and normali= zing LF line endings to > + /// CRLF line endings in accordance with RFC 5321 Section 2.3.8 > + /// (https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8). > #[cfg(feature =3D "mail-forwarder")] > pub fn forward( > mailto: &[&str], > @@ -485,11 +499,27 @@ impl<'a> Mail<'a> { > .spawn() > .with_context(|| "could not spawn sendmail process")?; > > + let capacity =3D message.iter().fold(0, |acc, e| match *e { > + b'\r' =3D> acc, > + b'\n' =3D> acc + 2, > + _ =3D> acc + 1, > + }); > + > + let mut normalized_message: Vec =3D Vec::with_capacity(capac= ity); > + > + for byte in message { > + match byte { > + b'\r' =3D> continue, > + b'\n' =3D> normalized_message.extend_from_slice(&[b'\r',= b'\n']), > + b =3D> normalized_message.push(*b), > + } > + } > + > sendmail_process > .stdin > .take() > .unwrap() > - .write_all(message) > + .write_all(&normalized_message) > .with_context(|| "couldn't write to sendmail stdin")?; > > sendmail_process > @@ -520,7 +550,9 @@ impl<'a> Mail<'a> { > write!(mail, "\n--{file_boundary}--")?; > } > > - Ok(mail) > + // normalize line endings from `\n` to `\r\n` and drop bare `\r`= according to RFC 5321 > + // 2.3.8: https://datatracker.ietf.org/doc/html/rfc5321#section-= 2.3.8 > + Ok(mail.replace('\r', "").replace('\n', "\r\n")) > } > > fn format_header( > @@ -561,7 +593,7 @@ impl<'a> Mail<'a> { > writeln!( > header, > "Subject: =3D?utf-8?B?{}?=3D", > - proxmox_base64::encode(&self.subject) > + strip_cr_base64(&self.subject) > )?; > } else { > writeln!(header, "Subject: {}", self.subject)?; > @@ -571,7 +603,7 @@ impl<'a> Mail<'a> { > writeln!( > header, > "From: =3D?utf-8?B?{}?=3D <{}>", > - proxmox_base64::encode(&self.mail_author), > + strip_cr_base64(&self.mail_author), > self.mail_from > )?; > } else { > @@ -606,17 +638,15 @@ impl<'a> Mail<'a> { > header.push_str("Auto-Submitted: auto-generated;\n"); > > for (key, value) in &self.headers { > - // Strip CR/LF so an interpolated key or value cannot inject= extra header lines. > - let key =3D key.replace(['\r', '\n'], ""); > - let value =3D value.replace(['\r', '\n'], ""); > + // Strip LF so an interpolated key or value cannot inject ex= tra header lines. Bare CR > + // will be stripped for non-ASCII text before encoding and f= or ASCII text globally > + // before finishing formatting. > + let key =3D key.replace('\n', ""); > + let value =3D value.replace('\n', ""); > if value.is_ascii() { > writeln!(header, "{key}: {value}")?; > } else { > - writeln!( > - header, > - "{key}: =3D?utf-8?B?{}?=3D", > - proxmox_base64::encode(&value) > - )?; > + writeln!(header, "{key}: =3D?utf-8?B?{}?=3D", strip_cr_b= ase64(&value))?; > } > } > > @@ -653,7 +683,7 @@ impl<'a> Mail<'a> { > body.push_str(&self.body_txt); > } else { > body.push_str("Content-Transfer-Encoding: base64\n\n"); > - body.push_str(&encode_base64_formatted(&self.body_txt)); > + body.push_str(&encode_base64_formatted(self.body_txt.replace= ('\r', ""))); > } > > if let Some(html) =3D &self.body_html { > @@ -666,7 +696,7 @@ impl<'a> Mail<'a> { > body.push_str(html); > } else { > body.push_str("Content-Transfer-Encoding: base64\n\n"); > - body.push_str(&encode_base64_formatted(html)); > + body.push_str(&encode_base64_formatted(html.replace('\r'= , ""))); > } > > write!(body, "\n--{html_boundary}--")?; > @@ -1140,10 +1170,10 @@ Content-Transfer-Encoding: 7bit > ]; > > let mail =3D Mail::new( > - "Sender N=C3=A4me", > - "from@example.com", > - "Subject Lin=C3=AB", > - "Lorem Ipsum Dolor Sit\nAm=C3=ABt", > + "Sender N=C3=A4\rme", > + "from@example.\rcom", > + "Subject Lin=C3=AB\r", > + "Lorem Ipsum Dolor \rSit\nAm=C3=ABt", > ) > .with_recipient_and_name("Receiver N=C3=A4me", "receiver@example= .com") > .with_attachment("deadbeef.bin", "application/octet-stream", &bi= n) > @@ -1344,4 +1374,168 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvw= xyz > > assert_eq!(suffixed, out); > } > + > + #[test] > + fn line_endings_are_normalized_when_not_encoded() { > + let bin: [u8; 63] =3D [ > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0x0d, > + ]; > + > + let mail =3D Mail::new( > + "Se\rnder Name", > + "fro\rm@example.com", > + "Sub\rject Line", > + "Lor\rem Ipsum Dolor Sit\nAmet", > + ) > + .with_recipient_and_name("R\receiver Name", "receive\rr@example.= com") > + .with_attachment("deadb\reef.bin", "application/octet-stream", &= bin) > + .with_html_alt("\n\t\n\t\tLorem Ipsum Dolor Si\rt Amet\n\t\n") > + .with_header("X-CR-HEADE\r", "value\r"); > + > + let body =3D mail.format_mail(1732806251).expect("could not form= at mail"); > + > + assert!(body.is_ascii()); > + > + let expected =3D r#"Content-Type: multipart/mixed; > + boundary=3D"----_=3D_NextPart_001_1732806251" > +MIME-Version: 1.0 > +Subject: Subject Line > +From: Sender Name > +To: Receiver Name > +Date: Thu, 28 Nov 2024 16:04:11 +0100 > +Auto-Submitted: auto-generated; > +X-CR-HEADE: value > + > +This is a multi-part message in MIME format. > + > +------_=3D_NextPart_001_1732806251 > +Content-Type: multipart/alternative; boundary=3D"----_=3D_NextPart_002_1= 732806251" > +MIME-Version: 1.0 > + > +------_=3D_NextPart_002_1732806251 > +Content-Type: text/plain; > + charset=3D"UTF-8" > +Content-Transfer-Encoding: 7bit > + > +Lorem Ipsum Dolor Sit > +Amet > +------_=3D_NextPart_002_1732806251 > +Content-Type: text/html; > + charset=3D"UTF-8" > +Content-Transfer-Encoding: 7bit > + > + > +
> +		Lorem Ipsum Dolor Sit Amet
> +	
> + > +------_=3D_NextPart_002_1732806251-- > +------_=3D_NextPart_001_1732806251 > +Content-Type: application/octet-stream; > + name=3D"deadbeef.bin" > +Content-Disposition: attachment; > + filename*0*=3DUTF-8''deadbeef.bin > +Content-Transfer-Encoding: base64 > + > +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v > +3q2+796tvu8N > +------_=3D_NextPart_001_1732806251--"#; > + > + // manually compare here to ensure we split specifically only on= `\r\n` line endings > + > + let lines1 =3D body.split("\r\n"); > + let lines2 =3D expected.split("\n"); > + > + for ((i, line1), line2) in lines1.enumerate().zip(lines2) { > + if !(line1.starts_with("Date:") && line2.starts_with("Date:"= )) { > + assert_eq!(line1, line2, "lines at {i} do not match"); > + } > + } > + > + assert_eq!(body.lines().count(), expected.lines().count()); > + } > + > + #[test] > + fn line_endings_are_normalized_when_encoded() { > + let bin: [u8; 63] =3D [ > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, = 0xbe, 0xef, 0xde, 0xad, > + 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0x0d, > + ]; > + > + let mail =3D Mail::new( > + "Se\rnder N=C3=A4me", > + "fro\rm@example.com", > + "Sub\rject Lin=C3=AB", > + "Lor\rem Ipsum Dolor Sit\nAm=C3=ABt", > + ) > + .with_recipient_and_name("R\receiver N=C3=A4me", "receive\rr@exa= mple.com") > + .with_attachment("=F0=9F=90=84=F0=9F=92=80.b\rin", "ima\rge/bmp"= , &bin) > + .with_html_alt("\n\t\n\t\tLorem Ipsum D=C3=B6lor Si\rt Amet\n\t\n") > + .with_header("X-CR-HEADE\r", "val=C3=BCe\r"); > + > + let body =3D mail.format_mail(1732806251).expect("could not form= at mail"); > + > + assert!(body.is_ascii()); > + > + let expected =3D r#"Content-Type: multipart/mixed; > + boundary=3D"----_=3D_NextPart_001_1732806251" > +MIME-Version: 1.0 > +Subject: =3D?utf-8?B?U3ViamVjdCBMaW7Dqw=3D=3D?=3D > +From: =3D?utf-8?B?U2VuZGVyIE7DpG1l?=3D > +To: =3D?utf-8?B?UmVjZWl2ZXIgTsOkbWU=3D?=3D > +Date: Thu, 28 Nov 2024 16:04:11 +0100 > +Auto-Submitted: auto-generated; > +X-CR-HEADE: =3D?utf-8?B?dmFsw7xl?=3D > + > +This is a multi-part message in MIME format. > + > +------_=3D_NextPart_001_1732806251 > +Content-Type: multipart/alternative; boundary=3D"----_=3D_NextPart_002_1= 732806251" > +MIME-Version: 1.0 > + > +------_=3D_NextPart_002_1732806251 > +Content-Type: text/plain; > + charset=3D"UTF-8" > +Content-Transfer-Encoding: base64 > + > +TG9yZW0gSXBzdW0gRG9sb3IgU2l0CkFtw6t0 > +------_=3D_NextPart_002_1732806251 > +Content-Type: text/html; > + charset=3D"UTF-8" > +Content-Transfer-Encoding: base64 > + > +PGh0bWwgbGFuZz0iZGUtYXQiPjxoZWFkPjwvaGVhZD48Ym9keT4KCTxwcmU+CgkJTG9yZW0g > +SXBzdW0gRMO2bG9yIFNpdCBBbWV0Cgk8L3ByZT4KPC9ib2R5PjwvaHRtbD4=3D > +------_=3D_NextPart_002_1732806251-- > +------_=3D_NextPart_001_1732806251 > +Content-Type: image/bmp; > + name=3D"=3D?utf-8?B?8J+QhPCfkoAuYmlu?=3D" > +Content-Disposition: attachment; > + filename*0*=3DUTF-8''%F0%9F%90%84%F0%9F%92%80.bin > +Content-Transfer-Encoding: base64 > + > +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v > +3q2+796tvu8N > +------_=3D_NextPart_001_1732806251--"#; > + > + // manually compare here to ensure we split specifically only on= `\r\n` line endings > + > + let lines1 =3D body.split("\r\n"); > + let lines2 =3D expected.split("\n"); > + > + for ((i, line1), line2) in lines1.enumerate().zip(lines2) { > + if !(line1.starts_with("Date:") && line2.starts_with("Date:"= )) { > + assert_eq!(line1, line2, "lines at {i} do not match"); > + } > + } > + > + assert_eq!(body.lines().count(), expected.lines().count()); > + } > }