From: Shannon Sterz <s.sterz@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [PATCH proxmox v3] sendmail: conform more to mime Content-Disposition header and wrap lines
Date: Wed, 25 Feb 2026 12:21:06 +0100 [thread overview]
Message-ID: <20260225112107.99905-1-s.sterz@proxmox.com> (raw)
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 <s.sterz@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
---
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:
---------
changes since v2 (thanks @ Lukas Wagner):
* add more documentation on how the new formatting helper works.
* add test cases for the formatting helper itself.
* fixed a bug when formatting text that wouldn't exceed a line but
should have its suffix skipped in the last line, would not have
skipped the suffix.
changes since v1:
* 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 | 338 +++++++++++++++++++++++++++++++-----
1 file changed, 299 insertions(+), 39 deletions(-)
diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs
index f5b04afd..82a8a98e 100644
--- a/proxmox-sendmail/src/lib.rs
+++ b/proxmox-sendmail/src/lib.rs
@@ -44,23 +44,111 @@ const RFC5987SET: &AsciiSet = &CONTROLS
/// base64 encode and hard-wrap the base64 encoded string every 72 characters. this improves
/// compatibility.
fn encode_base64_formatted<T: AsRef<[u8]>>(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_encoded_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_encoded_text(&encoded, format_prefix, ";", 0, true)
+}
+
+/// Helper function used to format text to not exceed line limits imposed by standards such as RFC
+/// 2045 Section 6.8 that specifies that Base64 content transfer encoded text "must be represented
+/// in lines of no more than 76 characters each". It also allows taking into account any necessary
+/// pre- and suffixes as well as shortening the first line by a certain amounts of characters.
+///
+/// Currently this errs on the side of caution and wraps lines at 72 characters. By doing so,
+/// callers can add a couple of characters to the last line of the text, which is sometimes needed
+/// to conform to how certain header values in MIME messages need to be represented.
+///
+/// Parameters:
+/// * `text`: The text that will be formatted.
+/// * `prefix`: A closure that computes the prefix needed for each line. It takes an
+/// `Option<usize>` as input. If the input is `None`, a prefix that is used as size hint for
+/// buffer allocation should be returned. `Some(n)` as input, should produce the prefix for the
+/// n-th line of the output text.
+/// * `suffix`: A string slice that will be appended to the end of each line of output text.
+/// * `shorten_first`: Defines by how many characters the first line will be shortened compared to
+/// the rest of the text. Useful for formatting header values, since there the first line often
+/// needs to be prefaced with the name of a key in a key value structure or similar.
+/// * `skip_last_suffix`: If true, no suffix will be appended to the last line.
+fn format_encoded_text<F>(
+ text: &str,
+ prefix: F,
+ suffix: &str,
+ shorten_first: usize,
+ skip_last_suffix: bool,
+) -> String
+where
+ F: Fn(Option<usize>) -> String,
+{
+ const DEFAULT_TEXT_WIDTH: usize = 72;
+
+ let bytes = text.as_bytes();
+ let suffix = suffix.as_bytes();
+
+ // compute the number of lines by taking into account skipped characters in the first line and
+ // formatting text
+ 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;
+
+ // format first line if we skip characters
+ let (existing_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);
+
+ if !rest.is_empty() {
+ out.extend_from_slice(suffix);
+ out.push(b'\n');
+ } else if !skip_last_suffix {
+ out.extend_from_slice(suffix);
+ }
+
+ (1, rest)
+ } else {
+ (0, bytes)
+ };
+
+ // format the rest of the text
+ for (line, chunk) in bytes
+ .chunks(DEFAULT_TEXT_WIDTH - format_text_len)
+ .enumerate()
+ {
+ let total_lines = existing_lines + line;
+ out.extend_from_slice(prefix(Some(total_lines)).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.
+
+ if total_lines + 1 != lines {
+ 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 +192,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_encoded_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");
@@ -541,6 +646,9 @@ impl<'a> Mail<'a> {
mod test {
use super::*;
+ // helper string for formatting tests
+ const ALPHA: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
+
/// Compare two multi-line strings, ignoring any line that starts with 'Date:'.
///
/// The `Date` header is formatted in the local timezone, which means our
@@ -809,9 +917,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 +947,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("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Sit Amet\n\t</pre>\n</body></html>");
let body = mail.format_mail(1732806251).expect("could not format mail");
@@ -879,17 +988,28 @@ Content-Transfer-Encoding: 7bit
</body></html>
------_=_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 +1117,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
@@ -1015,4 +1137,142 @@ Content-Transfer-Encoding: base64
------_=_NextPart_001_1732806251--"#,
)
}
+
+ #[test]
+ fn format_encoded_text_correctly() {
+ let simple = format_encoded_text(ALPHA, |_| "".to_string(), "", 0, true);
+
+ assert_eq!(simple, ALPHA);
+ }
+
+ #[test]
+ fn format_encoded_text_multiple_lines() {
+ let multiple_lines = format_encoded_text(
+ &format!("{ALPHA}{ALPHA}{ALPHA}"),
+ |_| "".to_string(),
+ "",
+ 0,
+ true,
+ );
+
+ let out = r#"
+ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJ
+KLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRST
+UVWXYZabcdefghijklmnopqrstuvwxyz1234567890"#
+ .trim();
+
+ assert_eq!(multiple_lines, out);
+ }
+
+ #[test]
+ fn format_encoded_text_suffixed() {
+ let suffixed = format_encoded_text(ALPHA, |_| "".to_string(), ";", 0, false);
+
+ let out = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890;";
+
+ assert_eq!(suffixed, out);
+
+ let suffixed = format_encoded_text(ALPHA, |_| "".to_string(), ";", 0, true);
+
+ let out = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
+
+ assert_eq!(suffixed, out);
+
+ let suffixed = format_encoded_text(
+ &format!("{ALPHA}{ALPHA}{ALPHA}"),
+ |_| "".to_string(),
+ ";",
+ 0,
+ false,
+ );
+
+ let out = r#"
+ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHI;
+JKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQR;
+STUVWXYZabcdefghijklmnopqrstuvwxyz1234567890;"#
+ .trim();
+
+ assert_eq!(suffixed, out);
+
+ let suffixed = format_encoded_text(
+ &format!("{ALPHA}{ALPHA}{ALPHA}"),
+ |_| "".to_string(),
+ ";",
+ 0,
+ true,
+ );
+
+ let out = r#"
+ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHI;
+JKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQR;
+STUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"#
+ .trim();
+
+ assert_eq!(suffixed, out);
+ }
+
+ #[test]
+ fn format_encoded_text_prefixed() {
+ let suffixed = format_encoded_text(ALPHA, |_| "==".to_string(), "", 0, false);
+
+ let out = "==ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
+
+ assert_eq!(suffixed, out);
+
+ let suffixed = format_encoded_text(
+ &format!("{ALPHA}{ALPHA}{ALPHA}"),
+ |_| "==".to_string(),
+ "",
+ 0,
+ false,
+ );
+
+ let out = r#"
+==ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGH
+==IJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOP
+==QRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"#
+ .trim();
+
+ assert_eq!(suffixed, out);
+
+ let suffixed = format_encoded_text(
+ &format!("{ALPHA}{ALPHA}{ALPHA}"),
+ |i| {
+ if let Some(n) = i {
+ format!("={n}=")
+ } else {
+ "===".to_string()
+ }
+ },
+ "",
+ 0,
+ false,
+ );
+
+ let out = r#"
+=0=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFG
+=1=HIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMN
+=2=OPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"#
+ .trim();
+
+ assert_eq!(suffixed, out);
+ }
+
+ #[test]
+ fn format_encoded_text_skip_first() {
+ let suffixed = format_encoded_text(ALPHA, |_| "".to_string(), "", 5, false);
+
+ let out = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890";
+
+ assert_eq!(suffixed, out);
+
+ let suffixed = format_encoded_text(ALPHA, |_| "".to_string(), "", 20, false);
+
+ let out = r#"
+ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
+1234567890"#
+ .trim();
+
+ assert_eq!(suffixed, out);
+ }
}
--
2.47.3
reply other threads:[~2026-02-25 11:20 UTC|newest]
Thread overview: [no followups] expand[flat|nested] mbox.gz Atom feed
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260225112107.99905-1-s.sterz@proxmox.com \
--to=s.sterz@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.