(geronimo-mail) branch main updated: GERONIMO-6851 - Add support for "mail.mime.parameters.strict" to ease transition between mail libs

[email protected] Sun, 19 Jul 2026 07:48:30 +0000
Newsgroups gmane.comp.java.geronimo.cvs
Message-ID <178444731004.431430.18223708471147495526@gitbox3-he-fi.apache.org>
This is an automated email from the ASF dual-hosted git repository.

rzo1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/geronimo-mail.git


The following commit(s) were added to refs/heads/main by this push:
     new 1fba502  GERONIMO-6851 - Add support for "mail.mime.parameters.strict" to ease transition between mail libs
1fba502 is described below

commit 1fba502ec596f620bfdd77f63eb57a5d9d786f34
Author: Richard Zowalla <[email protected]>
AuthorDate: Sun Jul 19 09:46:19 2026 +0200

    GERONIMO-6851 - Add support for "mail.mime.parameters.strict" to ease transition between mail libs
---
 .../jakarta/mail/internet/HeaderTokenizer.java     |   6 +-
 .../java/jakarta/mail/internet/ParameterList.java  | 128 +++++++++++++--------
 .../jakarta/mail/internet/ParameterListTest.java   |  70 +++++++++++
 3 files changed, 157 insertions(+), 47 deletions(-)

diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/HeaderTokenizer.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/HeaderTokenizer.java
index d6d9d00..9a2b4d1 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/HeaderTokenizer.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/HeaderTokenizer.java
@@ -406,7 +406,11 @@ public class HeaderTokenizer {
             }
         }
 
