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 CE3B71FF138 for ; Wed, 18 Feb 2026 15:20:24 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 205611DD61; Wed, 18 Feb 2026 15:21:24 +0100 (CET) From: Shannon Sterz To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox] sendmail: conform more to mime Content-Disposition header and wrap lines Date: Wed, 18 Feb 2026 15:20:45 +0100 Message-ID: <20260218142047.263732-1-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1771424441451 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: 3ZVARWEOEA4JL4K64CXO4OWEDMZCEG5Y X-Message-ID-Hash: 3ZVARWEOEA4JL4K64CXO4OWEDMZCEG5Y 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 Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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="file.pdf"; filename*=UTF-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*=UTF-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 = &CONTROLS .add(b'{') .add(b'}'); +const DEFAULT_TEXT_WIDTH: usize = 72; + /// base64 encode and hard-wrap the base64 encoded string every 72 characters. this improves /// compatibility. -fn encode_base64_formatted>(raw: T) -> String { - const TEXT_WIDTH: usize = 72; - +fn encode_base64_formatted>(raw: T, text_width: Option) -> String { let encoded = proxmox_base64::encode(raw); let bytes = encoded.as_bytes(); + let text_width = text_width.unwrap_or(DEFAULT_TEXT_WIDTH); - let lines = bytes.len().div_ceil(TEXT_WIDTH); + let lines = bytes.len().div_ceil(text_width); let mut out = Vec::with_capacity(bytes.len() + lines - 1); // account 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 != 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 = format!("{}", utf8_percent_encode(filename, RFC5987SET)); + let bytes = encoded.as_bytes(); + + let prefix_len = "\tfilename*00*=".len(); + // + 1 here to include a ";" per line + let lines = 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 = 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}*=").as_bytes()); + + if line == 0 { + out.extend_from_slice("UTF-8''".as_bytes()); + } + + out.extend_from_slice(chunk); + if line + 1 != 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 = String::new(); - let encoded_filename = if self.filename.is_ascii() { - &self.filename - } else { - &format!("=?utf-8?B?{}?=", proxmox_base64::encode(&self.filename)) - }; - let _ = writeln!(attachment, "\n--{file_boundary}"); - let _ = writeln!( - attachment, - "Content-Type: {}; name=\"{encoded_filename}\"", - self.mime, - ); - // both `filename` and `filename*` are included for additional compatibility + let _ = write!(attachment, "Content-Type: {};\n\tname=\"", self.mime); + + if self.filename.is_ascii() { + let _ = write!(attachment, "{}", &self.filename); + } else { + let mut first = true; + for line in + encode_base64_formatted(&self.filename, Some(DEFAULT_TEXT_WIDTH - 14)).split('\n') + { + if first { + let _ = write!(attachment, "=?utf-8?B?{line}?="); + first = false; + } else { + let _ = write!(attachment, "\n\t=?utf-8?B?{line}?="); + } + } + } + + let _ = writeln!(attachment, "\""); + let _ = writeln!( attachment, - "Content-Disposition: attachment; filename=\"{encoded_filename}\";\n\tfilename*=UTF-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) = &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 ------_=_NextPart_001_1732806251 -Content-Type: application/octet-stream; name="deadbeef.bin" -Content-Disposition: attachment; filename="deadbeef.bin"; - filename*=UTF-8''deadbeef.bin +Content-Type: application/octet-stream; + name="deadbeef.bin" +Content-Disposition: attachment; + filename*0*=UTF-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", &bin) - .with_attachment("🐄💀.bin", "image/bmp", &bin) + .with_attachment("🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀🐄💀.bin", "image/bmp", &bin) .with_html_alt("\n\t
\n\t\tLorem Ipsum Dolor Sit Amet\n\t
\n"); let body = mail.format_mail(1732806251).expect("could not format mail"); @@ -879,17 +921,28 @@ Content-Transfer-Encoding: 7bit ------_=_NextPart_002_1732806251-- ------_=_NextPart_001_1732806251 -Content-Type: application/octet-stream; name="deadbeef.bin" -Content-Disposition: attachment; filename="deadbeef.bin"; - filename*=UTF-8''deadbeef.bin +Content-Type: application/octet-stream; + name="deadbeef.bin" +Content-Disposition: attachment; + filename*0*=UTF-8''deadbeef.bin Content-Transfer-Encoding: base64 3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v 3q2+796tvu8= ------_=_NextPart_001_1732806251 -Content-Type: image/bmp; name="=?utf-8?B?8J+QhPCfkoAuYmlu?=" -Content-Disposition: attachment; filename="=?utf-8?B?8J+QhPCfkoAuYmlu?="; - filename*=UTF-8''%F0%9F%90%84%F0%9F%92%80.bin +Content-Type: image/bmp; + name="=?utf-8?B?8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE8J+SgPCfkI?= + =?utf-8?B?Twn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE8J+S?= + =?utf-8?B?gPCfkITwn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoAuYm?= + =?utf-8?B?lu?=" +Content-Disposition: attachment; + filename*0*=UTF-8''%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90; + filename*1*=%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F; + filename*2*=%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0; + filename*3*=%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84; + filename*4*=%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92; + filename*5*=%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F; + filename*6*=%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 @@ PGh0bWwgbGFuZz0iZGUtYXQiPjxoZWFkPjwvaGVhZD48Ym9keT4KCTxwcmU+CgkJTG9yZW0g SXBzdW0gRMO2bG9yIFNpdCBBbWV0Cgk8L3ByZT4KPC9ib2R5PjwvaHRtbD4= ------_=_NextPart_002_1732806251-- ------_=_NextPart_001_1732806251 -Content-Type: application/octet-stream; name="deadbeef.bin" -Content-Disposition: attachment; filename="deadbeef.bin"; - filename*=UTF-8''deadbeef.bin +Content-Type: application/octet-stream; + name="deadbeef.bin" +Content-Disposition: attachment; + filename*0*=UTF-8''deadbeef.bin Content-Transfer-Encoding: base64 3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v 3q2+796tvu8= ------_=_NextPart_001_1732806251 -Content-Type: image/bmp; name="=?utf-8?B?8J+QhPCfkoAuYmlu?=" -Content-Disposition: attachment; filename="=?utf-8?B?8J+QhPCfkoAuYmlu?="; - filename*=UTF-8''%F0%9F%90%84%F0%9F%92%80.bin +Content-Type: image/bmp; + name="=?utf-8?B?8J+QhPCfkoAuYmlu?=" +Content-Disposition: attachment; + filename*0*=UTF-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