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 e9f833f1346b1727f4089893538463f4ae42d75b Author: Benoit TELLIER <[email protected]> AuthorDate: Thu Nov 5 05:34:39 2026 +0100 [FIX] DefaultMessageWriter: a nested multipart does not close an enclosing one A nested multipart that declares the boundary of its parent can only hold a preamble: the parser ends it at the first delimiter of the parent. The writer still gave it a closing delimiter, which the next parse read as the end of the parent: the following body parts became epilogue, and each further write added one more delimiter. Such a multipart is now written as its preamble only, what the first parse read. A boundary declared by any enclosing multipart counts, through a message/rfc822 part too, unless base64 hides the delimiter. --- .../james/mime4j/message/DefaultMessageWriter.java | 44 +++++++++++++++++ .../mime4j/message/DefaultMessageWriterTest.java | 56 ++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/dom/src/main/java/org/apache/james/mime4j/message/DefaultMessageWriter.java b/dom/src/main/java/org/apache/james/mime4j/message/DefaultMessageWriter.java index 9a9eda62..9420e322 100644 --- a/dom/src/main/java/org/apache/james/mime4j/message/DefaultMessageWriter.java +++ b/dom/src/main/java/org/apache/james/mime4j/message/DefaultMessageWriter.java @@ -37,6 +37,7 @@ import org.apache.james.mime4j.dom.field.ContentTypeField; import org.apache.james.mime4j.stream.Field; import org.apache.james.mime4j.util.ByteArrayBuffer; import org.apache.james.mime4j.util.ByteSequence; +import org.apache.james.mime4j.util.CharsetUtil; import org.apache.james.mime4j.util.ContentUtil; import org.apache.james.mime4j.util.MimeUtil; import org.apache.james.mime4j.util.RecycledByteArrayBuffer; @@ -160,6 +161,14 @@ public class DefaultMessageWriter implements MessageWriter { preamble = multipart.getPreamble() != null ? ContentUtil.encode(multipart.getPreamble()) : null; epilogue = multipart.getEpilogue() != null ? ContentUtil.encode(multipart.getEpilogue()) : null; } + if (multipart.getBodyParts().isEmpty() && isClosedByAnEnclosingBoundary(multipart, contentType.getBoundary())) { + // The enclosing multipart would read a closing delimiter as its own, and the parts after + // this one as its epilogue. The first parse only read a preamble, cut by that delimiter. + if (preamble != null) { + writeBytes(preamble, out); + } + return; + } if (preamble != null) { writeBytes(preamble, out); out.write(CRLF); @@ -230,6 +239,41 @@ public class DefaultMessageWriter implements MessageWriter { } } + /** + * Whether an enclosing multipart reads the closing delimiter of this one as its own delimiter. + * Base64 hides the delimiters of what it encodes, quoted-printable does not. + */ + private boolean isClosedByAnEnclosingBoundary(Multipart multipart, String boundary) { + Entity entity = multipart.getParent(); + while (entity != null) { + if (!(entity.getBody() instanceof Multipart) + && MimeUtil.isBase64Encoding(entity.getContentTransferEncoding())) { + return false; + } + Entity parent = entity.getParent(); + if (parent != null && parent.getBody() instanceof Multipart && parent.getHeader() != null) { + ContentTypeField enclosing = AbstractEntity.getContentTypeField(parent.getHeader()); + if (enclosing != null && enclosing.getBoundary() != null + && isDelimiterOf(enclosing.getBoundary(), boundary + "--")) { + return true; + } + } + entity = parent; + } + return false; + } + + /** As MimeBoundaryInputStream: the boundary is followed by the end, a whitespace or "--". */ + private static boolean isDelimiterOf(String boundary, String line) { + if (!line.startsWith(boundary)) { + return false; + } + int end = boundary.length(); + return end == line.length() + || CharsetUtil.isWhitespace(line.charAt(end)) + || line.startsWith("--", end); + } + private ContentTypeField getContentType(Multipart multipart) { Entity parent = multipart.getParent(); if (parent == null) 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 b280c281..476552e1 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 @@ -368,4 +368,60 @@ public class DefaultMessageWriterTest { .parseMessage(new ByteArrayInputStream(out.toByteArray())); assertThat(((Multipart) readBack.getBody()).getCount()).isEqualTo(1); } + + private static void assertStableWithTwoParts(String input) throws Exception { + byte[] raw = input.getBytes(StandardCharsets.US_ASCII); + DefaultMessageBuilder builder = new DefaultMessageBuilder(); + + Message m1 = builder.parseMessage(new ByteArrayInputStream(raw)); + byte[] w1 = DefaultMessageWriter.asBytes(m1); + Message m2 = builder.parseMessage(new ByteArrayInputStream(w1)); + + assertThat(((Multipart) m1.getBody()).getCount()).isEqualTo(2); + assertThat(((Multipart) m2.getBody()).getCount()).isEqualTo(2); + assertThat(DefaultMessageWriter.asBytes(m2)).isEqualTo(w1); + } + + @Test + public void nestedMultipartWithTheBoundaryOfItsParentShouldNotCloseItsParent() throws Exception { + assertStableWithTwoParts("Content-Type: multipart/mixed; boundary=b\r\n\r\n" + + "--b\r\n" + + "Content-Type: multipart/mixed; boundary=b\r\n\r\n" + + "--b\r\n\r\n" + + "x\r\n" + + "--b--\r\n"); + } + + @Test + public void nestedMultipartWhoseClosingDelimiterMatchesItsParentShouldNotCloseItsParent() throws Exception { + assertStableWithTwoParts("Content-Type: multipart/mixed; boundary=b\r\n\r\n" + + "--b\r\n" + + "Content-Type: multipart/mixed; boundary=\"b--\"\r\n\r\n" + + "--b\r\n\r\n" + + "x\r\n" + + "--b--\r\n"); + } + + @Test + public void multipartInAMessageWithTheBoundaryOfTheEnclosingMultipartShouldNotCloseIt() throws Exception { + assertStableWithTwoParts("Content-Type: multipart/mixed; boundary=b\r\n\r\n" + + "--b\r\n" + + "Content-Type: message/rfc822\r\n\r\n" + + "Content-Type: multipart/mixed; boundary=b\r\n\r\n" + + "--b\r\n\r\n" + + "x\r\n" + + "--b--\r\n"); + } + + @Test + public void emptyNestedMultipartWithItsOwnBoundaryShouldKeepItsClosingDelimiter() throws Exception { + byte[] raw = ("Content-Type: multipart/mixed; boundary=b\r\n\r\n" + + "--b\r\n" + + "Content-Type: multipart/mixed; boundary=c\r\n\r\n" + + "--c--\r\n" + + "\r\n--b--\r\n").getBytes(StandardCharsets.US_ASCII); + Message message = new DefaultMessageBuilder().parseMessage(new ByteArrayInputStream(raw)); + + assertThat(new String(DefaultMessageWriter.asBytes(message), StandardCharsets.US_ASCII)).contains("--c--"); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
