From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 564A01FF13C for ; Thu, 19 Feb 2026 15:59:58 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 3B9CD1A131; Thu, 19 Feb 2026 16:00:59 +0100 (CET) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Thu, 19 Feb 2026 16:00:24 +0100 Message-Id: Subject: Re: [PATCH proxmox] sendmail: conform more to mime Content-Disposition header and wrap lines To: "Shannon Sterz" X-Mailer: aerc 0.20.0 References: <20260218142047.263732-1-s.sterz@proxmox.com> In-Reply-To: <20260218142047.263732-1-s.sterz@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1771513215203 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.087 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. 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: DYN5EKRTL7JN7Z5R6F46Q2OXQXWDBGUP X-Message-ID-Hash: DYN5EKRTL7JN7Z5R6F46Q2OXQXWDBGUP 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 CC: pbs-devel@lists.proxmox.com X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Superseded-by: https://lore.proxmox.com/pbs-devel/20260219145805.1114672-1-= s.sterz@proxmox.com/T/#u On Wed Feb 18, 2026 at 3:20 PM CET, Shannon Sterz wrote: > the http and mime Content-Disposition headers function slightly > differently. for the http the following would be valid according to > rfc 6266: > > Content-Disposition: attachment; filename=3D"file.pdf"; > filename*=3DUTF-8''file.pdf > > specifying multiple filename attributes like this isn't valid for mime > messages. according to rfc 2183 the above should be expressed like so > for mime messages (one variant, multiple are supported): > > Content-Disposition: attachment; > filename*0*=3DUTF-8''file.bin > > this patch implements proper filename handling and also wraps > filenames in the content disposition and content type headers > appropriatelly to a mail being rejected due to having mime headers > that are too long. > > Signed-off-by: Shannon Sterz > --- > > tested this with peat on the following muas: > > * aerc > * thunderbird > * ios mail app > * canary mail app > * start mail web client > * gmail web client > * outlook web client > > noticed this when peat suddenly struggled to send mails. thanks @ Stoiko > Ivanov for helping with analysing this issue. > > proxmox-sendmail/src/lib.rs | 131 +++++++++++++++++++++++++----------- > 1 file changed, 93 insertions(+), 38 deletions(-) > > diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs > index f5b04afd..4b686bd9 100644 > --- a/proxmox-sendmail/src/lib.rs > +++ b/proxmox-sendmail/src/lib.rs > @@ -41,18 +41,19 @@ const RFC5987SET: &AsciiSet =3D &CONTROLS > .add(b'{') > .add(b'}'); > > +const DEFAULT_TEXT_WIDTH: usize =3D 72; > + > /// base64 encode and hard-wrap the base64 encoded string every 72 chara= cters. this improves > /// compatibility. > -fn encode_base64_formatted>(raw: T) -> String { > - const TEXT_WIDTH: usize =3D 72; > - > +fn encode_base64_formatted>(raw: T, text_width: Option) -> String { > let encoded =3D proxmox_base64::encode(raw); > let bytes =3D encoded.as_bytes(); > + let text_width =3D text_width.unwrap_or(DEFAULT_TEXT_WIDTH); > > - let lines =3D bytes.len().div_ceil(TEXT_WIDTH); > + let lines =3D bytes.len().div_ceil(text_width); > let mut out =3D Vec::with_capacity(bytes.len() + lines - 1); // acco= unt for 1 newline per line > > - for (line, chunk) in bytes.chunks(TEXT_WIDTH).enumerate() { > + for (line, chunk) in bytes.chunks(text_width).enumerate() { > out.extend_from_slice(chunk); > if line + 1 !=3D lines { > // do not end last line with newline to give caller control = over doing so. > @@ -64,6 +65,38 @@ fn encode_base64_formatted>(raw: T) -> = String { > unsafe { String::from_utf8_unchecked(out) } > } > > +fn encode_filename_formatted(filename: &str) -> String { > + let encoded =3D format!("{}", utf8_percent_encode(filename, RFC5987S= ET)); > + let bytes =3D encoded.as_bytes(); > + > + let prefix_len =3D "\tfilename*00*=3D".len(); > + // + 1 here to include a ";" per line > + let lines =3D bytes.len().div_ceil(DEFAULT_TEXT_WIDTH - (prefix_len = + 1)); > + > + // bytes + prefixes + plus ";\n" - 2 (the last line does not end in = ";n") > + let mut out =3D Vec::with_capacity(bytes.len() + prefix_len * lines = + lines * 2 - 2); > + > + for (line, chunk) in bytes > + .chunks(DEFAULT_TEXT_WIDTH - (prefix_len + 1)) > + .enumerate() > + { > + out.extend_from_slice(format!("\tfilename*{line}*=3D").as_bytes(= )); > + > + if line =3D=3D 0 { > + out.extend_from_slice("UTF-8''".as_bytes()); > + } > + > + out.extend_from_slice(chunk); > + if line + 1 !=3D lines { > + out.push(b';'); > + out.push(b'\n') > + } > + } > + > + // SAFETY: bytes are utf-8 since they are percent encoded. > + unsafe { String::from_utf8_unchecked(out) } > +} > + > struct Recipient { > name: Option, > email: String, > @@ -104,28 +137,36 @@ impl Attachment<'_> { > > let mut attachment =3D String::new(); > > - let encoded_filename =3D if self.filename.is_ascii() { > - &self.filename > - } else { > - &format!("=3D?utf-8?B?{}?=3D", proxmox_base64::encode(&self.= filename)) > - }; > - > let _ =3D writeln!(attachment, "\n--{file_boundary}"); > - let _ =3D writeln!( > - attachment, > - "Content-Type: {}; name=3D\"{encoded_filename}\"", > - self.mime, > - ); > > - // both `filename` and `filename*` are included for additional c= ompatibility > + let _ =3D write!(attachment, "Content-Type: {};\n\tname=3D\"", s= elf.mime); > + > + if self.filename.is_ascii() { > + let _ =3D write!(attachment, "{}", &self.filename); > + } else { > + let mut first =3D true; > + for line in > + encode_base64_formatted(&self.filename, Some(DEFAULT_TEX= T_WIDTH - 14)).split('\n') > + { > + if first { > + let _ =3D write!(attachment, "=3D?utf-8?B?{line}?=3D= "); > + first =3D false; > + } else { > + let _ =3D write!(attachment, "\n\t=3D?utf-8?B?{line}= ?=3D"); > + } > + } > + } > + > + let _ =3D writeln!(attachment, "\""); > + > let _ =3D writeln!( > attachment, > - "Content-Disposition: attachment; filename=3D\"{encoded_file= name}\";\n\tfilename*=3DUTF-8''{}", > - utf8_percent_encode(&self.filename, RFC5987SET) > + "Content-Disposition: attachment;\n{}", > + encode_filename_formatted(&self.filename) > ); > > attachment.push_str("Content-Transfer-Encoding: base64\n\n"); > - attachment.push_str(&encode_base64_formatted(self.content)); > + attachment.push_str(&encode_base64_formatted(self.content, None)= ); > > attachment > } > @@ -514,7 +555,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, None)= ); > } > > if let Some(html) =3D &self.body_html { > @@ -527,7 +568,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, None)); > } > > write!(body, "\n--{html_boundary}--")?; > @@ -809,9 +850,10 @@ Content-Transfer-Encoding: 7bit > Lorem Ipsum Dolor Sit > Amet > ------_=3D_NextPart_001_1732806251 > -Content-Type: application/octet-stream; name=3D"deadbeef.bin" > -Content-Disposition: attachment; filename=3D"deadbeef.bin"; > - filename*=3DUTF-8''deadbeef.bin > +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 > @@ -838,7 +880,7 @@ Content-Transfer-Encoding: base64 > ) > .with_recipient_and_name("Receiver Name", "receiver@example.com"= ) > .with_attachment("deadbeef.bin", "application/octet-stream", &bi= n) > - .with_attachment("=F0=9F=90=84=F0=9F=92=80.bin", "image/bmp", &b= in) > + .with_attachment("=F0=9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92= =80=F0=9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80= =F0=9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80=F0= =9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80=F0=9F= =90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80=F0=9F=90= =84=F0=9F=92=80=F0=9F=90=84=F0=9F=92=80.bin", "image/bmp", &bin) > .with_html_alt("\n\t\n\t\tLorem Ipsum Dolor Sit Amet\n\t\n"); > > let body =3D mail.format_mail(1732806251).expect("could not form= at mail"); > @@ -879,17 +921,28 @@ Content-Transfer-Encoding: 7bit > > ------_=3D_NextPart_002_1732806251-- > ------_=3D_NextPart_001_1732806251 > -Content-Type: application/octet-stream; name=3D"deadbeef.bin" > -Content-Disposition: attachment; filename=3D"deadbeef.bin"; > - filename*=3DUTF-8''deadbeef.bin > +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+796tvu8=3D > ------_=3D_NextPart_001_1732806251 > -Content-Type: image/bmp; name=3D"=3D?utf-8?B?8J+QhPCfkoAuYmlu?=3D" > -Content-Disposition: attachment; filename=3D"=3D?utf-8?B?8J+QhPCfkoAuYml= u?=3D"; > - filename*=3DUTF-8''%F0%9F%90%84%F0%9F%92%80.bin > +Content-Type: image/bmp; > + name=3D"=3D?utf-8?B?8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE8J+= SgPCfkI?=3D > + =3D?utf-8?B?Twn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE8J+S?= =3D > + =3D?utf-8?B?gPCfkITwn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoAuYm?= =3D > + =3D?utf-8?B?lu?=3D" > +Content-Disposition: attachment; > + filename*0*=3DUTF-8''%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F= 0%9F%90; > + filename*1*=3D%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F= ; > + filename*2*=3D%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0= ; > + filename*3*=3D%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84= ; > + filename*4*=3D%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92= ; > + filename*5*=3D%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F= ; > + filename*6*=3D%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80.bin > Content-Transfer-Encoding: base64 > > 3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v > @@ -997,17 +1050,19 @@ PGh0bWwgbGFuZz0iZGUtYXQiPjxoZWFkPjwvaGVhZD48Ym9keT= 4KCTxwcmU+CgkJTG9yZW0g > SXBzdW0gRMO2bG9yIFNpdCBBbWV0Cgk8L3ByZT4KPC9ib2R5PjwvaHRtbD4=3D > ------_=3D_NextPart_002_1732806251-- > ------_=3D_NextPart_001_1732806251 > -Content-Type: application/octet-stream; name=3D"deadbeef.bin" > -Content-Disposition: attachment; filename=3D"deadbeef.bin"; > - filename*=3DUTF-8''deadbeef.bin > +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+796tvu8=3D > ------_=3D_NextPart_001_1732806251 > -Content-Type: image/bmp; name=3D"=3D?utf-8?B?8J+QhPCfkoAuYmlu?=3D" > -Content-Disposition: attachment; filename=3D"=3D?utf-8?B?8J+QhPCfkoAuYml= u?=3D"; > - filename*=3DUTF-8''%F0%9F%90%84%F0%9F%92%80.bin > +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 > -- > 2.47.3