(geronimo-mail) branch main updated: GERONIMO-6907 - MIME handling deviates from the Jakarta Mail 2.1 spec

[email protected] Sat, 18 Jul 2026 20:22:18 +0000
Newsgroups gmane.comp.java.geronimo.cvs
Message-ID <178440613841.3155093.14778442958880015543@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 35554c2  GERONIMO-6907 - MIME handling deviates from the Jakarta Mail 2.1 spec
35554c2 is described below

commit 35554c20d8c38a053ade7a659ddfb1bd6559154c
Author: Richard Zowalla <[email protected]>
AuthorDate: Sat Jul 18 22:03:41 2026 +0200

    GERONIMO-6907 - MIME handling deviates from the Jakarta Mail 2.1 spec
    
    Fixes fourteen client-side defects in jakarta.mail.internet exposed by the
    Jakarta Mail TCK: MimeMessage filename handling (raw Content-Disposition
    access, System-property flags, no forced Content-Type, RFC 2231 encoding of
    non-ASCII names), MailDateFormat leaving milliseconds in parsed dates,
    ContentType.match(null) NPE, Content-Language list splitting, case-
    insensitive RFC 2047 encoded-word decoding, strict RFC 2231 hex validation,
    MimeMultipart(DataSource) content-type initialization, preamble line-ending
    preservation, InternetAddress.toUnicodeString index bug, MimeUtility
    fold/unfold semantics, and UTF-8 address support (atom scanning and
    mail.mime.allowutf8 header I/O). Adds 18 regression tests and removes the
    last 17 exclusions from the TCK exclude list.
---
 .../apache/geronimo/mail/issues/IssuesTest.java    |  10 +-
 .../java/jakarta/mail/internet/AddressParser.java  |   9 +-
 .../java/jakarta/mail/internet/ContentType.java    |  10 +-
 .../jakarta/mail/internet/InternetAddress.java     |   2 +-
 .../jakarta/mail/internet/InternetHeaders.java     |  32 ++-
 .../java/jakarta/mail/internet/MailDateFormat.java |  10 +-
 .../java/jakarta/mail/internet/MimeBodyPart.java   |  53 +++-
 .../java/jakarta/mail/internet/MimeMessage.java    |  64 +++--
 .../java/jakarta/mail/internet/MimeMultipart.java  |  47 +++-
 .../java/jakarta/mail/internet/MimeUtility.java    | 278 +++++++++++----------
 .../apache/geronimo/mail/util/RFC2231Encoder.java  |  33 ++-
 .../jakarta/mail/internet/ContentTypeTest.java     |  12 +
 .../jakarta/mail/internet/InternetAddressTest.java |  35 +++
 .../jakarta/mail/internet/MailDateFormatTest.java  |  11 +
 .../jakarta/mail/internet/MimeBodyPartTest.java    |  60 +++++
 .../jakarta/mail/internet/MimeMessageTest.java     | 109 ++++++++
 .../jakarta/mail/internet/MimeMultipartTest.java   |  63 +++++
 .../jakarta/mail/internet/MimeUtilityTest.java     |  64 +++++
 .../jakarta/mail/internet/ParameterListTest.java   |  20 ++
 geronimo-mail_2.1_tck/src/tck/geronimo.jtx         |  27 +-
 20 files changed, 741 insertions(+), 208 deletions(-)

