From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id A137E1FF138 for ; Wed, 18 Feb 2026 16:25:45 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EFEF71AF5; Wed, 18 Feb 2026 16:26:44 +0100 (CET) Date: Wed, 18 Feb 2026 16:26:38 +0100 From: Stoiko Ivanov To: Shannon Sterz Subject: Re: [PATCH proxmox] sendmail: conform more to mime Content-Disposition header and wrap lines Message-ID: <20260218162638.5ac3e38b@rosa.proxmox.com> In-Reply-To: <20260218142047.263732-1-s.sterz@proxmox.com> References: <20260218142047.263732-1-s.sterz@proxmox.com> X-Mailer: Claws Mail 4.3.1 (GTK 3.24.49; x86_64-pc-linux-gnu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: quoted-printable X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1771428390493 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.069 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: 3W5CVMZAMZ6KFRP5F6T3RIHMGSN7JVRQ X-Message-ID-Hash: 3W5CVMZAMZ6KFRP5F6T3RIHMGSN7JVRQ X-MailFrom: s.ivanov@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: Thanks for addressing this so quickly and the good analysis! On Wed, 18 Feb 2026 14:21:19 +0000 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: >=20 > Content-Disposition: attachment; filename=3D"file.pdf"; > filename*=3DUTF-8''file.pdf >=20 > 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): to be (a bit) more verbose/pedantic - rfc 2183 would allow for multiple filename parameters: https://datatracker.ietf.org/doc/html/rfc2183#section-2 However MIME::Tools considers this (arguably so) as ambiguous content: https://metacpan.org/pod/MIME::Head#ambiguous_content Which in turn is something PMG rejects while parsing as of a recent change: https://git.proxmox.com/?p=3Dpmg-api.git;a=3Dcommitdiff;h=3D66f51c62f789b4c= 20308b7594fbbb357721b0844 >=20 > Content-Disposition: attachment; > filename*0*=3DUTF-8''file.bin as for the splitting into multiple parts (and providing of encoding and language) - that is described in rfc2231 - and we checked that the filename parameter and values fit with: https://datatracker.ietf.org/doc/html/rfc2231#section-7 >=20 > 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. >=20 > Signed-off-by: Shannon Sterz > --- >=20 > tested this with peat on the following muas: >=20 > * aerc > * thunderbird > * ios mail app > * canary mail app > * start mail web client > * gmail web client > * outlook web client I checked: * claws-mail * mutt (and the spamanalysis by rspamd of a generated mail) as far as receiving mails generated with this patch is concerned: Tested-by: Stoiko Ivanov 1 nit and one comment inline: >=20 > noticed this when peat suddenly struggled to send mails. thanks @ Stoiko > Ivanov for helping with analysing this issue. >=20 > proxmox-sendmail/src/lib.rs | 131 +++++++++++++++++++++++++----------- > 1 file changed, 93 insertions(+), 38 deletions(-) >=20 > 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'}'); >=20 > +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); >=20 > - 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 >=20 > - 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) } > } >=20 > +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); nit: for me bytes.len()+ (prefix_len + 2) *lines -2 would read more easily but with the comments it's straight-forward to follow > + > + 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()); > + } the UTF-8'' is not considered above while chunking - thus the first line of the filename parameter ends at 78 characters - vs. the 72 defined above. (which is still in line with: https://datatracker.ietf.org/doc/html/rfc5322#section-2.1.1) not sure if this was intended - thus the comment. else this LGTM. > + > + 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<'_> { >=20 > let mut attachment =3D String::new(); >=20 > - 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, > - ); >=20 > - // 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) > ); >=20 > 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)= ); >=20 > 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)= ); > } >=20 > 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)); > } >=20 > 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 >=20 > 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"); >=20 > 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 >=20 > 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 >=20 > 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 >=20 > 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 >=20 > 3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v > -- > 2.47.3 >=20 >=20 >=20 >=20 >=20