Repository: lettre/lettre (language: rust)

Issue:
Header Wrapping Broken
**Describe the bug**
Header wrapping can change the header field value.

**To Reproduce**

Try to send a header such as:

```smtp
List-Unsubscribe: <https://feedmail.org/subscriptions/6/unsubscribe?proof=o2FrAGF0WEDteqo5KL79w3eyLOxWPylNzCNNmx5Yt78Sn3BvWfgmVcFtrStO4DyZeGMBhaChfnGYHg8smG7aF1U4AcNlQANjYWROomFlGmNZoU1hcKFhcwY>
```

It gets sent as:

```smtp
List-Unsubscribe: <https://feedmail.org/subscriptions/6/unsubscribe?proof=o2
 FrAGF0WEDteqo5KL79w3eyLOxWPylNzCNNmx5Yt78Sn3BvWfgmVcFtrStO4DyZeGMBhaChfnGYH
 g8smG7aF1U4AcNlQANjYWROomFlGmNZoU1hcKFhcwY>
```

**Expected behavior**

The header is sent as

```smtp
List-Unsubscribe: <https://feedmail.org/subscriptions/6/unsubscribe?proof=o2FrAGF0WEDteqo5KL79w3eyLOxWPylNzCNNmx5Yt78Sn3BvWfgmVcFtrStO4DyZeGMBhaChfnGYHg8smG7aF1U4AcNlQANjYWROomFlGmNZoU1hcKFhcwY>
```

**Environment (please complete the following information):**
- Lettre version: a1290d6178ceda6b68ad1b66ade3769421a9807a
- OS: Linux

**Additional context**

> The contents of the list header fields mostly consist of angle-bracket ('<', '>') enclosed URLs, with internal whitespace being ignored. MTAs MUST NOT insert whitespace within the brackets...
>
> https://datatracker.ietf.org/doc/html/rfc2369#section-2

This case also shouldn't be a problem because the specification says that whitespace should be ignored in this case. However in general lettre makes some unsafe assumptions about wrapping.

1. It is assumed that you can wrap before a `,`.
2. It is assumed that you can cut a header anywhere.

IIUC neither of these assumptions are true and results in lettre corrupting headers that contain long sequences of non-whitespace characters and commas that are not valid fold points.

