This is an automated email from the ASF dual-hosted git repository.
hqtran pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git
The following commit(s) were added to refs/heads/master by this push:
new 9da46f92ac [FIX] jakarta.mail.internet.ParseException: Unbalanced
quoted string (#3002)
9da46f92ac is described below
commit 9da46f92acdef837aab8231e875d58b6098d5ffc
Author: Benoit TELLIER <[email protected]>
AuthorDate: Mon Apr 13 05:14:58 2026 +0200
[FIX] jakarta.mail.internet.ParseException: Unbalanced quoted string (#3002)
---
.../james/server/core/MimeMessageWrapper.java | 15 ++-
.../org/apache/james/server/core/MailImplTest.java | 27 +++++
.../james/server/core/MimeMessageWrapperTest.java | 109 +++++++++++++++++++++
3 files changed, 146 insertions(+), 5 deletions(-)
diff --git
a/server/container/core/src/main/java/org/apache/james/server/core/MimeMessageWrapper.java
b/server/container/core/src/main/java/org/apache/james/server/core/MimeMessageWrapper.java
index 091959ebce..2e6f2e64bc 100644
---
a/server/container/core/src/main/java/org/apache/james/server/core/MimeMessageWrapper.java
+++
b/server/container/core/src/main/java/org/apache/james/server/core/MimeMessageWrapper.java
@@ -65,7 +65,6 @@ public class MimeMessageWrapper extends MimeMessage
implements Disposable {
*/
public static final String USE_MEMORY_COPY = "james.message.usememorycopy";
private static final int UNKNOWN = -1;
- private static final int HEADER_BODY_SEPARATOR_SIZE = 2;
/**
* Can provide an input stream to the data
@@ -332,10 +331,16 @@ public class MimeMessageWrapper extends MimeMessage
implements Disposable {
if (!isHeaderModified()) {
myHeaders = parsedHeaders;
} else {
- // The headers was modified so we need to call
saveChanges() just to be sure
- // See JAMES-1320
- if (!saved) {
- saveChanges();
+ // Headers were modified via setHeader/addHeader, which
already updated
+ // this.headers (checkModifyHeaders ensures it is loaded
from source first).
+ // Calling saveChanges() here would trigger
updateHeaders() on every MIME
+ // body part, which re-parses Content-Type parameters and
fails on malformed
+ // MIME structures (e.g. unbalanced quoted strings). Since
we are in the fast
+ // path (body is unmodified), the body-derived headers
have not changed and
+ // saveChanges() is not needed to reflect them.
+ // See JAMES-1320 (checkModifyHeaders already guarantees
headers != null)
+ if (headers == null) {
+ loadHeaders();
}
myHeaders = headers;
}
diff --git
a/server/container/core/src/test/java/org/apache/james/server/core/MailImplTest.java
b/server/container/core/src/test/java/org/apache/james/server/core/MailImplTest.java
index bb78345026..641b39e19c 100644
---
a/server/container/core/src/test/java/org/apache/james/server/core/MailImplTest.java
+++
b/server/container/core/src/test/java/org/apache/james/server/core/MailImplTest.java
@@ -246,6 +246,33 @@ public class MailImplTest extends ContractMailTest {
.contains("abc: def");
}
+ @Test
+ void duplicateShouldNotThrowOnMalformedMimeWithModifiedHeaders() throws
Exception {
+ // Reproduces the spooler reprocessing loop: a mail with malformed
MIME (unbalanced quoted
+ // string in a body-part Content-Type) that also has a pre-modified
header (e.g. Received
+ // added by the SMTP handler). Before the fix, MailImpl.duplicate()
would call saveChanges()
+ // on the original MimeMessageWrapper which triggered MIME body-part
parsing and threw
+ // ParseException: Unbalanced quoted string.
+ MailImpl mail = MailImpl.builder()
+ .name(MailUtil.newId())
+ .sender("sender@localhost")
+ .addRecipients("recipient@localhost")
+ .mimeMessage(new
MimeMessageWrapper(MimeMessageInputStreamSource.create("test",
+
ClassLoaderUtils.getSystemResourceAsSharedStream("invalid.eml"))))
+ .build();
+
+ // Simulate the Received header added by the SMTP handler (sets
headersModified = true)
+ mail.getMessage().addHeader("Received", "from mx.example.com by
james.example.com");
+
+ // Must not throw ParseException
+ MailImpl duplicate = MailImpl.duplicate(mail);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ duplicate.getMessage().writeTo(out);
+ assertThat(new String(out.toByteArray(), StandardCharsets.US_ASCII))
+ .contains("Received: from mx.example.com by james.example.com");
+ }
+
@Test
void setAttributeShouldThrowOnNullAttributeName() {
MailImpl mail = newMail();
diff --git
a/server/container/core/src/test/java/org/apache/james/server/core/MimeMessageWrapperTest.java
b/server/container/core/src/test/java/org/apache/james/server/core/MimeMessageWrapperTest.java
index bc96f14fe9..7bba53b35d 100644
---
a/server/container/core/src/test/java/org/apache/james/server/core/MimeMessageWrapperTest.java
+++
b/server/container/core/src/test/java/org/apache/james/server/core/MimeMessageWrapperTest.java
@@ -404,4 +404,113 @@ public class MimeMessageWrapperTest extends
MimeMessageFromStreamTest {
IOUtils.consume(wrapper.getMessageInputStream()));
LifecycleUtil.dispose(wrapper);
}
+
+ @Test
+ void writeToShouldNotThrowOnMalformedMimeWhenHeadersModified() throws
Exception {
+ // A multipart message with an unbalanced quoted string in a body
part's Content-Type
+ // name parameter — the kind of malformed MIME that causes
ParseException in saveChanges().
+ String malformedMime = "MIME-Version: 1.0\r\n" +
+ "From: [email protected]\r\n" +
+ "To: [email protected]\r\n" +
+ "Subject: test\r\n" +
+ "Content-Type: multipart/mixed; boundary=\"boundary\"\r\n" +
+ "\r\n" +
+ "--boundary\r\n" +
+ "Content-Type: image/png;\r\n" +
+ " name=\"Outlook-Icon\r\n" +
+ "\r\n" +
+ "Desc.png\"\r\n" +
+ "Content-Transfer-Encoding: base64\r\n" +
+ "\r\n" +
+ "dGVzdA==\r\n" +
+ "--boundary--\r\n";
+
+ MimeMessageWrapper wrapper = new MimeMessageWrapper(
+ MimeMessageInputStreamSource.create("test", new
SharedByteArrayInputStream(malformedMime.getBytes())));
+
+ // Simulate what James does: add a Received header before duplicating
the mail.
+ // This sets headersModified = true, causing writeTo() to previously
call saveChanges()
+ // which would fail with ParseException on the malformed Content-Type.
+ wrapper.addHeader("Received", "from mx.example.com");
+
+ // writeTo() must not throw even though the MIME body part has a
malformed Content-Type
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ wrapper.writeTo(out);
+
+ assertThat(out.toString()).contains("Received: from mx.example.com");
+ LifecycleUtil.dispose(wrapper);
+ }
+
+ @Test
+ void writeToFastPathShouldReflectAddedHeaderWithoutExplicitSaveChanges()
throws Exception {
+ mw.addHeader("X-James-Test", "added-value");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ mw.writeTo(out);
+
+ assertThat(out.toString()).contains("X-James-Test: added-value");
+ }
+
+ @Test
+ void writeToFastPathShouldReflectSetHeader() throws Exception {
+ // Subject is already set in setUp() as "foo"; replace it
+ mw.setHeader("Subject", "replaced-subject");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ mw.writeTo(out);
+
+ String result = out.toString();
+ assertThat(result).contains("Subject: replaced-subject");
+ assertThat(result).doesNotContain("Subject: foo");
+ }
+
+ @Test
+ void writeToFastPathShouldReflectRemovedHeader() throws Exception {
+ // Subject header is present in the original message
+ assertThat(mw.getHeader("Subject")).isNotNull();
+
+ mw.removeHeader("Subject");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ mw.writeTo(out);
+
+ assertThat(out.toString()).doesNotContain("Subject:");
+ }
+
+ @Test
+ void writeToFastPathShouldPreserveBodyWhenHeadersModified() throws
Exception {
+ mw.addHeader("X-James-Test", "some-value");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ mw.writeTo(out);
+
+ // Body must be preserved exactly
+ assertThat(out.toString()).endsWith(body);
+ }
+
+ @Test
+ void writeToFastPathShouldPreserveUnmodifiedHeaders() throws Exception {
+ // Only add a new header; the original "Subject: foo" must survive
+ mw.addHeader("X-James-Extra", "extra");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ mw.writeTo(out);
+
+ String result = out.toString();
+ assertThat(result).contains("Subject: foo");
+ assertThat(result).contains("X-James-Extra: extra");
+ }
+
+ @Test
+ void writeToFastPathShouldReflectMultipleAddedHeadersForSameName() throws
Exception {
+ mw.addHeader("Received", "from relay1.example.com");
+ mw.addHeader("Received", "from relay2.example.com");
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ mw.writeTo(out);
+
+ String result = out.toString();
+ assertThat(result).contains("Received: from relay1.example.com");
+ assertThat(result).contains("Received: from relay2.example.com");
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]