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]

Reply via email to