This is an automated email from the ASF dual-hosted git repository. chibenwa pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-mime4j.git
commit 4a0f8218c61fdde09316a4c9f97c10630fd7b50e Author: Benoit TELLIER <[email protected]> AuthorDate: Wed Nov 4 15:08:55 2026 +0100 [FIX] QuotedPrintable: keep lone CR and LF through a decode / encode round trip On a malformed escape such as "=2" CRLF, QuotedPrintableInputStream wrote the byte after the escape as data even when it was a CR, so the CRLF became "\r\r\n". The text-mode QuotedPrintableOutputStream then lost a CR followed by another CR, a pending CR after a pending blank, and wrote any other lone CR or LF unencoded, which the decoder turns into CRLF (or rejects in strict mode). DefaultMessageWriter therefore changed such bodies. The decoder now leaves only "=" and the first byte as is and handles the third one as usual; the encoder keeps only CRLF as a line break and escapes any other CR or LF (=0D, =0A), as RFC 2045 6.7 rule (4) requires. Fixes #109 --- .../mime4j/codec/QuotedPrintableInputStream.java | 5 +++- .../mime4j/codec/QuotedPrintableOutputStream.java | 12 ++++++--- .../codec/QuotedPrintableInputStreamTest.java | 7 +++++ .../codec/QuotedPrintableTextEncodeTest.java | 17 ++++++++++-- .../resources/mimetools-testmsgs/german-qp.out | 24 +++++------------ .../mime4j/message/DefaultMessageWriterTest.java | 31 ++++++++++++++++++++++ 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java index ee5ce536..f90e0af7 100644 --- a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java +++ b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableInputStream.java @@ -282,7 +282,10 @@ public class QuotedPrintableInputStream extends InputStream { // TODO see MIME4J-160 index = transfer(EQ, buffer, index, to, true); index = transfer(b2, buffer, index, to, false); - index = transfer(b3, buffer, index, to, false); + if (b3 != -1) { + // b3 may be a line break or a blank: give it the usual handling + pos--; + } } else { index = transfer((upper << 4) | lower, buffer, index, to, true); } diff --git a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java index b37f3e3c..5c572a80 100644 --- a/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java +++ b/core/src/main/java/org/apache/james/mime4j/codec/QuotedPrintableOutputStream.java @@ -91,8 +91,10 @@ public class QuotedPrintableOutputStream extends FilterOutputStream { plain(SP); } else if (pendingTab) { plain(TB); - } else if (pendingCR) { - plain(CR); + } + if (pendingCR) { + // a CR that is not followed by LF is not a line break + escape(CR); } clearPending(); } @@ -120,14 +122,18 @@ public class QuotedPrintableOutputStream extends FilterOutputStream { lineBreak(); clearPending(); } else { + // a LF that is not preceded by CR is not a line break writePending(); - plain(next); + escape(next); } } } else if (next == CR) { if (binary) { escape(next); } else { + if (pendingCR) { + writePending(); + } pendingCR = true; } } else { diff --git a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java index 4ed94a3b..0539396d 100644 --- a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java +++ b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableInputStreamTest.java @@ -277,4 +277,11 @@ public class QuotedPrintableInputStreamTest { } } } + + @Test + public void malformedEscapeBeforeLineBreakKeepsTheLineBreak() throws Exception { + InputStream in = new QuotedPrintableInputStream(InputStreams.createAscii("a=2\r\nb\r\n"), DecodeMonitor.SILENT); + // "=2" is left as is (MIME4J-160), the CRLF stays one line break + Assert.assertEquals("a=2\r\nb\r\n", readText(in)); + } } diff --git a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java index 528dc689..d48cbe27 100644 --- a/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java +++ b/core/src/test/java/org/apache/james/mime4j/codec/QuotedPrintableTextEncodeTest.java @@ -116,12 +116,25 @@ public class QuotedPrintableTextEncodeTest { @Test public void testDontEscapeLF() throws Exception { - check("Ready\nFor\n", "Ready\nFor\n"); + check("Ready\nFor\n", "Ready=0AFor=0A"); } @Test public void testDontEscapeCR() throws Exception { - check("Ready\rFor\r", "Ready\rFor\r"); + check("Ready\rFor\r", "Ready=0DFor=0D"); + } + + @Test + public void testLoneCarriageReturnsAndLineFeedsRoundTrip() throws Exception { + check("a\r\r\nb\rc", "a=0D\r\nb=0Dc"); + check("a \rb\t\r", "a =0Db\t=0D"); + check("a\n\r\n", "a=0A\r\n"); + byte[] content = ContentUtil.toAsciiByteArray("a\r\r\nb\rc \r\t\n\n\r"); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + EncoderUtil.encodeQ(InputStreams.create(content), out); + // strict decoding: the encoder must not write a bare CR or LF + InputStream in = new QuotedPrintableInputStream(InputStreams.create(out.toByteArray()), DecodeMonitor.STRICT); + assertEquals(content, ContentUtil.buffer(in)); } @Test diff --git a/core/src/test/resources/mimetools-testmsgs/german-qp.out b/core/src/test/resources/mimetools-testmsgs/german-qp.out index 3075aa0e..1b6d3fb3 100644 --- a/core/src/test/resources/mimetools-testmsgs/german-qp.out +++ b/core/src/test/resources/mimetools-testmsgs/german-qp.out @@ -9,21 +9,9 @@ Date: Wed, 21 Dec 2005 22:02:44 +0100 To: [email protected] From: =?ISO-8859-15?Q?J=F6rn?= Reder <[email protected]> - -Hallo, - -das ist eine Testnachricht mit 8 Bit S=F6nderz=E4ichen, und obendr= -ein noch -quoted-printable kodiert=2E - -Gr=FC=DFe, - -J=F6rn - --- - =2E''`=2E J=F6rn Reder <joern@zyn=2Ede> -: :' : http://www=2Eexit1= -=2Eorg/ http://www=2Ezyn=2Ede/ -`=2E `' - `- Debian GNU/Linux -- The powe= -r of freedom +=0AHallo,=0A=0Adas ist eine Testnachricht mit 8 Bit S=F6nderz=E4ichen, und = +obendrein noch =0Aquoted-printable kodiert=2E=0A=0AGr=FC=DFe,=0A=0AJ=F6rn= +=0A +-- =0A =2E''`=2E J=F6rn Reder <joern@zyn=2Ede>=0A: :' : http://www=2Eexi= +t1=2Eorg/ http://www=2Ezyn=2Ede/=0A`=2E `'=0A `- Debian GNU/Linux -- Th= +e power of freedom=0A \ No newline at end of file diff --git a/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java b/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java index 64b11aae..6fd79e2d 100644 --- a/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java +++ b/dom/src/test/java/org/apache/james/mime4j/message/DefaultMessageWriterTest.java @@ -24,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.apache.james.mime4j.Charsets; import org.apache.james.mime4j.MimeIOException; +import org.apache.james.mime4j.codec.DecodeMonitor; import org.apache.james.mime4j.io.MaxNestingDepthLimitException; import org.apache.james.mime4j.stream.MimeConfig; import java.io.ByteArrayInputStream; @@ -35,6 +36,7 @@ import java.util.Collections; import org.apache.james.mime4j.dom.Header; import org.apache.james.mime4j.dom.Message; import org.apache.james.mime4j.dom.Multipart; +import org.apache.james.mime4j.dom.SingleBody; import org.apache.james.mime4j.dom.TextBody; import org.apache.james.mime4j.dom.address.Mailbox; import org.apache.james.mime4j.dom.field.AddressListField; @@ -42,6 +44,7 @@ import org.apache.james.mime4j.dom.field.MailboxListField; import org.apache.james.mime4j.field.Fields; import org.apache.james.mime4j.field.address.LenientAddressParser; import org.apache.james.mime4j.stream.RawField; +import org.apache.james.mime4j.util.ContentUtil; import org.junit.Test; public class DefaultMessageWriterTest { @@ -303,4 +306,32 @@ public class DefaultMessageWriterTest { assertThat(written).contains("Content-Type: text/plain; charset=UTF-8\r\n"); assertThat(written).endsWith("body é"); } + + @Test + public void quotedPrintableBodyWithMalformedEscapeSurvivesWriteAndParse() throws Exception { + byte[] raw = "Content-Transfer-Encoding: quoted-printable\r\n\r\na=2\r\nb\r\n".getBytes(StandardCharsets.US_ASCII); + DefaultMessageBuilder builder = new DefaultMessageBuilder(); + Message m1 = builder.parseMessage(new ByteArrayInputStream(raw)); + ByteArrayOutputStream w1 = new ByteArrayOutputStream(); + new DefaultMessageWriter().writeMessage(m1, w1); + Message m2 = builder.parseMessage(new ByteArrayInputStream(w1.toByteArray())); + + assertThat(ContentUtil.buffer(((SingleBody) m2.getBody()).getInputStream())) + .isEqualTo(ContentUtil.buffer(((SingleBody) m1.getBody()).getInputStream())); + } + + @Test + public void quotedPrintableTextWithLoneCarriageReturnSurvivesStrictWriteAndParse() throws Exception { + byte[] raw = "Content-Transfer-Encoding: quoted-printable\r\n\r\na=0D\r\nb=0Dc=0A\r\n".getBytes(StandardCharsets.US_ASCII); + DefaultMessageBuilder builder = new DefaultMessageBuilder(); + builder.setMimeEntityConfig(MimeConfig.STRICT); + builder.setDecodeMonitor(DecodeMonitor.STRICT); + Message m1 = builder.parseMessage(new ByteArrayInputStream(raw)); + ByteArrayOutputStream w1 = new ByteArrayOutputStream(); + new DefaultMessageWriter().writeMessage(m1, w1); + Message m2 = builder.parseMessage(new ByteArrayInputStream(w1.toByteArray())); + + assertThat(ContentUtil.buffer(((SingleBody) m2.getBody()).getInputStream())) + .isEqualTo("a\r\r\nb\rc\n\r\n".getBytes(StandardCharsets.US_ASCII)); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
