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 DF9581FF13B for ; Wed, 25 Feb 2026 12:22:04 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EB58F36058; Wed, 25 Feb 2026 12:23:01 +0100 (CET) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Wed, 25 Feb 2026 12:22:55 +0100 To: "Shannon Sterz" Subject: superseded: Re: [PATCH proxmox v2] sendmail: conform more to mime Content-Disposition header and wrap lines Message-Id: X-Mailer: aerc 0.20.0 References: <20260219145805.1114672-1-s.sterz@proxmox.com> In-Reply-To: <20260219145805.1114672-1-s.sterz@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1772018558977 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.095 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 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: TA4KMPWBMFIXT37UYOJWKKQ75WMREZB5 X-Message-ID-Hash: TA4KMPWBMFIXT37UYOJWKKQ75WMREZB5 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/20260225112107.99905-1-s.= sterz@proxmox.com/T/#u On Thu Feb 19, 2026 at 3:58 PM CET, Shannon Sterz wrote: > the http and mime Content-Disposition headers function slightly > differently. for the http version the following would be valid > according to rfc 6266 [1]: > > 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 [2] the above should be expressed like > so for mime messages (one variant, multiple are supported): > > Content-Disposition: attachment; > filename*0*=3DUTF-8''file.bin > > while rfc 2183 [2] would in theory allow for multiple filename > parameters similar to rfc 6266, in practice MIME::Tools [2] considers > this as ambigous content [3]. in more recent versions of Proxmox Mail > Gateway, such messages are rejected [4]. > > 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 as described in rfcs 2231 [5] and 2045 [6]. > > [1]: https://datatracker.ietf.org/doc/html/rfc6266/ > [2]: https://datatracker.ietf.org/doc/html/rfc2183#section-2 > [3]: https://metacpan.org/pod/MIME::Head#ambiguous_content > [4]: > https://git.proxmox.com/?p=3Dpmg-api.git;a=3Dcommitdiff;h=3D66f51c62f789b= 4c20308b7594fbbb357721b0844 > [5]: https://datatracker.ietf.org/doc/html/rfc2231#section-7 > [6]: https://datatracker.ietf.org/doc/html/rfc2045/#section-6.8 > > 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. > > Changelog: > --------- > > * wrap lines more correctly ensuring that encoded lines don't exceed 76 > chars (thanks @ Stoiko Ivanov) > * refactored the wrapping logic to be more general > > proxmox-sendmail/src/lib.rs | 167 ++++++++++++++++++++++++++++-------- > 1 file changed, 129 insertions(+), 38 deletions(-) > > diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs > index f5b04afd..ec8488a4 100644 > --- a/proxmox-sendmail/src/lib.rs > +++ b/proxmox-sendmail/src/lib.rs > @@ -44,23 +44,83 @@ const RFC5987SET: &AsciiSet =3D &CONTROLS > /// 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; > - > let encoded =3D proxmox_base64::encode(raw); > - let bytes =3D encoded.as_bytes(); > > - 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 > + format_text(&encoded, |_| "".into(), "", 0, true) > +} > > - for (line, chunk) in bytes.chunks(TEXT_WIDTH).enumerate() { > +fn encode_filename_formatted(filename: &str) -> String { > + let encoded =3D format!("UTF-8''{}", utf8_percent_encode(filename, R= FC5987SET)); > + > + let format_prefix =3D |l| { > + if let Some(l) =3D l { > + format!("\tfilename*{l}*=3D") > + } else { > + "\tfilename*NN*=3D".into() > + } > + }; > + > + format_text(&encoded, format_prefix, ";", 0, true) > +} > + > +fn format_text( > + text: &str, > + prefix: F, > + suffix: &str, > + shorten_first: usize, > + skip_last_suffix: bool, > +) -> String > +where > + F: Fn(Option) -> String, > +{ > + const DEFAULT_TEXT_WIDTH: usize =3D 72; > + > + let bytes =3D text.as_bytes(); > + let suffix =3D suffix.as_bytes(); > + > + let format_text_len =3D suffix.len() + prefix(None).len(); > + let lines =3D (bytes.len() + shorten_first).div_ceil(DEFAULT_TEXT_WI= DTH - format_text_len); > + > + // bytes + formatting and "\n" per line - no "\n" in the last line > + let mut capacity =3D bytes.len() + (format_text_len + 1) * lines - 1= ; > + > + if skip_last_suffix { > + capacity -=3D suffix.len(); > + } > + > + let mut out =3D Vec::with_capacity(capacity); > + let first_line_length =3D DEFAULT_TEXT_WIDTH - shorten_first - forma= t_text_len; > + > + let (total_lines, bytes) =3D > + if let Some((first, rest)) =3D bytes.split_at_checked(first_line= _length) { > + out.extend_from_slice(prefix(Some(0)).as_bytes()); > + out.extend_from_slice(first); > + out.extend_from_slice(suffix); > + if !rest.is_empty() { > + out.push(b'\n'); > + } > + (1, rest) > + } else { > + (0, bytes) > + }; > + > + for (line, chunk) in bytes > + .chunks(DEFAULT_TEXT_WIDTH - format_text_len) > + .enumerate() > + { > + let line =3D line + total_lines; > + out.extend_from_slice(prefix(Some(line)).as_bytes()); > out.extend_from_slice(chunk); > + > if line + 1 !=3D lines { > - // do not end last line with newline to give caller control = over doing so. > + out.extend_from_slice(suffix); > out.push(b'\n'); > + } else if !skip_last_suffix { > + out.extend_from_slice(suffix); > } > } > > - // SAFETY: base64 encoded, which is 7-bit chars (ASCII) and thus alw= ays valid UTF8 > + // SAFETY: all input slices were valid utf-8 string slices > unsafe { String::from_utf8_unchecked(out) } > } > > @@ -104,24 +164,41 @@ 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 encoded =3D proxmox_base64::encode(&self.filename); > + let prefix_fn =3D |line| { > + if Some(0) =3D=3D line { > + "=3D?utf-8?B?" > + } else { > + "\t=3D?utf-8?B?" > + } > + .into() > + }; > + > + // minus one here as the first line isn't indented > + let skip_first =3D "\tname=3D\"".len() - 1; > + > + let _ =3D write!( > + attachment, > + "{}", > + format_text(&encoded, prefix_fn, "?=3D", skip_first, fal= se) > + ); > + } > + > + // should be fine to add one here, the last line should be at mo= st 72 chars. according to > + // rfc 2045 encoded output lines should be "no more than 76 char= acters each" > + 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"); > @@ -809,9 +886,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 +916,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 +957,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+= Sg?=3D > + =3D?utf-8?B?PCfkITwn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE= ?=3D > + =3D?utf-8?B?8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoA= ?=3D > + =3D?utf-8?B?uYmlu?=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= ; > + filename*1*=3D0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%8= ; > + filename*2*=3D4%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%9= ; > + filename*3*=3D2%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9= ; > + filename*4*=3DF%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F= ; > + filename*5*=3D0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%8= ; > + filename*6*=3D0%F0%9F%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 +1086,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