diff --git a/geronimo-mail_2.1_impl/geronimo-mail_2.1_provider/src/test/java/org/apache/geronimo/mail/issues/IssuesTest.java b/geronimo-mail_2.1_impl/geronimo-mail_2.1_provider/src/test/java/org/apache/geronimo/mail/issues/IssuesTest.java
index 71f4049..d544e41 100644
--- a/geronimo-mail_2.1_impl/geronimo-mail_2.1_provider/src/test/java/org/apache/geronimo/mail/issues/IssuesTest.java
+++ b/geronimo-mail_2.1_impl/geronimo-mail_2.1_provider/src/test/java/org/apache/geronimo/mail/issues/IssuesTest.java
@@ -86,12 +86,18 @@ public class IssuesTest extends AbstractProtocolTest {
 
     @Test
     public void testGERONIMO4594Fail1() throws Exception {
-        Assertions.assertFalse(doGERONIMO4594(false, false));
+        // with mail.mime.encodefilename=false the filename is now written as an
+        // RFC 2231 encoded parameter (filename*=charset''...), which decodes
+        // transparently on read, so the round trip succeeds even without the
+        // encode/decodefilename convenience properties.
+        Assertions.assertTrue(doGERONIMO4594(false, false));
     }
 
     @Test
     public void testGERONIMO4594Fail2() throws Exception {
-        Assertions.assertFalse(doGERONIMO4594(true, false));
+        // as above: RFC 2231 parameter encoding makes the filename round-trip
+        // without the RFC 2047 convenience properties.
+        Assertions.assertTrue(doGERONIMO4594(true, false));
     }
         
     private boolean doGERONIMO4594(boolean decode, boolean encode) throws Exception {
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/AddressParser.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/AddressParser.java
index 272b1b4..1617182 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/AddressParser.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/AddressParser.java
@@ -728,9 +728,10 @@ class AddressParser {
                     break;
 
                 // potentially an atom...if it starts with an allowed atom character, we
-                // parse out the token, otherwise this is invalid.
+                // parse out the token, otherwise this is invalid.  Characters above the
+                // ASCII range are tolerated in atoms to support UTF-8 addresses (RFC 6532).
                 default:
-                    if (ch < 040 || ch >= 0177) {
+                    if (ch < 040 || (ch >= 0177 && ch < 0200)) {
                         syntaxError("Illegal character in address", position);
                     }
 
@@ -884,7 +885,9 @@ class AddressParser {
         while (moreCharacters()) {
 
             final char ch = currentChar();
-            if (isAtom(ch)) {
+            // anything above the ASCII range is accepted inside an atom so that
+            // UTF-8 addresses (RFC 6532) can be parsed.
+            if (isAtom(ch) || ch >= 0200) {
                 nextChar();
             }
             else {
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ContentType.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ContentType.java
index 5a0a411..c2702c8 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ContentType.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/ContentType.java
@@ -154,11 +154,11 @@ public class ContentType {
     }
 
     public boolean match(final ContentType other) {
-    	
-    	if(_major == null || _minor == null) {
+
+    	if(other == null || _major == null || _minor == null) {
     		return false;
     	}
-    	
+
         return _major.equalsIgnoreCase(other._major)
                 && (_minor.equalsIgnoreCase(other._minor)
                 || _minor.equals("*")
@@ -166,6 +166,10 @@ public class ContentType {
     }
 
     public boolean match(final String contentType) {
+        // a null string can never be a match
+        if (contentType == null) {
+            return false;
+        }
         try {
             return match(new ContentType(contentType));
         } catch (final ParseException e) {
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetAddress.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetAddress.java
index 0b7ace9..1e7bd37 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetAddress.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetAddress.java
@@ -628,7 +628,7 @@ public class InternetAddress extends Address implements Cloneable {
             final StringBuffer buf = new StringBuffer(addresses.length * 32);
             for (int i = 0; i < addresses.length; i++) {
 
-                String converted = ((InternetAddress)addresses[0]).toUnicodeString();
+                String converted = ((InternetAddress)addresses[i]).toUnicodeString();
 
                 if (MimeUtility.verifyAscii(converted) != MimeUtility.ALL_ASCII){
                     sawNonAsciiCharacters = true;
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetHeaders.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetHeaders.java
index 5d63d73..c2a9f8c 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetHeaders.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/InternetHeaders.java
@@ -660,13 +660,29 @@ public class InternetHeaders {
      * @exception IOException
      */
     void writeTo(final OutputStream out, final String[] ignore) throws IOException {
+        writeTo(out, ignore, false);
+    }
+
+
+    /**
+     * Write out the set of headers, except for any
+     * headers specified in the optional ignore list.
+     *
+     * @param out    The output stream.
+     * @param ignore The optional ignore list.
+     * @param utf8   true to emit the header bytes in UTF-8, false for the
+     *               classic ISO8859-1 encoding.
+     *
+     * @exception IOException
+     */
+    void writeTo(final OutputStream out, final String[] ignore, final boolean utf8) throws IOException {
         if (ignore == null) {
             // write out all header lines with non-null values
             for (int i = 0; i < headers.size(); i++) {
                 final InternetHeader header = headers.get(i);
                 // we only include headers with real values, no placeholders
                 if (header.getValue() != null) {
-                    header.writeTo(out);
+                    header.writeTo(out, utf8);
                 }
             }
         }
@@ -677,7 +693,7 @@ public class InternetHeaders {
                 // we only include headers with real values, no placeholders
                 if (header.getValue() != null) {
                     if (!matchHeader(header.getName(), ignore)) {
-                        header.writeTo(out);
+                        header.writeTo(out, utf8);
                     }
                 }
             }
@@ -750,10 +766,18 @@ public class InternetHeaders {
         }
 
         void writeTo(final OutputStream out) throws IOException {
-            out.write(name.getBytes("ISO8859-1"));
+            writeTo(out, false);
+        }
+
+        void writeTo(final OutputStream out, final boolean utf8) throws IOException {
+            // the default wire encoding is ISO8859-1; UTF-8 is only used when
+            // mail.mime.allowutf8 processing has been requested by the caller.
+            final java.nio.charset.Charset charset =
+                utf8 ? StandardCharsets.UTF_8 : StandardCharsets.ISO_8859_1;
+            out.write(name.getBytes(charset));
             out.write(':');
             out.write(' ');
-            out.write(value.getBytes("ISO8859-1"));
+            out.write(value.getBytes(charset));
             out.write('\r');
             out.write('\n');
         }
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MailDateFormat.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MailDateFormat.java
index a312bbe..09194a8 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MailDateFormat.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MailDateFormat.java
@@ -247,9 +247,13 @@ public class MailDateFormat extends SimpleDateFormat {
             // set the index of how far we've parsed this 
             pos.setIndex(current);
             
-            // create a calendar for creating the date 
-            final Calendar greg = new GregorianCalendar(TimeZone.getTimeZone("GMT")); 
-            // we inherit the leniency rules 
+            // create a calendar for creating the date
+            final Calendar greg = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
+            // the calendar starts out seeded with the current time.  All fields that
+            // set() does not touch (notably MILLISECOND) would otherwise leak into the
+            // parsed result, so wipe everything first.
+            greg.clear();
+            // we inherit the leniency rules
             greg.setLenient(lenient);
             greg.set(year, month, day, hour, minutes, seconds); 
             // now adjust by the offset.  This seems a little strange, but we  
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeBodyPart.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeBodyPart.java
index c6af846..1dcb20b 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeBodyPart.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeBodyPart.java
@@ -262,7 +262,9 @@ public class MimeBodyPart extends BodyPart implements MimePart {
     }
 
     public String[] getContentLanguage() throws MessagingException {
-        return getHeader("Content-Language");
+        // the header holds a comma-separated list of language tags that we need to
+        // break apart into individual values.
+        return MimeUtility.parseLanguageList(getHeader("Content-Language", ","));
     }
 
     public void setContentLanguage(final String[] languages) throws MessagingException {
@@ -316,7 +318,10 @@ public class MimeBodyPart extends BodyPart implements MimePart {
         final String disposition = getSingleHeader("Content-Disposition");
         String filename = null;
 
-        if (disposition != null) {
+        // a blank header can show up on messages whose headers were merged from a
+        // store's metadata (e.g. IMAP BODYSTRUCTURE without disposition information)
+        // and simply means "no disposition"
+        if (disposition != null && !disposition.trim().isEmpty()) {
             filename = new ContentDisposition(disposition).getParameter("filename");
         }
 
@@ -364,20 +369,54 @@ public class MimeBodyPart extends BodyPart implements MimePart {
 
         // now create a disposition object and set the parameter.
         final ContentDisposition contentDisposition = new ContentDisposition(disposition);
-        contentDisposition.setParameter("filename", name);
+        setFileNameParameter(contentDisposition, name);
 
         // serialize this back out and reset.
         setHeader("Content-Disposition", contentDisposition.toString());
 
         // The Sun implementation appears to update the Content-type name parameter too, based on
-        // another system property
+        // another system property.  Only do this when a Content-Type header actually exists;
+        // otherwise we'd force a default (text/plain) header into place and updateHeaders()
+        // would never get a chance to apply the type from the data handler.  When a header is
+        // eventually created, updateHeaders() copies the filename into the name parameter.
         if (SessionUtil.getBooleanProperty(MIME_SETCONTENTTYPEFILENAME, true)) {
-            final ContentType type = new ContentType(getContentType());
-            type.setParameter("name", name);
-            setHeader("Content-Type", type.toString());
+            final String existingType = getSingleHeader("Content-Type");
+            if (existingType != null) {
+                try {
+                    final ContentType type = new ContentType(existingType);
+                    type.setParameter("name", name);
+                    setHeader("Content-Type", type.toString());
+                } catch (final ParseException e) {
+                    // leave an unparseable header alone
+                }
+            }
         }
     }
 
+    /**
+     * Store a file name parameter on a Content-Disposition, encoding
+     * non-ASCII names in RFC 2231 form (filename*=charset''percent-encoded)
+     * using the default MIME charset.  Shared by MimeBodyPart and
+     * MimeMessage setFileName() implementations.
+     *
+     * @param disposition The target disposition object.
+     * @param name        The file name value to record.
+     */
+    static void setFileNameParameter(final ContentDisposition disposition, final String name) {
+        if (name == null) {
+            disposition.setParameter("filename", name);
+            return;
+        }
+        // route the value through the charset-aware setter; ASCII names are stored
+        // unchanged, non-ASCII names get the RFC 2231 encoded form.
+        ParameterList list = disposition.getParameterList();
+        if (list == null) {
+            list = new ParameterList();
+            disposition.setParameterList(list);
+        }
+        list.set("filename", name, MimeUtility.getDefaultMIMECharset());
+    }
+
     public InputStream getInputStream() throws MessagingException, IOException {
         return getDataHandler().getInputStream();
     }
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMessage.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMessage.java
index 8abd717..e05344e 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMessage.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMessage.java
@@ -54,6 +54,7 @@ public class MimeMessage extends Message implements MimePart {
 
 	private static final String MAIL_ALTERNATES = "mail.alternates";
 	private static final String MAIL_REPLYALLCC = "mail.replyallcc";
+	private static final String MIME_ALLOWUTF8 = "mail.mime.allowutf8";
 
     // static used to ensure message ID uniqueness
     private static int messageID = 0;
@@ -154,6 +155,8 @@ public class MimeMessage extends Message implements MimePart {
         // empty messages are modified, because the content is not there, and require saving before use.
         modified = true;
         saved = false;
+        // pick up the UTF-8 header switch from the session configuration
+        allowUtf8 = SessionUtil.getBooleanProperty(session, MIME_ALLOWUTF8, false);
     }
 
     /**
@@ -180,8 +183,10 @@ public class MimeMessage extends Message implements MimePart {
      */
     public MimeMessage(final MimeMessage message) throws MessagingException {
         super(message.session);
-        // get a copy of the source message flags 
-        flags = message.getFlags(); 
+        // pick up the UTF-8 header switch from the session configuration
+        allowUtf8 = SessionUtil.getBooleanProperty(session, MIME_ALLOWUTF8, false);
+        // get a copy of the source message flags
+        flags = message.getFlags();
         // this is somewhat difficult to do.  There's a lot of data in both the superclass and this
         // class that needs to undergo a "deep cloning" operation.  These operations don't really exist
         // on the objects in question, so the only solution I can come up with is to serialize the
@@ -229,6 +234,8 @@ public class MimeMessage extends Message implements MimePart {
         saved = true;
         // we've not filled in the content yet, so this needs to be marked as modified
         modified = true;
+        // pick up the UTF-8 header switch from the session configuration
+        allowUtf8 = SessionUtil.getBooleanProperty(session, MIME_ALLOWUTF8, false);
     }
 
     /**
@@ -868,7 +875,9 @@ public class MimeMessage extends Message implements MimePart {
     }
 
     public String[] getContentLanguage() throws MessagingException {
-        return getHeader("Content-Language");
+        // the header holds a comma-separated list of language tags that we need to
+        // break apart into individual values.
+        return MimeUtility.parseLanguageList(getHeader("Content-Language", ","));
     }
 
     public void setContentLanguage(final String[] languages) throws MessagingException {
@@ -891,11 +900,16 @@ public class MimeMessage extends Message implements MimePart {
     }
 
     public String getFileName() throws MessagingException {
-        // see if there is a disposition.  If there is, parse off the filename parameter.
-        final String disposition = getDisposition();
+        // NB:  We need the full header value here, not the result of getDisposition(),
+        // because getDisposition() strips off the parameters (including the filename
+        // parameter we're looking for).  This mirrors MimeBodyPart.getFileName().
+        final String disposition = getSingleHeader("Content-Disposition");
         String filename = null;
 
-        if (disposition != null) {
+        // a blank header can show up on messages whose headers were merged from a
+        // store's metadata (e.g. IMAP BODYSTRUCTURE without disposition information)
+        // and simply means "no disposition"
+        if (disposition != null && !disposition.trim().isEmpty()) {
             filename = new ContentDisposition(disposition).getParameter("filename");
         }
 
@@ -911,7 +925,9 @@ public class MimeMessage extends Message implements MimePart {
             }
         }
         // if we have a name, we might need to decode this if an additional property is set.
-        if (filename != null && SessionUtil.getBooleanProperty(session, MIME_DECODEFILENAME, false)) {
+        // this is controlled by a System property (not a session property), just like
+        // MimeBodyPart handles it.
+        if (filename != null && SessionUtil.getBooleanProperty(MIME_DECODEFILENAME, false)) {
             try {
                 filename = MimeUtility.decodeText(filename);
             } catch (final UnsupportedEncodingException e) {
@@ -924,9 +940,9 @@ public class MimeMessage extends Message implements MimePart {
 
 
     public void setFileName(String name) throws MessagingException {
-        // there's an optional session property that requests file name encoding...we need to process this before
-        // setting the value.
-        if (name != null && SessionUtil.getBooleanProperty(session, MIME_ENCODEFILENAME, false)) {
+        // there's an optional System property that requests file name encoding...we need to process this before
+        // setting the value (same lookup MimeBodyPart uses).
+        if (name != null && SessionUtil.getBooleanProperty(MIME_ENCODEFILENAME, false)) {
             try {
                 name = MimeUtility.encodeText(name);
             } catch (final UnsupportedEncodingException e) {
@@ -942,10 +958,13 @@ public class MimeMessage extends Message implements MimePart {
         }
         // now create a disposition object and set the parameter.
         final ContentDisposition contentDisposition = new ContentDisposition(disposition);
-        contentDisposition.setParameter("filename", name);
+        MimeBodyPart.setFileNameParameter(contentDisposition, name);
 
-        // serialize this back out and reset.
-        setDisposition(contentDisposition.toString());
+        // write the header directly.  Going through setDisposition() would treat the
+        // serialized "disposition; filename=..." string as a bare disposition value and
+        // merge it with any stale parameters from the previous header value.  This
+        // mirrors MimeBodyPart.setFileName().
+        setHeader("Content-Disposition", contentDisposition.toString());
     }
 
     public InputStream getInputStream() throws MessagingException, IOException {
@@ -1275,8 +1294,9 @@ public class MimeMessage extends Message implements MimePart {
             saveChanges();
         }
 
-        // write out the headers first
-        headers.writeTo(out, ignoreHeaders);
+        // write out the headers first.  When UTF-8 headers are enabled for this
+        // message, the header bytes are written in UTF-8 rather than ISO8859-1.
+        headers.writeTo(out, ignoreHeaders, allowUtf8);
         // add the separater between the headers and the data portion.
         out.write('\r');
         out.write('\n');
@@ -1692,6 +1712,16 @@ public class MimeMessage extends Message implements MimePart {
         if (addresses == null) {
             headers.removeHeader(header);
         }
+        else if (allowUtf8) {
+            // with UTF-8 headers enabled, address values keep their raw unicode form
+            final String value = InternetAddress.toUnicodeString(addresses, header.length() + 2);
+            if (value == null) {
+                headers.removeHeader(header);
+            }
+            else {
+                headers.setHeader(header, value);
+            }
+        }
         else {
             headers.setHeader(header, addresses);
         }
@@ -1719,7 +1749,9 @@ public class MimeMessage extends Message implements MimePart {
             System.arraycopy(a, 0, anew, 0, a.length);
             System.arraycopy(addresses, 0, anew, a.length, addresses.length);
         }
-        final String s = InternetAddress.toString(anew, header.length() + 2);
+        final String s = allowUtf8 ?
+                InternetAddress.toUnicodeString(anew, header.length() + 2) :
+                InternetAddress.toString(anew, header.length() + 2);
         if (s == null) {
             return;
         }
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMultipart.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMultipart.java
index f2a9a1f..27159e8 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMultipart.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeMultipart.java
@@ -155,6 +155,12 @@ public class MimeMultipart extends Multipart {
         ds = dataSource;
         if (dataSource instanceof MultipartDataSource) {
             super.setMultipartDataSource((MultipartDataSource) dataSource);
+            // even though the parts come pre-parsed from the data source, we still
+            // need the content type initialized; writeTo() and getContentType()
+            // depend on it.
+            final String sourceType = dataSource.getContentType();
+            contentType = sourceType != null ? sourceType : "multipart/mixed";
+            type = new ContentType(contentType);
             parsed = true;
         } else {
             // We keep the original, provided content type string so that we
@@ -345,11 +351,13 @@ public class MimeMultipart extends Multipart {
      */
     private byte[] readTillFirstBoundary(final BufferedInputStream pushbackInStream) throws MessagingException {
         final ByteArrayOutputStream preambleStream = new ByteArrayOutputStream();
+        final ByteArrayOutputStream lineTerminator = new ByteArrayOutputStream();
 
         try {
             while (true) {
-                // read the next line
-                final byte[] line = readLine(pushbackInStream);
+                // read the next line, capturing the terminator bytes that ended it
+                lineTerminator.reset();
+                final byte[] line = readLine(pushbackInStream, lineTerminator);
                 // hit an EOF?
                 if (line == null || line.length==0) {
                     return null;//throw new MessagingException("Unexpected End of Stream while searching for first Mime Boundary");
@@ -364,10 +372,10 @@ public class MimeMultipart extends Multipart {
                     return stripLinearWhiteSpace(line);
                 }
                 else {
-                    // this is part of the preamble.
+                    // this is part of the preamble.  Keep the terminator exactly as
+                    // it appeared in the source data.
                     preambleStream.write(line);
-                    preambleStream.write('\r');
-                    preambleStream.write('\n');
+                    lineTerminator.writeTo(preambleStream);
                 }
             }
         } catch (final IOException ioe) {
@@ -416,11 +424,13 @@ public class MimeMultipart extends Multipart {
      */
     private boolean readTillFirstBoundary(final BufferedInputStream pushbackInStream, final byte[] boundary) throws MessagingException {
         final ByteArrayOutputStream preambleStream = new ByteArrayOutputStream();
+        final ByteArrayOutputStream lineTerminator = new ByteArrayOutputStream();
 
         try {
             while (true) {
-                // read the next line
-                final byte[] line = readLine(pushbackInStream);
+                // read the next line, capturing the terminator bytes that ended it
+                lineTerminator.reset();
+                final byte[] line = readLine(pushbackInStream, lineTerminator);
                 // hit an EOF?
                 if (line == null || line.length==0) {
                 	return false;//throw new MessagingException("Unexpected End of Stream while searching for first Mime Boundary");
@@ -436,10 +446,10 @@ public class MimeMultipart extends Multipart {
                     return true;
                 }
 
-                // this is part of the preamble.
+                // this is part of the preamble.  Keep the terminator exactly as
+                // it appeared in the source data.
                 preambleStream.write(line);
-                preambleStream.write('\r');
-                preambleStream.write('\n');
+                lineTerminator.writeTo(preambleStream);
             }
         } catch (final IOException ioe) {
             throw new MessagingException(ioe.toString(), ioe);
@@ -487,15 +497,21 @@ public class MimeMultipart extends Multipart {
 
     /**
      * Read a single line of data from the input stream,
-     * returning it as an array of bytes.
+     * returning it as an array of bytes.  The bytes that
+     * terminated the line (CR, LF, or CRLF) are recorded in
+     * the supplied terminator stream so callers can reproduce
+     * the source data exactly.
      *
-     * @param in     The source input stream.
+     * @param in         The source input stream.
+     * @param terminator Receives the line terminator bytes consumed for
+     *                   this line (empty at EOF or when the data ends
+     *                   without a terminator).
      *
      * @return A byte array containing the line data.  Returns
      *         null if there's nothing left in the stream.
      * @exception MessagingException
      */
-    private byte[] readLine(final BufferedInputStream in) throws IOException
+    private byte[] readLine(final BufferedInputStream in, final ByteArrayOutputStream terminator) throws IOException
     {
         final ByteArrayOutputStream line = new ByteArrayOutputStream();
 
@@ -509,6 +525,7 @@ public class MimeMultipart extends Multipart {
                 break;
             }
             else if (value == '\r') {
+                terminator.write('\r');
                 in.mark(10);
                 value = in.read();
                 // we expect to find a linefeed after the carriage return, but
@@ -516,10 +533,14 @@ public class MimeMultipart extends Multipart {
                 if (value != '\n') {
                     in.reset();
                 }
+                else {
+                    terminator.write('\n');
+                }
                 break;
             }
             else if (value == '\n') {
                 // naked linefeed, allow that
+                terminator.write('\n');
                 break;
             }
             else {
diff --git a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeUtility.java b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeUtility.java
index 90202cb..f6464b8 100644
--- a/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeUtility.java
+++ b/geronimo-mail_2.1_spec/src/main/java/jakarta/mail/internet/MimeUtility.java
@@ -27,7 +27,9 @@ import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.NoSuchElementException;
 import java.util.StringTokenizer;
@@ -432,12 +434,12 @@ public class MimeUtility {
 
             final byte[] encodedData = encodedText.getBytes("US-ASCII");
 
-            // Base64 encoded?
-            if (encoding.equals("B")) {
+            // Base64 encoded?  RFC 2047 says the encoding token is case-insensitive.
+            if (encoding.equalsIgnoreCase("B")) {
                 Base64.decode(encodedData, out);
             }
             // maybe quoted printable.
-            else if (encoding.equals("Q")) {
+            else if (encoding.equalsIgnoreCase("Q")) {
                 final QuotedPrintableEncoder dataEncoder = new QuotedPrintableEncoder();
                 dataEncoder.decodeWord(encodedData, out);
             }
@@ -448,7 +450,11 @@ public class MimeUtility {
             final byte[] decodedData = out.toByteArray();
             return new String(decodedData, javaCharset(charset));
         } catch (final IOException e) {
-            throw new UnsupportedEncodingException("Invalid RFC 2047 encoding");
+            // don't swallow the real failure; keep it attached for diagnosis.
+            final UnsupportedEncodingException failure =
+                new UnsupportedEncodingException("Invalid RFC 2047 encoding: " + e.getMessage());
+            failure.initCause(e);
+            throw failure;
         }
 
     }
@@ -1098,7 +1104,7 @@ public class MimeUtility {
         // and line break characters.
         for (end = s.length() - 1; end >= 0; end--) {
             final int ch = s.charAt(end);
-            if (ch != ' ' && ch != '\t' ) {
+            if (ch != ' ' && ch != '\t' && ch != '\r' && ch != '\n') {
                 break;
             }
         }
@@ -1108,88 +1114,113 @@ public class MimeUtility {
             s = s.substring(0, end + 1);
         }
 
-        // does the string as it exists now not require folding?  We can just had that back right off.
+        // does the string as it exists now not require folding?  Just run the line break
+        // cleanup pass and return.
         if (s.length() + used <= FOLD_THRESHOLD) {
-            return s;
+            return fixupLineBreaks(s);
         }
 
         // get a buffer for the length of the string, plus room for a few line breaks.
-        // these are soft line breaks, so we generally need more that just the line breaks (an escape +
-        // CR + LF + leading space on next line);
-        final StringBuffer newString = new StringBuffer(s.length() + 8);
-
+        final StringBuilder newString = new StringBuilder(s.length() + 8);
 
         // now keep chopping this down until we've accomplished what we need.
         while (used + s.length() > FOLD_THRESHOLD) {
             int breakPoint = -1;
-            char breakChar = 0;
+            char previousChar = 0;
 
-            // now scan for the next place where we can break.
+            // scan for the best whitespace position to fold at.  Only the first
+            // character of a whitespace run is a candidate.  Once we're past the
+            // line limit we stop at the first candidate we've seen.
             for (int i = 0; i < s.length(); i++) {
-                // have we passed the fold limit?
-                if (used + i > FOLD_THRESHOLD) {
-                    // if we've already seen a blank, then stop now.  Otherwise
-                    // we keep going until we hit a fold point.
-                    if (breakPoint != -1) {
-                        break;
-                    }
+                if (breakPoint != -1 && used + i > FOLD_THRESHOLD) {
+                    break;
                 }
-                char ch = s.charAt(i);
-
-                // a white space character?
-                if (ch == ' ' || ch == '\t') {
-                    // this might be a run of white space, so skip over those now.
+                final char ch = s.charAt(i);
+                if ((ch == ' ' || ch == '\t') && previousChar != ' ' && previousChar != '\t') {
                     breakPoint = i;
-                    // we need to maintain the same character type after the inserted linebreak.
-                    breakChar = ch;
-                    i++;
-                    while (i < s.length()) {
-                        ch = s.charAt(i);
-                        if (ch != ' ' && ch != '\t') {
-                            break;
-                        }
-                        i++;
-                    }
-                }
-                // found an embedded new line.  Escape this so that the unfolding process preserves it.
-                else if (ch == '\n') {
-                    newString.append('\\');
-                    newString.append('\n');
-                }
-                else if (ch == '\r') {
-                    newString.append('\\');
-                    newString.append('\n');
-                    i++;
-                    // if this is a CRLF pair, add the second char also
-                    if (i < s.length() && s.charAt(i) == '\n') {
-                        newString.append('\r');
-                    }
                 }
-
+                previousChar = ch;
             }
-            // no fold point found, we punt, append the remainder and leave.
+
+            // no fold point at all...take the remainder as one long line.
             if (breakPoint == -1) {
                 newString.append(s);
-                return newString.toString();
+                s = "";
+                break;
             }
-            newString.append(s.substring(0, breakPoint));
+
+            // append the segment plus a soft line break, reusing the whitespace
+            // character as the continuation character on the new line.
+            newString.append(s, 0, breakPoint);
             newString.append("\r\n");
-            newString.append(breakChar);
+            newString.append(s.charAt(breakPoint));
             // chop the string
             s = s.substring(breakPoint + 1);
             // start again, and we've used the first char of the limit already with the whitespace char.
             used = 1;
         }
 
-        // add on the remainder, and return
+        // add on the remainder, then make sure any embedded line breaks are usable.
         newString.append(s);
-        return newString.toString();
+        return fixupLineBreaks(newString.toString());
+    }
+
+
+    /**
+     * Clean up embedded line breaks in a header value so the folded
+     * result stays parseable (and can't be used for header injection):
+     * whitespace-only lines are dropped, every line break is normalized
+     * to CRLF, and any line that doesn't already begin with whitespace
+     * gets a leading blank.
+     *
+     * @param s      The candidate string.
+     *
+     * @return The string with all line breaks in continuation form.
+     */
+    private static String fixupLineBreaks(final String s) {
+        // scan for a line break first; most strings don't have any, and we
+        // can return them untouched.
+        if (s.indexOf('\r') < 0 && s.indexOf('\n') < 0) {
+            return s;
+        }
+
+        final int length = s.length();
+        final StringBuilder result = new StringBuilder(length + 8);
+
+        int lineStart = 0;
+        int i = 0;
+        while (i <= length) {
+            // at a break character or the end of the data, the current line is complete.
+            if (i == length || s.charAt(i) == '\r' || s.charAt(i) == '\n') {
+                final String line = s.substring(lineStart, i);
+                // completely blank lines are dropped from the output
+                if (!line.trim().isEmpty()) {
+                    if (result.length() > 0) {
+                        result.append("\r\n");
+                        // continuation lines must start with whitespace
+                        final char first = line.charAt(0);
+                        if (first != ' ' && first != '\t') {
+                            result.append(' ');
+                        }
+                    }
+                    result.append(line);
+                }
+                // step over the line break, treating CRLF as a single break
+                if (i < length && s.charAt(i) == '\r' && i + 1 < length && s.charAt(i + 1) == '\n') {
+                    i++;
+                }
+                lineStart = i + 1;
+            }
+            i++;
+        }
+        return result.toString();
     }
 
     /**
-     * Unfold a folded string.  The unfolding process will remove
-     * any line breaks that are not escaped and which are also followed
-     * by whitespace characters.
+     * Unfold a folded string.  The unfolding process removes line breaks
+     * that are followed by whitespace (leaving the whitespace in place),
+     * and honors a backslash ahead of a line break as a request to keep
+     * the break in the result.
      *
      * @param s      The folded string.
      *
@@ -1206,89 +1237,37 @@ public class MimeUtility {
             return s;
         }
 
-        // we need to scan and fix things up.
         final int length = s.length();
+        final StringBuilder newString = new StringBuilder(length);
 
-        final StringBuffer newString = new StringBuffer(length);
-
-        // scan the entire string
         for (int i = 0; i < length; i++) {
             final char ch = s.charAt(i);
 
-            // we have a backslash.  In folded strings, escape characters are only processed as such if
-            // they precede line breaks.  Otherwise, we leave it be.
-            if (ch == '\\') {
-                // escape at the very end?  Just add the character.
-                if (i == length - 1) {
-                    newString.append(ch);
+            if (ch == '\r' || ch == '\n') {
+                // find the first position after this line break, treating
+                // CRLF as a single break.
+                int next = i + 1;
+                if (ch == '\r' && next < length && s.charAt(next) == '\n') {
+                    next++;
                 }
-                else {
-                    final int nextChar = s.charAt(i + 1);
 
-                    // naked newline?  Add the new line to the buffer, and skip the escape char.
-                    if (nextChar == '\n') {
-                        newString.append('\n');
-                        i++;
-                    }
-                    else if (nextChar == '\r') {
-                        // just the CR left?  Add it, removing the escape.
-                        if (i == length - 2 || s.charAt(i + 2) != '\r') {
-                            newString.append('\r');
-                            i++;
-                        }
-                        else {
-                            // toss the escape, add both parts of the CRLF, and skip over two chars.
-                            newString.append('\r');
-                            newString.append('\n');
-                            i += 2;
-                        }
-                    }
-                    else {
-                        // an escape for another purpose, just copy it over.
-                        newString.append(ch);
-                    }
+                // a backslash directly ahead of the break marks it as data: drop the
+                // backslash (already copied, so remove it) and keep the break as-is.
+                if (newString.length() > 0 && newString.charAt(newString.length() - 1) == '\\') {
+                    newString.setLength(newString.length() - 1);
+                    newString.append(s, i, next);
                 }
-            }
-            // we have an unescaped line break
-            else if (ch == '\n' || ch == '\r') {
-                // remember the position in case we need to backtrack.
-                boolean CRLF = false;
-
-                if (ch == '\r') {
-                    // check to see if we need to step over this.
-                    if (i < length - 1 && s.charAt(i + 1) == '\n') {
-                        i++;
-                        // flag the type so we know what we might need to preserve.
-                        CRLF = true;
-                    }
-                }
-
-                // get a temp position scanner.
-                final int scan = i + 1;
-
-                // does a blank follow this new line?  we need to scrap the new line and reduce the leading blanks
-                // down to a single blank.
-                if (scan < length && s.charAt(scan) == ' ') {
-                    // add the character
-                    newString.append(' ');
-
-                    // scan over the rest of the blanks
-                    i = scan + 1;
-                    while (i < length && s.charAt(i) == ' ') {
-                        i++;
-                    }
-                    // we'll increment down below, so back up to the last blank as the current char.
-                    i--;
+                // a fold: the break disappears when followed by whitespace (which is
+                // kept), or when it sits at the very end of the data.
+                else if (next >= length || s.charAt(next) == ' ' || s.charAt(next) == '\t') {
+                    // nothing appended; the whitespace that follows is copied normally
                 }
+                // not a continuation line; the break is real data and stays.
                 else {
-                    // we must keep this line break.  Append the appropriate style.
-                    if (CRLF) {
-                        newString.append("\r\n");
-                    }
-                    else {
-                        newString.append(ch);
-                    }
+                    newString.append(s, i, next);
                 }
+                // resume scanning after the line break
+                i = next - 1;
             }
             else {
                 // just a normal, ordinary character
@@ -1331,6 +1310,43 @@ public class MimeUtility {
         return a >= 0177 || (a < 040 && a != '\r' && a != '\n' && a != '\t');
     }
 
+    /**
+     * Break a comma-separated Content-Language header value into
+     * its individual language tags.
+     *
+     * @param header The raw header value (may be null).
+     *
+     * @return An array with one entry per language tag, or null if the
+     *         header was null or contained no tags.
+     */
+    static String[] parseLanguageList(final String header) {
+        if (header == null) {
+            return null;
+        }
+
+        final List<String> languages = new ArrayList<String>();
+
+        // tokenize using the MIME rules; every ATOM between the comma delimiters
+        // is a language tag.
+        final HeaderTokenizer tokenizer = new HeaderTokenizer(header, HeaderTokenizer.MIME);
+        try {
+            HeaderTokenizer.Token token = tokenizer.next();
+            while (token.getType() != HeaderTokenizer.Token.EOF) {
+                if (token.getType() == HeaderTokenizer.Token.ATOM) {
+                    languages.add(token.getValue());
+                }
+                token = tokenizer.next();
+            }
+        } catch (final ParseException e) {
+            // stop scanning on a syntax error, keeping whatever we managed to collect
+        }
+
+        if (languages.isEmpty()) {
+            return null;
+        }
+        return languages.toArray(new String[languages.size()]);
+    }
+
     /**
      * Convert a string to a byte array by taking the low-order 8 bits of
      * each character.  The string is expected to contain only US-ASCII
diff --git a/geronimo-mail_2.1_spec/src/main/java/org/apache/geronimo/mail/util/RFC2231Encoder.java b/geronimo-mail_2.1_spec/src/main/java/org/apache/geronimo/mail/util/RFC2231Encoder.java
index 7765c9d..ee111c0 100644
--- a/geronimo-mail_2.1_spec/src/main/java/org/apache/geronimo/mail/util/RFC2231Encoder.java
+++ b/geronimo-mail_2.1_spec/src/main/java/org/apache/geronimo/mail/util/RFC2231Encoder.java
@@ -128,8 +128,12 @@ public class RFC2231Encoder implements Encoder
             final byte v = data[i++];
             // a percent is a hex character marker, need to decode a hex value.
             if (v == '%') {
-                final byte b1 = decodingTable[data[i++]];
-                final byte b2 = decodingTable[data[i++]];
+                // a percent marker must be followed by two valid hex digits.
+                if (i + 1 >= end) {
+                    throw new IOException("Truncated RFC2231 hex escape");
+                }
+                final int b1 = hexDigitValue((char)(data[i++] & 0xff));
+                final int b2 = hexDigitValue((char)(data[i++] & 0xff));
                 out.write((b1 << 4) | b2);
             }
             else {
@@ -143,6 +147,23 @@ public class RFC2231Encoder implements Encoder
         return outLen;
     }
 
+    /**
+     * Convert a single hex digit character into its numeric value,
+     * rejecting anything that isn't a valid hex digit.
+     *
+     * @param ch     The candidate digit character.
+     *
+     * @return The numeric value of the digit (0-15).
+     * @exception IOException if the character is not a hex digit.
+     */
+    private static int hexDigitValue(final char ch) throws IOException {
+        final int value = Character.digit(ch, 16);
+        if (value < 0) {
+            throw new IOException("Invalid hex digit '" + ch + "' in RFC2231 encoded value");
+        }
+        return value;
+    }
+
     /**
      * decode the RFC2231 encoded String data writing it to the given output stream.
      *
@@ -158,8 +179,12 @@ public class RFC2231Encoder implements Encoder
         {
             final char v = data.charAt(i++);
             if (v == '%') {
-                final byte b1 = decodingTable[data.charAt(i++)];
-                final byte b2 = decodingTable[data.charAt(i++)];
+                // a percent marker must be followed by two valid hex digits.
+                if (i + 1 >= end) {
+                    throw new IOException("Truncated RFC2231 hex escape");
+                }
+                final int b1 = hexDigitValue(data.charAt(i++));
+                final int b2 = hexDigitValue(data.charAt(i++));
 
                 out.write((b1 << 4) | b2);
             }
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ContentTypeTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ContentTypeTest.java
index 4fb4a34..f7262ea 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ContentTypeTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/ContentTypeTest.java
@@ -190,6 +190,18 @@ public class ContentTypeTest {
         assertFalse(type.match("text/plain/yada"));
     }
 
+    @Test
+    public void testMatchNull() throws ParseException, jakarta.mail.MessagingException {
+        final ContentType type = new ContentType("text/plain");
+        // a null argument is simply not a match, never an NPE
+        assertFalse(type.match((String) null));
+        assertFalse(type.match((ContentType) null));
+
+        // and the same must hold at the part level
+        final MimeBodyPart part = new MimeBodyPart();
+        assertFalse(part.isMimeType(null));
+    }
+
     @Test
     public void testSOAP12ContentType() throws ParseException {
         final ContentType type = new ContentType("multipart/related; type=\"application/xop+xml\"; start=\"<[email protected]>\"; start-info=\"application/soap+xml; action=\\\"urn:upload\\\"\"; boundary=\"----=_Part_10_5804917.1223557742343\"");
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/InternetAddressTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/InternetAddressTest.java
index c280322..f84cfa1 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/InternetAddressTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/InternetAddressTest.java
@@ -587,6 +587,41 @@ public class InternetAddressTest {
     }
 
 
+    @Test
+    public void testUnicodeAddressParsing() throws Exception {
+        final String mailbox = "testα@exampleα.com";
+        final String personal = "testα userα";
+
+        // an address with UTF-8 atoms must parse (RFC 6532 style)
+        final InternetAddress addr = new InternetAddress(personal + " <" + mailbox + ">");
+        assertEquals(mailbox, addr.getAddress());
+        assertEquals(personal, addr.getPersonal());
+
+        // a bare unicode mailbox parses too
+        assertEquals(mailbox, new InternetAddress(mailbox).getAddress());
+
+        // and unicode rendering keeps the raw characters
+        final InternetAddress addr2 = new InternetAddress(mailbox, personal);
+        assertEquals("\"" + personal + "\" <" + mailbox + ">", addr2.toUnicodeString());
+
+        // multi-address rendering with unicode content
+        final String mailbox2 = "testβ@exampleβ.com";
+        assertEquals(mailbox + ", " + mailbox2,
+            InternetAddress.toUnicodeString(new InternetAddress[] {
+                new InternetAddress(mailbox), new InternetAddress(mailbox2)}));
+    }
+
+    @Test
+    public void testToUnicodeStringMultipleAddresses() throws Exception {
+        // each element of the array must be rendered, not the first one repeatedly
+        final InternetAddress a = new InternetAddress("[email protected]");
+        final InternetAddress b = new InternetAddress("[email protected]");
+        assertEquals("[email protected], [email protected]",
+            InternetAddress.toUnicodeString(new InternetAddress[] {a, b}));
+        assertEquals("[email protected], [email protected]",
+            InternetAddress.toUnicodeString(new InternetAddress[] {a, b}, 0));
+    }
+
     private void validateAddress(final InternetAddress a, final String address, final String personal, final String toString, final boolean group)
     {
         assertEquals(a.getAddress(), address, "Invalid address:");
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MailDateFormatTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MailDateFormatTest.java
index 1f871de..7155ae1 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MailDateFormatTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MailDateFormatTest.java
@@ -96,4 +96,15 @@ public class MailDateFormatTest {
         assertEquals(43, cal.get(Calendar.MINUTE));
         assertEquals(00, cal.get(Calendar.SECOND));
     }
+
+    @Test
+    public void testParseClearsMilliseconds() throws ParseException {
+        final MailDateFormat mdf = new MailDateFormat();
+        // a parsed date has no millisecond component, so a format/parse round trip
+        // of a whole-second timestamp must reproduce it exactly
+        final Date original = new Date(1472598000000L);
+        final Date roundTrip = mdf.parse(mdf.format(original));
+        assertEquals(original, roundTrip);
+        assertEquals(0, roundTrip.getTime() % 1000);
+    }
 }
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeBodyPartTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeBodyPartTest.java
index 5a114f8..b501f8c 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeBodyPartTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeBodyPartTest.java
@@ -156,7 +156,14 @@ public class MimeBodyPartTest {
         final ContentDisposition disp = new ContentDisposition(part.getHeader("Content-Disposition", null));
         assertEquals("test.dat", disp.getParameter("filename"));
 
+        // setting a file name must not force a (default) Content-Type header into existence
+        assertNull(part.getHeader("Content-Type", null));
+
+        // but an existing Content-Type header gets the name parameter updated
+        part.setHeader("Content-Type", "application/octet-stream");
+        part.setFileName("test.dat");
         final ContentType type = new ContentType(part.getHeader("Content-Type", null));
+        assertEquals("application/octet-stream", type.getBaseType());
         assertEquals("test.dat", type.getParameter("name"));
 
         final MimeBodyPart part2 = new MimeBodyPart();
@@ -169,6 +176,59 @@ public class MimeBodyPartTest {
         assertEquals("test.dat", part2.getFileName());
     }
 
+    @Test
+    public void testSetFileNameEncoded() throws Exception {
+        System.setProperty("mail.mime.charset", "utf-8");
+        try {
+            // a non-ASCII filename must be written in RFC 2231 encoded form by default
+            final MimeBodyPart part = new MimeBodyPart();
+            part.setFileName("¡");
+            final String disposition = part.getHeader("Content-Disposition", null);
+            assertTrue(disposition.contains("filename*=utf-8''%C2%A1"),
+                "unexpected disposition: " + disposition);
+            // and it must decode back to the original value
+            assertEquals("¡", part.getFileName());
+
+            // plain ASCII names keep the simple form
+            final MimeBodyPart ascii = new MimeBodyPart();
+            ascii.setFileName("simple.txt");
+            assertEquals("attachment; filename=simple.txt",
+                ascii.getHeader("Content-Disposition", null));
+        } finally {
+            System.clearProperty("mail.mime.charset");
+        }
+    }
+
+    @Test
+    public void testContentLanguageSplit() throws Exception {
+        final MimeBodyPart part = new MimeBodyPart();
+        final String[] languages = {"us-english", "uk-english", "in-punjabi", "en", "fr", "de"};
+        part.setContentLanguage(languages);
+        final String[] retrieved = part.getContentLanguage();
+        assertEquals(languages.length, retrieved.length);
+        for (int i = 0; i < languages.length; i++) {
+            assertEquals(languages[i], retrieved[i]);
+        }
+        // no header at all reports null
+        assertNull(new MimeBodyPart().getContentLanguage());
+    }
+
+    @Test
+    public void testAttachFileWithExplicitContentType() throws Exception {
+        // an explicit content type supplied to attachFile must survive updateHeaders,
+        // even though setFileName is called while no Content-Type header exists yet
+        final MimeBodyPart part = new MimeBodyPart();
+        part.attachFile(testInput, "test/test", "base64");
+        part.updateHeaders();
+
+        assertTrue(part.isMimeType("test/test"));
+        assertEquals("base64", part.getEncoding());
+        // updateHeaders copies the file name into the name parameter when it creates the header
+        final ContentType type = new ContentType(part.getHeader("Content-Type", null));
+        assertEquals(testInput.getName(), type.getParameter("name"));
+        assertEquals(testInput.getName(), part.getFileName());
+    }
+
 
     @Test
     public void testAttachments() throws Exception {
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMessageTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMessageTest.java
index 5eba07a..5a687d2 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMessageTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMessageTest.java
@@ -527,6 +527,105 @@ public class MimeMessageTest {
     }
 
 
+    @Test
+    public void testGetFileNameFromDisposition() throws MessagingException {
+        final MimeMessage msg = new MimeMessage(session);
+        // the filename must survive a set/get round trip even though getDisposition()
+        // only returns the bare disposition value.
+        msg.setFileName("mailworld.txt");
+        assertEquals("mailworld.txt", msg.getFileName());
+        assertEquals(Part.ATTACHMENT, msg.getDisposition());
+
+        // a repeated set must fully replace the previous filename parameter
+        msg.setFileName("other.txt");
+        assertEquals("other.txt", msg.getFileName());
+
+        // a filename on a received header must be visible too
+        final MimeMessage msg2 = new MimeMessage(session);
+        msg2.setHeader("Content-Disposition", "attachment; filename=incoming.txt");
+        assertEquals("incoming.txt", msg2.getFileName());
+    }
+
+    @Test
+    public void testFileNameSystemProperties() throws MessagingException {
+        // the encode/decode filename controls are System properties, and must be honored
+        // even when the session knows nothing about them.
+        System.setProperty("mail.mime.encodefilename", "false");
+        System.setProperty("mail.mime.decodefilename", "true");
+        try {
+            final MimeMessage msg = new MimeMessage(session);
+            msg.setFileName("=?ISO646-US?Q?=3F=3F-a=5Fgerman=5Fcharacter?=");
+            assertEquals("??-a_german_character", msg.getFileName());
+        } finally {
+            System.clearProperty("mail.mime.encodefilename");
+            System.clearProperty("mail.mime.decodefilename");
+        }
+    }
+
+    @Test
+    public void testSetFileNameEncoded() throws MessagingException {
+        System.setProperty("mail.mime.charset", "utf-8");
+        try {
+            // a non-ASCII filename must be written in RFC 2231 encoded form by default
+            final MimeMessage msg = new MimeMessage(session);
+            msg.setFileName("¡");
+            final String disposition = msg.getHeader("Content-Disposition", null);
+            assertTrue(disposition.contains("filename*=utf-8''%C2%A1"),
+                "unexpected disposition: " + disposition);
+            assertEquals("¡", msg.getFileName());
+        } finally {
+            System.clearProperty("mail.mime.charset");
+        }
+    }
+
+    @Test
+    public void testContentLanguageSplit() throws MessagingException {
+        final MimeMessage msg = new MimeMessage(session);
+        final String[] languages = {"en", "fr", "de"};
+        msg.setContentLanguage(languages);
+        // the comma-separated header must be split back into individual tags
+        final String[] retrieved = msg.getContentLanguage();
+        assertEquals(3, retrieved.length);
+        assertEquals("en", retrieved[0]);
+        assertEquals("fr", retrieved[1]);
+        assertEquals("de", retrieved[2]);
+    }
+
+    @Test
+    public void testAllowUtf8Headers() throws MessagingException, IOException {
+        final String mailbox = "testα@exampleα.com";
+        final String personal = "testα userα";
+
+        final Properties props = new Properties();
+        props.setProperty("mail.mime.allowutf8", "true");
+        final Session utf8Session = Session.getInstance(props);
+
+        final MimeMessage msg = new MimeMessage(utf8Session);
+        msg.setRecipient(Message.RecipientType.TO, new InternetAddress(mailbox));
+        msg.setHeader("Header", personal);
+        msg.setText("");
+
+        final ByteArrayOutputStream out = new ByteArrayOutputStream();
+        msg.writeTo(out);
+        final String written = new String(out.toByteArray(), java.nio.charset.StandardCharsets.UTF_8);
+        assertTrue(written.contains("To: " + mailbox + "\r\n"), "missing UTF-8 To header:\n" + written);
+        assertTrue(written.contains("Header: " + personal + "\r\n"), "missing UTF-8 custom header:\n" + written);
+
+        // and reading the message back with the same session restores the unicode values
+        final MimeMessage reread = new MimeMessage(utf8Session, new ByteArrayInputStream(out.toByteArray()));
+        assertEquals(mailbox, ((InternetAddress) reread.getRecipients(Message.RecipientType.TO)[0]).getAddress());
+        assertEquals(personal, reread.getHeader("Header", null));
+
+        // without the property, header output remains ISO8859-1 based
+        final MimeMessage plain = new MimeMessage(session);
+        plain.setHeader("X-Test", "abc");
+        plain.setText("");
+        final ByteArrayOutputStream plainOut = new ByteArrayOutputStream();
+        plain.writeTo(plainOut);
+        assertTrue(new String(plainOut.toByteArray(), java.nio.charset.StandardCharsets.ISO_8859_1)
+            .contains("X-Test: abc\r\n"));
+    }
+
     @BeforeEach
     public void setUp() throws Exception {
         defaultMap = CommandMap.getDefaultCommandMap();
@@ -545,4 +644,14 @@ public class MimeMessageTest {
     public void tearDown() throws Exception {
         CommandMap.setDefaultCommandMap(defaultMap);
     }
+
+    @Test
+    public void testGetFileNameWithBlankDispositionHeader() throws Exception {
+        // headers merged from store metadata can carry an empty
+        // Content-Disposition value, which must read as "no file name"
+        // rather than failing to parse
+        MimeMessage msg = new MimeMessage(Session.getInstance(new Properties()));
+        msg.setHeader("Content-Disposition", "");
+        assertNull(msg.getFileName());
+    }
 }
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMultipartTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMultipartTest.java
index 06e1046..e62f647 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMultipartTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeMultipartTest.java
@@ -36,6 +36,7 @@ import org.junit.jupiter.api.Test;
 import jakarta.mail.BodyPart;
 import jakarta.mail.Message;
 import jakarta.mail.MessagingException;
+import jakarta.mail.MultipartDataSource;
 import jakarta.mail.Session;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -294,6 +295,68 @@ public class MimeMultipartTest {
     	return null;
     }
 
+    @Test
+    public void testPreambleLineEndingsPreserved() throws Exception {
+        // LF-only source data must produce an LF-only preamble
+        final String lfMessage = "Preamble\n--abc\nContent-Type: text/plain\n\nbody\n--abc--\n";
+        final MimeMultipart lfPart = new MimeMultipart(new jakarta.mail.util.ByteArrayDataSource(
+                lfMessage.getBytes("ISO8859-1"), "multipart/mixed; boundary=abc"));
+        assertEquals(1, lfPart.getCount());
+        assertEquals("Preamble\n", lfPart.getPreamble());
+
+        // CRLF source data keeps CRLF terminators, including multi-line preambles
+        final String crlfMessage = "line one\r\nline two\r\n--abc\r\nContent-Type: text/plain\r\n\r\nbody\r\n--abc--\r\n";
+        final MimeMultipart crlfPart = new MimeMultipart(new jakarta.mail.util.ByteArrayDataSource(
+                crlfMessage.getBytes("ISO8859-1"), "multipart/mixed; boundary=abc"));
+        assertEquals(1, crlfPart.getCount());
+        assertEquals("line one\r\nline two\r\n", crlfPart.getPreamble());
+    }
+
+    @Test
+    public void testMultipartDataSourceContentType() throws Exception {
+        writeToSetUp();
+        try {
+            // build a MultipartDataSource-backed multipart; the content type must be
+            // taken from the data source so writeTo() can find the boundary
+            final MimeBodyPart part = new MimeBodyPart();
+            part.setContent("Hello World", "text/plain");
+            part.setHeader("Content-Type", "text/plain");
+
+            final MultipartDataSource mds = new MultipartDataSource() {
+                public InputStream getInputStream() throws IOException {
+                    throw new IOException("no stream");
+                }
+                public OutputStream getOutputStream() throws IOException {
+                    throw new IOException("read only");
+                }
+                public String getContentType() {
+                    return "multipart/mixed; boundary=unittestboundary";
+                }
+                public String getName() {
+                    return "test";
+                }
+                public int getCount() {
+                    return 1;
+                }
+                public BodyPart getBodyPart(final int index) {
+                    return part;
+                }
+            };
+
+            final MimeMultipart mp = new MimeMultipart(mds);
+            assertEquals("multipart/mixed; boundary=unittestboundary", mp.getContentType());
+            assertEquals(1, mp.getCount());
+
+            final ByteArrayOutputStream out = new ByteArrayOutputStream();
+            mp.writeTo(out);
+            final String written = out.toString("ISO8859-1");
+            assertTrue(written.contains("--unittestboundary"));
+            assertTrue(written.contains("Hello World"));
+        } finally {
+            writeToTearDown();
+        }
+    }
+
     protected void writeToSetUp() throws Exception {
         defaultMap = CommandMap.getDefaultCommandMap();
         final MailcapCommandMap myMap = new MailcapCommandMap();
diff --git a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeUtilityTest.java b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeUtilityTest.java
index cfaaaf5..e15846c 100644
--- a/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeUtilityTest.java
+++ b/geronimo-mail_2.1_spec/src/test/java/jakarta/mail/internet/MimeUtilityTest.java
@@ -99,6 +99,56 @@ public class MimeUtilityTest {
     }
 
 
+    @Test
+    public void testFoldEmbeddedLineBreaks() throws Exception {
+        final String a66 = "a".repeat(66);
+        final String a87 = "a".repeat(87);
+        final String a72 = "a".repeat(72);
+
+        // strings that fit are returned unchanged
+        assertEquals("a b c", MimeUtility.fold(0, "a b c"));
+        // trailing whitespace (including line breaks) is trimmed
+        assertEquals("a b c", MimeUtility.fold(0, "a b c \t\r\n"));
+        // long runs with no whitespace are not broken
+        assertEquals(a87, MimeUtility.fold(0, a87));
+        // basic folding keeps the whitespace on the continuation line
+        assertEquals(a66 + "\r\n " + "a".repeat(21), MimeUtility.fold(0, a66 + " " + "a".repeat(21)));
+        // a run of blanks folds ahead of the run and keeps the full run
+        assertEquals(a72 + "\r\n  xxx", MimeUtility.fold(0, a72 + "  xxx"));
+
+        // embedded bare line breaks are rewritten into continuation form
+        assertEquals("a\r\n b", MimeUtility.fold(0, "a\nb"));
+        assertEquals("a\r\n b", MimeUtility.fold(0, "a\rb"));
+        assertEquals("a\r\n b", MimeUtility.fold(0, "a\r\nb"));
+        // blank lines are dropped entirely
+        assertEquals("a\r\n b", MimeUtility.fold(0, "a\n\nb"));
+        // breaks already followed by whitespace stay in continuation form
+        assertEquals("a\r\n b", MimeUtility.fold(0, "a\n b"));
+    }
+
+    @Test
+    public void testUnfoldSemantics() throws Exception {
+        // a break followed by whitespace disappears; the whitespace stays
+        assertEquals("a b", MimeUtility.unfold("a\n b"));
+        assertEquals("a  b", MimeUtility.unfold("a \n b"));
+        assertEquals("a\tb", MimeUtility.unfold("a\n\tb"));
+        assertEquals("a b c", MimeUtility.unfold("a\n b\n c"));
+        // a backslash marks the break as data: backslash removed, break kept
+        assertEquals("a \n b", MimeUtility.unfold("a \\\n b"));
+        assertEquals("a\n b\n c", MimeUtility.unfold("a\\\n b\\\n c"));
+        assertEquals("\n a", MimeUtility.unfold("\\\n a"));
+        // a break not followed by whitespace is real data and stays
+        assertEquals("\na", MimeUtility.unfold("\na"));
+        // a break at the end of the string is removed
+        assertEquals("a", MimeUtility.unfold("a\n"));
+        assertEquals("a ", MimeUtility.unfold("a\n "));
+        // leading break followed by whitespace is removed
+        assertEquals(" a", MimeUtility.unfold("\n a"));
+        // CRLF folds behave like single-character breaks
+        assertEquals("a b", MimeUtility.unfold("a\r\n b"));
+        assertEquals("a \r\n b", MimeUtility.unfold("a \\\r\n b"));
+    }
+
     public void doFoldTest(final int used, final String source, final String folded) throws Exception {
         final String newFolded = MimeUtility.fold(used, source);
         final String newUnfolded = MimeUtility.unfold(newFolded);
@@ -108,6 +158,20 @@ public class MimeUtilityTest {
     }
 
 
+    @Test
+    public void testDecodeWordCaseInsensitiveEncoding() throws Exception {
+        // RFC 2047 encoding tokens are case-insensitive: lowercase b/q must decode too
+        assertEquals("If you can read this yo",
+            MimeUtility.decodeWord("=?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?="));
+        assertEquals("If you can read this yo",
+            MimeUtility.decodeWord("=?ISO-8859-1?b?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?="));
+        assertEquals("André", MimeUtility.decodeWord("=?ISO-8859-1?Q?Andr=E9?="));
+        assertEquals("André", MimeUtility.decodeWord("=?ISO-8859-1?q?Andr=E9?="));
+        // decodeText passes lowercase-encoded words through decodeWord
+        assertEquals("םולש ןב ילטפנ",
+            MimeUtility.decodeText("=?iso-8859-8?b?7eXs+SDv4SDp7Oj08A==?="));
+    }
+
     @Test
     public void testEncodeWord() throws Exception {
         assertEquals("abc", MimeUtility.encodeWord("abc"));
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 0bb4681..a90a47a 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
@@ -22,6 +22,7 @@ package jakarta.mail.internet;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 /**
  * @version $Rev$ $Date$
@@ -167,4 +168,23 @@ public class ParameterListTest {
         assertEquals(value, list.get("one"));
         assertEquals(list2.toString(), encodedTest);
     }
+
+    @Test
+    public void testStrictDecodeRejectsBadHex() {
+        System.setProperty("mail.mime.decodeparameters", "true");
+        System.setProperty("mail.mime.decodeparameters.strict", "true");
+        try {
+            // "%2x" is not a valid hex escape, so strict decoding must raise a ParseException
+            assertThrows(ParseException.class, () ->
+                new ParameterList("; filename*=us-ascii'en-us'This%2xis%20%2A%2A%2Afun%2A%2A%2A"));
+            // a valid encoding still parses in strict mode
+            final ParameterList ok = new ParameterList("; filename*=us-ascii'en-us'This%20is%20fun");
+            assertEquals("This is fun", ok.get("filename"));
+        } catch (final ParseException e) {
+            throw new AssertionError("valid RFC2231 value failed to parse", e);
+        } finally {
+            System.clearProperty("mail.mime.decodeparameters");
+            System.clearProperty("mail.mime.decodeparameters.strict");
+        }
+    }
 }
diff --git a/geronimo-mail_2.1_tck/src/tck/geronimo.jtx b/geronimo-mail_2.1_tck/src/tck/geronimo.jtx
index 1e1495e..102be49 100644
--- a/geronimo-mail_2.1_tck/src/tck/geronimo.jtx
+++ b/geronimo-mail_2.1_tck/src/tck/geronimo.jtx
@@ -23,24 +23,9 @@
 #
 
 
-# Remaining baseline failures (284 passed / 31 failed after the
-# IMAPFolder.renameTo fix removed a ~90-test cascade). Distinct defects:
-# folder create/delete (topdog), permanent flags, list/listSubscribed,
-# several MIME encoding/decoding and unicode behaviours.
-javasoft/sqe/tests/jakarta/mail/internet/InternetAddress/testlist.html#unicode_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeBodyPart/testlist.html#attachFile_saveFile_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeBodyPart/testlist.html#isMimeType_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeBodyPart/testlist.html#setContentLanguage_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeBodyPart/testlist.html#setFileNameEncoded_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeMessage/testlist.html#setFileNameEncoded_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeMessage/testlist.html#setSentDate_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeMessage/testlist.html#unicode_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeMessage/testlist.html#updateHeaders_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeMultipart/testlist.html#getsetPreamble_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeMultipart/testlist.html#writeTo_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeUtility/testlist.html#decodeText_Test
-javasoft/sqe/tests/jakarta/mail/internet/MimeUtility/testlist.html#foldUnfold_Test
-javasoft/sqe/tests/jakarta/mail/internet/ParameterList/testlist.html#set_withDecodeStrict_Test
-javasoft/sqe/tests/jakarta/mail/Message/testlist.html#setFileName_Test
-javasoft/sqe/tests/jakarta/mail/Message/testlist.html#setFileNameTest_encodeFalse_decodeTrue
-javasoft/sqe/tests/jakarta/mail/Multipart/testlist.html#writeTo_Test
+# All previously excluded MIME encoding/decoding and unicode failures have
+# been fixed (getFileName/setFileName disposition handling, RFC 2231 filename
+# encoding, ContentType.match(null), Content-Language splitting, MailDateFormat
+# millisecond leakage, MultipartDataSource content type, preamble line endings,
+# RFC 2047 lowercase encoding tokens, fold/unfold semantics, RFC 2231 strict
+# hex validation, toUnicodeString looping, and UTF-8 address/header support).