-        if (i <= 0) {
+        // i is the index of the last non-whitespace character, or -1 when the
+        // string is empty or all whitespace.  Note that index 0 may well hold a
+        // real character (a one character token), so only a negative index means
+        // there is nothing left.
+        if (i < 0) {
             return "";
 
         } else {
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ParameterList.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ParameterList.java
index 67c5f36..979d0e6 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ParameterList.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ParameterList.java
@@ -64,6 +64,7 @@ public class ParameterList {
     private static final String MIME_ENCODEPARAMETERS = "mail.mime.encodeparameters";
     private static final String MIME_DECODEPARAMETERS = "mail.mime.decodeparameters";
     private static final String MIME_DECODEPARAMETERS_STRICT = "mail.mime.decodeparameters.strict";
+    private static final String MIME_PARAMETERS_STRICT = "mail.mime.parameters.strict";
 
     private static final int HEADER_SIZE_LIMIT = 76;
 
@@ -115,6 +116,7 @@ public class ParameterList {
     private boolean encodeParameters = false;
     private boolean decodeParameters = false;
     private boolean decodeParametersStrict = false;
+    private boolean parametersStrict = true;
 
     public ParameterList() {
         // figure out how parameter handling is to be performed.
@@ -126,13 +128,20 @@ public class ParameterList {
         getInitialProperties();
         // get a token parser for the type information
         final HeaderTokenizer tokenizer = new HeaderTokenizer(list, HeaderTokenizer.MIME);
+        // In lenient mode ("mail.mime.parameters.strict" set to false), reading an
+        // unquoted value that contains special characters swallows the ';' that
+        // terminates the value.  This flag remembers that so the next loop pass
+        // does not insist on seeing another ';' before the following parameter name.
+        boolean separatorConsumed = false;
         while (true) {
             HeaderTokenizer.Token token = tokenizer.next();
 
             if (token.getType() == HeaderTokenizer.Token.EOF) {
                 // the EOF token terminates parsing.
                 break;
-            } else if (token.getType() == ';') {
+            }
+
+            if (token.getType() == ';') {
                 // each new parameter is separated by a semicolon, including the
                 // first, which separates
                 // the parameters from the main part of the header.
@@ -143,65 +152,88 @@ public class ParameterList {
                 if (token.getType() == HeaderTokenizer.Token.EOF) {
                     break;
                 }
+            } else if (!(separatorConsumed && token.getType() == HeaderTokenizer.Token.ATOM)) {
+                // in lenient mode the previous value scan may have consumed the
+                // separator already, in which case this token is the next parameter
+                // name.  In every other situation a missing semicolon is an error.
+                throw new ParseException("Missing ';'");
+            }
+            separatorConsumed = false;
 
-                if (token.getType() != HeaderTokenizer.Token.ATOM) {
-                    throw new ParseException("Invalid parameter name: " + token.getValue());
-                }
+            if (token.getType() != HeaderTokenizer.Token.ATOM) {
+                throw new ParseException("Invalid parameter name: " + token.getValue());
+            }
 
-                // get the parameter name as a lower case version for better
-                // mapping.
-                String name = token.getValue().toLowerCase();
+            // get the parameter name as a lower case version for better
+            // mapping.
+            String name = token.getValue().toLowerCase();
 
-                token = tokenizer.next();
+            token = tokenizer.next();
 
-                // parameters are name=value, so we must have the "=" here.
-                if (token.getType() != '=') {
-                    throw new ParseException("Missing '='");
-                }
+            // parameters are name=value, so we must have the "=" here.
+            if (token.getType() != '=') {
+                throw new ParseException("Missing '='");
+            }
 
-                // now the value, which may be an atom or a literal
+            // now the value, which may be an atom or a literal
+            final String value;
+            if (parametersStrict) {
                 token = tokenizer.next();
 
                 if (token.getType() != HeaderTokenizer.Token.ATOM && token.getType() != HeaderTokenizer.Token.QUOTEDSTRING) {
                     throw new ParseException("Invalid parameter value: " + token.getValue());
                 }
-
-                final String value = token.getValue();
-                String decodedValue = null;
-
-                // we might have to do some additional decoding. A name that
-                // ends with "*"
-                // is marked as being encoded, so if requested, we decode the
-                // value.
-                if (decodeParameters && name.endsWith("*") && !isMultiSegmentName(name)) {
-                    // the name needs to be pruned of the marker, and we need to
-                    // decode the value.
-                    name = name.substring(0, name.length() - 1);
-                    // get a new decoder
-                    final RFC2231Encoder decoder = new RFC2231Encoder(HeaderTokenizer.MIME);
-
-                    try {
-                        // decode the value
-                        decodedValue = decoder.decode(value);
-                    } catch (final Exception e) {
-                        // if we're doing things strictly, then raise a parsing
-                        // exception for errors.
-                        // otherwise, leave the value in its current state.
-                        if (decodeParametersStrict) {
-                            throw new ParseException("Invalid RFC2231 encoded parameter");
-                        }
-                    }
-                    _parameters.put(name, new ParameterValue(name, decodedValue, value));
-                } else if (isMultiSegmentName(name)) {
-                    // multisegment parameter
-                    _multiSegmentParameters.put(new MultiSegmentEntry(name), new ParameterValue(name, value));
+                value = token.getValue();
+            } else if (tokenizer.peek().getType() == HeaderTokenizer.Token.QUOTEDSTRING) {
+                // lenient mode, but the value is a properly quoted string.  Read it
+                // exactly like the strict path so embedded ';' and escapes keep working.
+                value = tokenizer.next().getValue();
+            } else {
+                // lenient mode with an unquoted value:  take the raw text up to the
+                // next ';' (or the end of the header) as the value, even if it
+                // contains whitespace or special characters.
+                token = tokenizer.next(';', true);
+                if (token.getType() == HeaderTokenizer.Token.EOF || token.getType() == ';') {
+                    // there was nothing between the '=' and the terminator, treat
+                    // this as an empty value rather than failing.
+                    value = "";
                 } else {
-                    _parameters.put(name, new ParameterValue(name, value));
+                    value = token.getValue().trim();
                 }
+                // the raw scan stops after the terminating ';' (if there was one).
+                separatorConsumed = true;
+            }
 
+            String decodedValue = null;
+
+            // we might have to do some additional decoding. A name that
+            // ends with "*"
+            // is marked as being encoded, so if requested, we decode the
+            // value.
+            if (decodeParameters && name.endsWith("*") && !isMultiSegmentName(name)) {
+                // the name needs to be pruned of the marker, and we need to
+                // decode the value.
+                name = name.substring(0, name.length() - 1);
+                // get a new decoder
+                final RFC2231Encoder decoder = new RFC2231Encoder(HeaderTokenizer.MIME);
+
+                try {
+                    // decode the value
+                    decodedValue = decoder.decode(value);
+                } catch (final Exception e) {
+                    // if we're doing things strictly, then raise a parsing
+                    // exception for errors.
+                    // otherwise, leave the value in its current state.
+                    if (decodeParametersStrict) {
+                        throw new ParseException("Invalid RFC2231 encoded parameter");
+                    }
+                }
+                _parameters.put(name, new ParameterValue(name, decodedValue, value));
+            } else if (isMultiSegmentName(name)) {
+                // multisegment parameter
+                _multiSegmentParameters.put(new MultiSegmentEntry(name), new ParameterValue(name, value));
             } else {
-
-                throw new ParseException("Missing ';'");
+                _parameters.put(name, new ParameterValue(name, value));
             }
 
         }
@@ -326,6 +358,10 @@ public class ParameterList {
         decodeParameters = SessionUtil.getBooleanProperty(MIME_DECODEPARAMETERS, true); //since JavaMail 1.5 RFC 2231 support is enabled by default
         decodeParametersStrict = SessionUtil.getBooleanProperty(MIME_DECODEPARAMETERS_STRICT, false);
         encodeParameters = SessionUtil.getBooleanProperty(MIME_ENCODEPARAMETERS, true); //since JavaMail 1.5 RFC 2231 support is enabled by default
+        // when false, parameter values that fail to follow the MIME token/quoted-string
+        // rules are recovered by reading the raw text up to the next ';' instead of
+        // raising a ParseException.  The default is the spec-conforming strict mode.
+        parametersStrict = SessionUtil.getBooleanProperty(MIME_PARAMETERS_STRICT, true);
     }
 
     public int size() {
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ParameterListTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ParameterListTest.java
index a90a47a..d7605bf 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ParameterListTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ParameterListTest.java
@@ -19,10 +19,13 @@
 
 package jakarta.mail.internet;
 
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * @version $Rev$ $Date$
@@ -169,6 +172,73 @@ public class ParameterListTest {
         assertEquals(list2.toString(), encodedTest);
     }
 
+    // header reported in GERONIMO-6851: the type parameter value contains an
+    // unquoted '/' which is a MIME special character
+    private static final String UNQUOTED_SPECIALS_HEADER =
+        "multipart/related; type=text/html; boundary=MErelboundary-32775405-3-1674841212";
+
+    @AfterEach
+    public void clearParametersStrictProperty() {
+        System.clearProperty("mail.mime.parameters.strict");
+    }
+
+    @Test
+    public void testLenientParsingOfUnquotedSpecials() throws Exception {
+        System.setProperty("mail.mime.parameters.strict", "false");
+
+        // the reporter's exact header must now parse
+        final ContentType contentType = new ContentType(UNQUOTED_SPECIALS_HEADER);
+        assertEquals("multipart", contentType.getPrimaryType());
+        assertEquals("related", contentType.getSubType());
+        assertEquals("text/html", contentType.getParameter("type"));
+        assertEquals("MErelboundary-32775405-3-1674841212", contentType.getParameter("boundary"));
+
+        // and isMimeType on a part carrying that raw header must work instead of
+        // throwing a ParseException
+        final InternetHeaders headers = new InternetHeaders();
+        headers.addHeader("Content-Type", UNQUOTED_SPECIALS_HEADER);
+        final MimeBodyPart part = new MimeBodyPart(headers, new byte[0]);
+        assertFalse(part.isMimeType("text/plain"));
+        assertTrue(part.isMimeType("multipart/related"));
+    }
+
+    @Test
+    public void testStrictDefaultRejectsUnquotedSpecials() {
+        // without the property set, strict parsing is the default and the
+        // reporter's header must still be rejected
+        assertThrows(ParseException.class, () -> new ContentType(UNQUOTED_SPECIALS_HEADER));
+        assertThrows(ParseException.class, () ->
+            new ParameterList("; type=text/html; boundary=MErelboundary-32775405-3-1674841212"));
+
+        // an explicit "true" behaves the same way
+        System.setProperty("mail.mime.parameters.strict", "true");
+        assertThrows(ParseException.class, () -> new ContentType(UNQUOTED_SPECIALS_HEADER));
+    }
+
+    @Test
+    public void testLenientParsingKeepsWellFormedValues() throws Exception {
+        System.setProperty("mail.mime.parameters.strict", "false");
+
+        // quoted values (including embedded ';' and whitespace) are untouched
+        final ParameterList quoted = new ParameterList("; foo=\"a;b c\"; bar=plain");
+        assertEquals("a;b c", quoted.get("foo"));
+        assertEquals("plain", quoted.get("bar"));
+
+        // RFC 2231 encoded parameters still decode
+        final ParameterList encoded = new ParameterList("; title*=us-ascii'en-us'This%20is%20fun; charset=us-ascii");
+        assertEquals("This is fun", encoded.get("title"));
+        assertEquals("us-ascii", encoded.get("charset"));
+
+        // RFC 2231 multi-segment parameters still combine
+        final ParameterList multi = new ParameterList(";foo*0=one;foo*1=\"two\"");
+        assertEquals("onetwo", multi.get("foo"));
+
+        // an unquoted value with whitespace runs to the next ';' and is trimmed
+        final ParameterList spaces = new ParameterList("; name=hello world stuff ; next=x");
+        assertEquals("hello world stuff", spaces.get("name"));
+        assertEquals("x", spaces.get("next"));
+    }
+
     @Test
     public void testStrictDecodeRejectsBadHex() {
         System.setProperty("mail.mime.decodeparameters", "true");