(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).