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 BEDD41FF13C for ; Thu, 19 Feb 2026 15:59:05 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 2780E19FD3; Thu, 19 Feb 2026 16:00:06 +0100 (CET) From: Shannon Sterz To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox v2] sendmail: conform more to mime Content-Disposition header and wrap lines Date: Thu, 19 Feb 2026 15:58:04 +0100 Message-ID: <20260219145805.1114672-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: 1771513078149 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.089 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: DOBR6OMTXJC2AXHCZZNAAIRC73WBG2TM X-Message-ID-Hash: DOBR6OMTXJC2AXHCZZNAAIRC73WBG2TM 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 version the following would be valid according to rfc 6266 [1]: 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 [2] the above should be expressed like so for mime messages (one variant, multiple are supported): Content-Disposition: attachment; filename*0*=UTF-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=pmg-api.git;a=commitdiff;h=66f51c62f789b4c20308b7594fbbb357721b0844 [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 = &CONTROLS /// 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; - let encoded = proxmox_base64::encode(raw); - let bytes = encoded.as_bytes(); - let lines = bytes.len().div_ceil(TEXT_WIDTH); - let mut out = Vec::with_capacity(bytes.len() + lines - 1); // account 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 = format!("UTF-8''{}", utf8_percent_encode(filename, RFC5987SET)); + + let format_prefix = |l| { + if let Some(l) = l { + format!("\tfilename*{l}*=") + } else { + "\tfilename*NN*=".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 = 72; + + let bytes = text.as_bytes(); + let suffix = suffix.as_bytes(); + + let format_text_len = suffix.len() + prefix(None).len(); + let lines = (bytes.len() + shorten_first).div_ceil(DEFAULT_TEXT_WIDTH - format_text_len); + + // bytes + formatting and "\n" per line - no "\n" in the last line + let mut capacity = bytes.len() + (format_text_len + 1) * lines - 1; + + if skip_last_suffix { + capacity -= suffix.len(); + } + + let mut out = Vec::with_capacity(capacity); + let first_line_length = DEFAULT_TEXT_WIDTH - shorten_first - format_text_len; + + let (total_lines, bytes) = + if let Some((first, rest)) = 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 = line + total_lines; + out.extend_from_slice(prefix(Some(line)).as_bytes()); out.extend_from_slice(chunk); + if line + 1 != 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 always 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 = 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 encoded = proxmox_base64::encode(&self.filename); + let prefix_fn = |line| { + if Some(0) == line { + "=?utf-8?B?" + } else { + "\t=?utf-8?B?" + } + .into() + }; + + // minus one here as the first line isn't indented + let skip_first = "\tname=\"".len() - 1; + + let _ = write!( + attachment, + "{}", + format_text(&encoded, prefix_fn, "?=", skip_first, false) + ); + } + + // should be fine to add one here, the last line should be at most 72 chars. according to + // rfc 2045 encoded output lines should be "no more than 76 characters each" + 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"); @@ -809,9 +886,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 +916,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 +957,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+Sg?= + =?utf-8?B?PCfkITwn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE?= + =?utf-8?B?8J+SgPCfkITwn5KA8J+QhPCfkoDwn5CE8J+SgPCfkITwn5KA8J+QhPCfkoA?= + =?utf-8?B?uYmlu?=" +Content-Disposition: attachment; + filename*0*=UTF-8''%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F; + filename*1*=0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%8; + filename*2*=4%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%9; + filename*3*=2%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9; + filename*4*=F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F; + filename*5*=0%9F%92%80%F0%9F%90%84%F0%9F%92%80%F0%9F%90%84%F0%9F%92%8; + filename*6*=0%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 @@ 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