According to [rfc5322](https://datatracker.ietf.org/doc/html/rfc5322#section-2.2.3) the only general folding rule is adding a `\r\n` before whitespace characters (`[ \t]`). You can also fold at locations that **allow** whitespace by inserting a space to do the folding. However this requires knowledge of the specific header field and can break DKIM signatures (as it **does** change the byte value of the header field, it is just the semantic value that shouldn't change).



Discussion / hints:
It also appears that wrapping in general adds a space as it leaves the space at the end of the previous line and adds a new space at the start of the continuation line. IIUC the RFCs say that the space at the beginning of the new line is part of the header value.
I'll have to take a look at what should happen when a word in a header's value is more than 76 characters long. At the moment it adds a line break, which also introduces the extra space

https://github.com/lettre/lettre/blob/a89383cdb6f25144f81bdf34b80923d9a7349d8d/src/message/header/mod.rs#L361-L377
It is perfectly legal to have lines longer than 76 characters in SMTP. That
limit is just a recommendation. The RFC does say that you MUST NOT go over
998 characters (probably bytes actually) (1000 including the CRLF). I don't
know what do do in that case in general. Probably return an error?

To do the best possible job you also need per-header logic. Many headers
that are likely to be long include logic to allow wrapping them. For
example the DKIM signature field explicitly allows whitespace which is
ignored so that I can be wrapped to 76 characters.

I'm working on a hacky fix to get it working for my application (using
bytes instead of characters, removing forced breaks, not breaking on
commas, not inserting extra spaces upon wrapping, just hoping that lines
don't go over 1000 bytes but if they do just emitting long lines) but I
don't know if this will be suitable for upstream. Doing this properly would
require updating the header interface to include wrapping logic which is
fairly unfortunate.


Fix patch:
diff --git a/Cargo.toml b/Cargo.toml
index f27c33d2c..b6dfa6aa8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,7 +30,7 @@ fastrand = { version = "1.4", optional = true }
 quoted_printable = { version = "0.4", optional = true }
 base64 = { version = "0.13", optional = true }
 regex = { version = "1", default-features = false, features = ["std", "unicode-case"] }
-email-encoding = { git = "https://github.com/lettre/email-encoding.git", rev = "7b4fdf44a6e8715753d9012317c019271bcbc431", optional = true }
+email-encoding = { git = "https://github.com/lettre/email-encoding.git", rev = "ac7ab8630b8d012ade63869c89b2855e27b3ce7a", optional = true }
 
 # file transport
 uuid = { version = "1", features = ["v4"], optional = true }
@@ -88,7 +88,7 @@ name = "transport_smtp"
 
 [features]
 default = ["smtp-transport", "pool", "native-tls", "hostname", "builder"]
-builder = ["httpdate", "mime", "base64", "fastrand", "quoted_printable", "email-encoding"]
+builder = ["httpdate", "mime", "fastrand", "quoted_printable", "email-encoding"]
 mime03 = ["mime"]
 
 # transports
@@ -109,7 +109,7 @@ tokio1 = ["tokio1_crate", "async-trait", "futures-io", "futures-util"]
 tokio1-native-tls = ["tokio1", "native-tls", "tokio1_native_tls_crate"]
 tokio1-rustls-tls = ["tokio1", "rustls-tls", "tokio1_rustls"]
 
-dkim = ["sha2", "rsa", "ed25519-dalek"]
+dkim = ["base64", "sha2", "rsa", "ed25519-dalek"]
 
 [package.metadata.docs.rs]
 all-features = true
diff --git a/src/message/dkim.rs b/src/message/dkim.rs
index 01f08d836..e6bf7b795 100644
--- a/src/message/dkim.rs
+++ b/src/message/dkim.rs
@@ -480,7 +480,7 @@ cJ5Ku0OTwRtSMaseRPX+T4EfG1Caa/eunPPN4rh+CSup2BVVarOT
     fn test_headers_simple_canonicalize() {
         let message = test_message();
         dbg!(message.headers.to_string());
-        assert_eq!(dkim_canonicalize_headers(["From", "Test"], &message.headers, DkimCanonicalizationType::Simple), "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\nTest: test  test very very long with spaces and extra spaces   \twill be \r\n folded to several lines \r\n")
+        assert_eq!(dkim_canonicalize_headers(["From", "Test"], &message.headers, DkimCanonicalizationType::Simple), "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\nTest: test  test very very long with spaces and extra spaces   \twill be\r\n folded to several lines \r\n")
     }
 
     #[test]
@@ -521,18 +521,14 @@ cJ5Ku0OTwRtSMaseRPX+T4EfG1Caa/eunPPN4rh+CSup2BVVarOT
                 "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\n",
                 "To: Test2 <test2@example.org>\r\n",
                 "Date: Thu, 01 Jan 1970 00:00:00 +0000\r\n",
-                "Test: test  test very very long with spaces and extra spaces   \twill be \r\n",
+                "Test: test  test very very long with spaces and extra spaces   \twill be\r\n",
                 " folded to several lines \r\n",
                 "Subject: Test with utf-8 =?utf-8?b?w6s=?=\r\n",
                 "Content-Transfer-Encoding: 7bit\r\n",
-                "DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest; \r\n",
-                " c=simple/simple; q=dns/txt; t=0; h=Date:From:Subject:To; \r\n",
-                " bh=f3Zksdcjqa/xRBwdyFzIXWCcgP7XTgxjCgYsXOMKQl4=; b=UQWpUooKjVzgC7jtuEKdpCbz\r\n",
-                " sSDnOqZFg8+S7rZj89n/+AVsdcwxumeLCUYLeko2TZgVFJA7kGz+wLzH2wpzB4XnyUqrkF6PrFA\r\n",
-                " 9K11K365JDtzfMSc5eRVS8crO6F/A9QtXPndnzrXQ5HrtFgfxUlJ9cX6pTOor1NVCpfYUNBviIg\r\n",
-                " Am0LnUOKdlJ8z82kLFRpIqawMKNVfyqP8Es6H3NHM4Y1uwGgls9DM1+lZxNXxkMpoGV3rL/n/ai\r\n",
-                " s8+VifrRxPLB0mr9gbavSkCQ2QzUA/+iq8DgPCGpXDrdDrwTcrV3pL/iHyEjQZWwFSQkx+r/CGb\r\n",
-                " 8TQLqH6T3wfr69XWvg==\r\n",
+                "DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest;\r\n",
+                " c=simple/simple; q=dns/txt; t=0; h=Date:From:Subject:To;\r\n",
+                " bh=f3Zksdcjqa/xRBwdyFzIXWCcgP7XTgxjCgYsXOMKQl4=;\r\n",
+                " b=NhoIMMAALoSgu5lKAR0+MUQunOWnU7wpF9ORUFtpxq9sGZDo9AX43AMhFemyM5W204jpFwMU6pm7AMR1nOYBdSYye4yUALtvT2nqbJBwSh7JeYu+z22t1RFKp7qQR1il8aSrkbZuNMFHYuSEwW76QtKwcNqP4bQOzS9CzgQp0ABu8qwYPBr/EypykPTfqjtyN+ywrfdqjjGOzTpRGolH0hc3CrAETNjjHbNBgKgucXmXTN7hMRdzqWjeFPxizXwouwNAavFClPG0l33gXVArFWn+CkgA84G/s4zuJiF7QPZR87Pu4pw/vIlSXxH4a42W3tT19v9iBTH7X7ldYegtmQ==\r\n",
                 "\r\n",
                 "test\r\n",
                 "\r\n",
@@ -575,18 +571,13 @@ cJ5Ku0OTwRtSMaseRPX+T4EfG1Caa/eunPPN4rh+CSup2BVVarOT
                 "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\n",
                 "To: Test2 <test2@example.org>\r\n",
                 "Date: Thu, 01 Jan 1970 00:00:00 +0000\r\n",
-                "Test: test  test very very long with spaces and extra spaces   \twill be \r\n",
-                " folded to several lines \r\n",
-                "Subject: Test with utf-8 =?utf-8?b?w6s=?=\r\n",
+                "Test: test  test very very long with spaces and extra spaces   \twill be\r\n",
+                " folded to several lines \r\n","Subject: Test with utf-8 =?utf-8?b?w6s=?=\r\n",
                 "Content-Transfer-Encoding: 7bit\r\n",
-                "DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest; \r\n",
-                " c=relaxed/relaxed; q=dns/txt; t=0; h=date:from:subject:to; \r\n",
-                " bh=qN8je6qJgWFGSnN2MycC/XKPbN6BOrMJyAX2h4m19Ss=; b=YaVfmH8dbGEywoLJ4uhbvYqD\r\n",
-                " yQG1UGKFH3PE7zXGgk+YFxUgkwWjoA3aQupDNQtfTjfUsNe0dnrjyZP+ylnESpZBpbCIf5/n3FE\r\n",
-                " h6j3RQthqNbQblcfH/U8mazTuRbVjYBbTZQDaQCMPTz+8D+ZQfXo2oq6dGzTuGvmuYft0CVsq/B\r\n",
-                " Ip/EkhZHqiphDeVJSHD4iKW8+L2XwEWThoY92xOYc1G0TtBwz2UJgtiHX2YulH/kRBHeK3dKn9R\r\n",
-                " TNVL3VZ+9ZrnFwIhET9TPGtU2I+q0EMSWF9H9bTrASMgW/U+E0VM2btqJlrTU6rQ7wlQeHdwecL\r\n",
-                " nzXcyhCUInF1+veMNw==\r\n",
+                "DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest;\r\n",
+                " c=relaxed/relaxed; q=dns/txt; t=0; h=date:from:subject:to;\r\n",
+                " bh=qN8je6qJgWFGSnN2MycC/XKPbN6BOrMJyAX2h4m19Ss=;\r\n",
+                " b=YaVfmH8dbGEywoLJ4uhbvYqDyQG1UGKFH3PE7zXGgk+YFxUgkwWjoA3aQupDNQtfTjfUsNe0dnrjyZP+ylnESpZBpbCIf5/n3FEh6j3RQthqNbQblcfH/U8mazTuRbVjYBbTZQDaQCMPTz+8D+ZQfXo2oq6dGzTuGvmuYft0CVsq/BIp/EkhZHqiphDeVJSHD4iKW8+L2XwEWThoY92xOYc1G0TtBwz2UJgtiHX2YulH/kRBHeK3dKn9RTNVL3VZ+9ZrnFwIhET9TPGtU2I+q0EMSWF9H9bTrASMgW/U+E0VM2btqJlrTU6rQ7wlQeHdwecLnzXcyhCUInF1+veMNw==\r\n",
                 "\r\n",
                 "test\r\n",
                 "\r\n",
diff --git a/src/message/header/mod.rs b/src/message/header/mod.rs
index 6f5b1d46a..422e1acc4 100644
--- a/src/message/header/mod.rs
+++ b/src/message/header/mod.rs
@@ -3,10 +3,12 @@
 use std::{
     borrow::Cow,
     error::Error,
-    fmt::{self, Display, Formatter},
+    fmt::{self, Display, Formatter, Write},
     ops::Deref,
 };
 
+use email_encoding::headers::EmailWriter;
+
 pub use self::{
     content::*,
     content_disposition::ContentDisposition,
@@ -315,55 +317,36 @@ impl HeaderValue {
     }
 }
 
-const ENCODING_START_PREFIX: &str = "=?utf-8?b?";
-const ENCODING_END_SUFFIX: &str = "?=";
-const MAX_LINE_LEN: usize = 76;
-
 /// [RFC 1522](https://tools.ietf.org/html/rfc1522) header value encoder
-struct HeaderValueEncoder {
-    line_len: usize,
+struct HeaderValueEncoder<'a> {
+    writer: EmailWriter<'a>,
     encode_buf: String,
 }
 
-impl HeaderValueEncoder {
-    fn encode(name: &str, value: &str, f: &mut impl fmt::Write) -> fmt::Result {
-        let (words_iter, encoder) = Self::new(name, value);
-        encoder.format(words_iter, f)
+impl<'a> HeaderValueEncoder<'a> {
+    fn encode(name: &str, value: &'a str, f: &'a mut impl fmt::Write) -> fmt::Result {
+        let (words_iter, encoder) = Self::new(name, value, f);
+        encoder.format(words_iter)
     }
 
-    fn new<'a>(name: &str, value: &'a str) -> (WordsPlusFillIterator<'a>, Self) {
+    fn new(
+        name: &str,
+        value: &'a str,
+        writer: &'a mut dyn Write,
+    ) -> (WordsPlusFillIterator<'a>, Self) {
+        let line_len = name.len() + ": ".len();
+        let writer = EmailWriter::new(writer, line_len, false);
+
         (
             WordsPlusFillIterator { s: value },
             Self {
-                line_len: name.len() + ": ".len(),
+                writer,
                 encode_buf: String::new(),
             },
         )
     }
 
-    fn format(
-        mut self,
-        words_iter: WordsPlusFillIterator<'_>,
-        f: &mut impl fmt::Write,
-    ) -> fmt::Result {
-        /// Estimate if an encoded string of `len` would fix in an empty line
-        fn would_fit_new_line(len: usize) -> bool {
-            len < (MAX_LINE_LEN - " ".len())
-        }
-
-        /// Estimate how long a string of `len` would be after base64 encoding plus
-        /// adding the encoding prefix and suffix to it
-        fn base64_len(len: usize) -> usize {
-            ENCODING_START_PREFIX.len() + (len * 4 / 3 + 4) + ENCODING_END_SUFFIX.len()
-        }
-
-        /// Estimate how many more bytes we can fit in the current line
-        fn available_len_to_max_encode_len(len: usize) -> usize {
-            len.saturating_sub(
-                ENCODING_START_PREFIX.len() + (len * 3 / 4 + 4) + ENCODING_END_SUFFIX.len(),
-            )
-        }
-
+    fn format(mut self, words_iter: WordsPlusFillIterator<'_>) -> fmt::Result {
         for next_word in words_iter {
             let allowed = allowed_str(next_word);
 
@@ -371,161 +354,64 @@ impl HeaderValueEncoder {
                 // This word only contains allowed characters
 
                 // the next word is allowed, but we may have accumulated some words to encode
-                self.flush_encode_buf(f, true)?;
-
-                if next_word.len() > self.remaining_line_len() {
-                    // not enough space left on this line to encode word
-
-                    if self.something_written_to_this_line() && would_fit_new_line(next_word.len())
-                    {
-                        // word doesn't fit this line, but something had already been written to it,
-                        // and word would fit the next line, so go to a new line
-                        // so go to new line
-                        self.new_line(f)?;
-                    } else {
-                        // word neither fits this line and the next one, cut it
-                        // in the middle and make it fit
-
-                        let mut next_word = next_word;
-
-                        while !next_word.is_empty() {
-                            if self.remaining_line_len() == 0 {
-                                self.new_line(f)?;
-                            }
-
-                            let len = self.remaining_line_len().min(next_word.len());
-                            let first_part = &next_word[..len];
-                            next_word = &next_word[len..];
-
-                            f.write_str(first_part)?;
-                            self.line_len += first_part.len();
-                        }
-
-                        continue;
-                    }
-                }
-
-                // word fits, write it!
-                f.write_str(next_word)?;
-                self.line_len += next_word.len();
+                self.flush_encode_buf()?;
+
+                self.writer.folding().write_str(next_word)?;
             } else {
                 // This word contains unallowed characters
-
-                if self.remaining_line_len() >= base64_len(self.encode_buf.len() + next_word.len())
-                {
-                    // next_word fits
-                    self.encode_buf.push_str(next_word);
-                    continue;
-                }
-
-                // next_word doesn't fit this line
-
-                if would_fit_new_line(base64_len(next_word.len())) {
-                    // ...but it would fit the next one
-
-                    self.flush_encode_buf(f, false)?;
-                    self.new_line(f)?;
-
-                    self.encode_buf.push_str(next_word);
-                    continue;
-                }
-
-                // ...and also wouldn't fit the next one.
-                // chop it up into pieces
-
-                let mut next_word = next_word;
-
-                while !next_word.is_empty() {
-                    let mut len = available_len_to_max_encode_len(self.remaining_line_len())
-                        .min(next_word.len());
-
-                    if len == 0 {
-                        self.flush_encode_buf(f, false)?;
-                        self.new_line(f)?;
-                    }
-
-                    // avoid slicing on a char boundary
-                    while !next_word.is_char_boundary(len) {
-                        len += 1;
-                    }
-                    let first_part = &next_word[..len];
-                    next_word = &next_word[len..];
-
-                    self.encode_buf.push_str(first_part);
-                }
+                self.encode_buf.push_str(next_word);
             }
         }
 
-        self.flush_encode_buf(f, false)?;
+        self.flush_encode_buf()?;
 
         Ok(())
     }
 
-    /// Returns the number of bytes left for the current line
-    fn remaining_line_len(&self) -> usize {
-        MAX_LINE_LEN - self.line_len
-    }
-
-    /// Returns true if something has been written to the current line
-    fn something_written_to_this_line(&self) -> bool {
-        self.line_len > 1
-    }
-
-    fn flush_encode_buf(
-        &mut self,
-        f: &mut impl fmt::Write,
-        switching_to_allowed: bool,
-    ) -> fmt::Result {
+    fn flush_encode_buf(&mut self) -> fmt::Result {
         if self.encode_buf.is_empty() {
             // nothing to encode
             return Ok(());
         }
 
-        let mut write_after = None;
-
-        if switching_to_allowed {
-            // If the next word only contains allowed characters, and the string to encode
-            // ends with a space, take the space out of the part to encode
+        // It is important that we don't encode leading whitespace otherwise it breaks wrapping.
+        let first_not_allowed = self
+            .encode_buf
+            .bytes()
+            .enumerate()
+            .find(|(_i, c)| !allowed_char(*c))
+            .map(|(i, _)| i);
+        // May as well also write the tail in plain text.
+        let last_not_allowed = self
+            .encode_buf
+            .bytes()
+            .enumerate()
+            .rev()
+            .find(|(_i, c)| !allowed_char(*c))
+            .map(|(i, _)| i + 1);
 
-            let last_char = self.encode_buf.pop().expect("self.encode_buf isn't empty");
-            if is_space_like(last_char) {
-                write_after = Some(last_char);
-            } else {
-                self.encode_buf.push(last_char);
-            }
-        }
+        let (prefix, to_encode, suffix) = match first_not_allowed {
+            Some(first_not_allowed) => {
+                let last_not_allowed = last_not_allowed.unwrap();
 
-        f.write_str(ENCODING_START_PREFIX)?;
-        let encoded = base64::display::Base64Display::with_config(
-            self.encode_buf.as_bytes(),
-            base64::STANDARD,
-        );
-        write!(f, "{}", encoded)?;
-        f.write_str(ENCODING_END_SUFFIX)?;
+                let (remaining, suffix) = self.encode_buf.split_at(last_not_allowed);
+                let (prefix, to_encode) = remaining.split_at(first_not_allowed);
 
-        self.line_len += ENCODING_START_PREFIX.len();
-        self.line_len += self.encode_buf.len() * 4 / 3 + 4;
-        self.line_len += ENCODING_END_SUFFIX.len();
+                (prefix, to_encode, suffix)
+            }
+            None => ("", self.encode_buf.as_str(), ""),
+        };
 
-        if let Some(write_after) = write_after {
-            f.write_char(write_after)?;
-            self.line_len += 1;
-        }
+        self.writer.folding().write_str(prefix)?;
+        email_encoding::headers::rfc2047::encode(to_encode, &mut self.writer)?;
+        self.writer.folding().write_str(suffix)?;
 
         self.encode_buf.clear();
         Ok(())
     }
-
-    fn new_line(&mut self, f: &mut impl fmt::Write) -> fmt::Result {
-        f.write_str("\r\n ")?;
-        self.line_len = 1;
-
-        Ok(())
-    }
 }
 
-/// Iterator yielding a string split space by space, but including all space
-/// characters between it and the next word
+/// Iterator yielding a string split by space, but spaces are included before the next word.
 struct WordsPlusFillIterator<'a> {
     s: &'a str,
 }
@@ -540,31 +426,25 @@ impl<'a> Iterator for WordsPlusFillIterator<'a> {
 
         let next_word = self
             .s
-            .char_indices()
+            .bytes()
+            .enumerate()
             .skip(1)
-            .skip_while(|&(_i, c)| !is_space_like(c))
-            .find(|&(_i, c)| !is_space_like(c))
-            .map(|(i, _)| i);
+            .find(|&(_i, c)| c == b' ')
+            .map(|(i, _)| i)
+            .unwrap_or(self.s.len());
 
-        let word = &self.s[..next_word.unwrap_or(self.s.len())];
+        let word = &self.s[..next_word];
         self.s = &self.s[word.len()..];
         Some(word)
     }
 }
 
-const fn is_space_like(c: char) -> bool {
-    c == ',' || c == ' '
-}
-
 fn allowed_str(s: &str) -> bool {
-    s.chars().all(allowed_char)
+    s.bytes().all(allowed_char)
 }
 
-const fn allowed_char(c: char) -> bool {
-    c >= 1 as char && c <= 9 as char
-        || c == 11 as char
-        || c == 12 as char
-        || c >= 14 as char && c <= 127 as char
+const fn allowed_char(c: u8) -> bool {
+    c >= 1 && c <= 9 || c == 11 || c == 12 || c >= 14 && c <= 127
 }
 
 #[cfg(test)]
@@ -654,8 +534,8 @@ mod tests {
         assert_eq!(
             headers.to_string(),
             concat!(
-                "To: Ascii <example@example.com>, John Doe <johndoe@example.com, John Smith \r\n",
-                " <johnsmith@example.com>, Pinco Pallino <pincopallino@example.com>, Jemand \r\n",
+                "To: Ascii <example@example.com>, John Doe <johndoe@example.com, John Smith\r\n",
+                " <johnsmith@example.com>, Pinco Pallino <pincopallino@example.com>, Jemand\r\n",
                 " <jemand@example.com>, Jean Dupont <jean@example.com>\r\n"
             )
         );
@@ -672,8 +552,8 @@ mod tests {
         assert_eq!(
             headers.to_string(),
             concat!(
-                "Subject: Hello! This is lettre, and this \r\n ",
-                "IsAVeryLongLineDoYouKnowWhatsGoingToHappenIGuessWeAreGoingToFindOut. Ok I \r\n",
+                "Subject: Hello! This is lettre, and this\r\n",
+                " IsAVeryLongLineDoYouKnowWhatsGoingToHappenIGuessWeAreGoingToFindOut. Ok I\r\n",
                 " guess that's it!\r\n"
             )
         );
@@ -691,8 +571,9 @@ mod tests {
         assert_eq!(
             headers.to_string(),
             concat!(
-                "Subject: Hello! IGuessTheLastLineWasntLongEnoughSoLetsTryAgainShallWeWhatDoY\r\n",
-                " ouThinkItsGoingToHappenIGuessWereAboutToFindOut! I don't know\r\n",
+                "Subject: Hello!\r\n",
+                " IGuessTheLastLineWasntLongEnoughSoLetsTryAgainShallWeWhatDoYouThinkItsGoingToHappenIGuessWereAboutToFindOut!\r\n",
+                " I don't know\r\n",
             )
         );
     }
@@ -707,11 +588,7 @@ mod tests {
 
         assert_eq!(
             headers.to_string(),
-            concat!(
-                "Subject: 1abcdefghijklmnopqrstuvwxyz2abcdefghijklmnopqrstuvwxyz3abcdefghijkl\r\n",
-                " mnopqrstuvwxyz4abcdefghijklmnopqrstuvwxyz5abcdefghijklmnopqrstuvwxyz6abcdef\r\n",
-                " ghijklmnopqrstuvwxyz\r\n",
-            )
+            "Subject: 1abcdefghijklmnopqrstuvwxyz2abcdefghijklmnopqrstuvwxyz3abcdefghijklmnopqrstuvwxyz4abcdefghijklmnopqrstuvwxyz5abcdefghijklmnopqrstuvwxyz6abcdefghijklmnopqrstuvwxyz\r\n",
         );
     }
 
@@ -725,7 +602,7 @@ mod tests {
 
         assert_eq!(
             headers.to_string(),
-            "To: =?utf-8?b?U2XDoW4=?= <sean@example.com>\r\n"
+            "To: Se=?utf-8?b?w6E=?=n <sean@example.com>\r\n"
         );
     }
 
@@ -754,11 +631,11 @@ mod tests {
         assert_eq!(
             headers.to_string(),
             concat!(
-                "To: =?utf-8?b?8J+MjQ==?= <world@example.com>, =?utf-8?b?8J+mhg==?= \r\n",
-                " Everywhere <ducks@example.com>, =?utf-8?b?0JjQstCw0L3QvtCyIA==?=\r\n",
-                " =?utf-8?b?0JjQstCw0L0g0JjQstCw0L3QvtCy0LjRhw==?= <ivanov@example.com>, \r\n",
-                " =?utf-8?b?SsSBbmlzIELEk3J6acWGxaE=?= <janis@example.com>, \r\n",
-                " =?utf-8?b?U2XDoW4gw5MgUnVkYcOt?= <sean@example.com>\r\n"
+                "To: =?utf-8?b?8J+MjQ==?= <world@example.com>, =?utf-8?b?8J+mhg==?= Everywhere\r\n",
+                " <ducks@example.com>, =?utf-8?b?0JjQstCw0L3QvtCyINCY0LLQsNC9INCY0LLQsNC9?=\r\n",
+                " =?utf-8?b?0L7QstC40Yc=?= <ivanov@example.com>, J=?utf-8?b?xIFuaXMgQsST?=\r\n",
+                " =?utf-8?b?cnppxYbFoQ==?= <janis@example.com>, Se=?utf-8?b?w6FuIMOTIFJ1?=\r\n",
+                " =?utf-8?b?ZGHDrQ==?= <sean@example.com>\r\n",
             )
         );
     }
@@ -774,7 +651,14 @@ mod tests {
 
         assert_eq!(
             headers.to_string(),
-            "Subject: =?utf-8?b?8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz?=\r\n"
+            concat!(
+                "Subject: =?utf-8?b?8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz?=\r\n",
+                " =?utf-8?b?8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbM=?=\r\n",
+                " =?utf-8?b?8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbM=?=\r\n",
+                " =?utf-8?b?8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbM=?=\r\n",
+                " =?utf-8?b?8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+ls/CfpbM=?=\r\n",
+                " =?utf-8?b?8J+ls/CfpbPwn6Wz8J+ls/CfpbPwn6Wz8J+lsw==?=\r\n"
+            )
         );
     }
 
@@ -819,14 +703,14 @@ mod tests {
         assert_eq!(
             headers.to_string(),
             concat!(
-                "Subject: Hello! This is lettre, and this \r\n",
-                " IsAVeryLongLineDoYouKnowWhatsGoingToHappenIGuessWeAreGoingToFindOut. Ok I \r\n",
+                "Subject: Hello! This is lettre, and this\r\n",
+                " IsAVeryLongLineDoYouKnowWhatsGoingToHappenIGuessWeAreGoingToFindOut. Ok I\r\n",
                 " guess that's it!\r\n",
-                "To: =?utf-8?b?8J+MjQ==?= <world@example.com>, =?utf-8?b?8J+mhg==?= \r\n",
-                " Everywhere <ducks@example.com>, =?utf-8?b?0JjQstCw0L3QvtCyIA==?=\r\n",
-                " =?utf-8?b?0JjQstCw0L0g0JjQstCw0L3QvtCy0LjRhw==?= <ivanov@example.com>, \r\n",
-                " =?utf-8?b?SsSBbmlzIELEk3J6acWGxaE=?= <janis@example.com>, \r\n",
-                " =?utf-8?b?U2XDoW4gw5MgUnVkYcOt?= <sean@example.com>\r\n",
+                "To: =?utf-8?b?8J+MjQ==?= <world@example.com>, =?utf-8?b?8J+mhg==?= Everywhere\r\n",
+                " <ducks@example.com>, =?utf-8?b?0JjQstCw0L3QvtCyINCY0LLQsNC9INCY0LLQsNC9?=\r\n",
+                " =?utf-8?b?0L7QstC40Yc=?= <ivanov@example.com>, J=?utf-8?b?xIFuaXMgQsST?=\r\n",
+                " =?utf-8?b?cnppxYbFoQ==?= <janis@example.com>, Se=?utf-8?b?w6FuIMOTIFJ1?=\r\n",
+                " =?utf-8?b?ZGHDrQ==?= <sean@example.com>\r\n",
                 "From: Someone <somewhere@example.com>\r\n",
                 "Content-Transfer-Encoding: quoted-printable\r\n",
             )
@@ -844,8 +728,8 @@ mod tests {
         assert_eq!(
             headers.to_string(),
             concat!(
-                "Subject: =?utf-8?b?77yL5Luu5ZCN?= :a;go; \r\n",
-                " =?utf-8?b?Ozs7OztzOzs7Ozs7Ozs7Ozs7Ozs7O2ZmZmVpbm1qZ2dnZ2dnZ2dn772G44Gj?=\r\n"
+                "Subject: =?utf-8?b?77yL5Luu5ZCN?= :a;go;\r\n",
+                " ;;;;;s;;;;;;;;;;;;;;;;fffeinmjggggggggg=?utf-8?b?772G44Gj?=\r\n"
             )
         );
     }
diff --git a/src/message/mimebody.rs b/src/message/mimebody.rs
index 896626b61..5b7ca5faf 100644
--- a/src/message/mimebody.rs
+++ b/src/message/mimebody.rs
@@ -479,7 +479,7 @@ mod test {
         assert_eq!(
             String::from_utf8(part.formatted()).unwrap(),
             concat!(
-                "Content-Type: multipart/mixed; \r\n",
+                "Content-Type: multipart/mixed;\r\n",
                 " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
                 "\r\n",
                 "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
@@ -526,8 +526,8 @@ mod test {
         assert_eq!(
             String::from_utf8(part.formatted()).unwrap(),
             concat!(
-                "Content-Type: multipart/encrypted; \r\n",
-                " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"; \r\n",
+                "Content-Type: multipart/encrypted;\r\n",
+                " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\";\r\n",
                 " protocol=\"application/pgp-encrypted\"\r\n",
                 "\r\n",
                 "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
@@ -582,8 +582,8 @@ mod test {
         assert_eq!(
             String::from_utf8(part.formatted()).unwrap(),
             concat!(
-                "Content-Type: multipart/signed; \r\n",
-                " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"; \r\n",
+                "Content-Type: multipart/signed;\r\n",
+                " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\";\r\n",
                 " protocol=\"application/pgp-signature\";",
                 " micalg=\"pgp-sha256\"\r\n",
                 "\r\n",
@@ -624,7 +624,7 @@ mod test {
                              .body(String::from("<p>Текст <em>письма</em> в <a href=\"https://ru.wikipedia.org/wiki/Юникод\">уникоде</a><p>")));
 
         assert_eq!(String::from_utf8(part.formatted()).unwrap(),
-                   concat!("Content-Type: multipart/alternative; \r\n",
+                   concat!("Content-Type: multipart/alternative;\r\n",
                            " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
                            "\r\n",
                            "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
@@ -662,11 +662,11 @@ mod test {
                              .body(String::from("int main() { return 0; }")));
 
         assert_eq!(String::from_utf8(part.formatted()).unwrap(),
-                   concat!("Content-Type: multipart/mixed; \r\n",
+                   concat!("Content-Type: multipart/mixed;\r\n",
                            " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
                            "\r\n",
                            "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
-                           "Content-Type: multipart/related; \r\n",
+                           "Content-Type: multipart/related;\r\n",
                            " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
                            "\r\n",
                            "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
diff --git a/src/message/mod.rs b/src/message/mod.rs
index de0a04117..60ffbe933 100644
--- a/src/message/mod.rs
+++ b/src/message/mod.rs
@@ -655,7 +655,7 @@ mod test {
                 "Date: Tue, 15 Nov 1994 08:12:31 +0000\r\n",
                 "From: =?utf-8?b?0JrQsNC4?= <kayo@example.com>\r\n",
                 "To: \"Pony O.P.\" <pony@domain.tld>\r\n",
-                "Subject: =?utf-8?b?0Y/So9CwINC10Lsg0LHQtdC705nQvSE=?=\r\n",
+                "Subject: =?utf-8?b?0Y/So9CwINC10Lsg0LHQtdC705nQvQ==?=!\r\n",
                 "Content-Transfer-Encoding: 7bit\r\n",
                 "\r\n",
                 "Happy new year!"


Test patch:
diff --git a/testdata/email_with_png.eml b/testdata/email_with_png.eml
index fc5f49686..59c4d49fc 100644
--- a/testdata/email_with_png.eml
+++ b/testdata/email_with_png.eml
@@ -4,7 +4,7 @@ Reply-To: Yuin <yuin@domain.tld>
 To: Hei <hei@domain.tld>
 Subject: Happy new year
 MIME-Version: 1.0
-Content-Type: multipart/related; 
+Content-Type: multipart/related;
  boundary="GUEEoEeTXtLcK2sMhmH1RfC1co13g4rtnRUFjQFA"
 
 --0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1