JavaMailではMIMEに準拠した国際化もほぼ対応されており、日本語を含むメッセージもほとんど問題なく送受信ができるようになっています。ただし、まだ完全ではありませんし、当然日本という国に固有の事情までプログラミングされているわけではありませんので、一部のAPIは日本では当たり前に流れているメッセージをうまく取り扱えないケースがあります。
それらの問題点については、各APIの説明時にも触れていますが、ここで、JavaMailで日本語メッセージを取り扱う際にプログラマが対処しなければならない問題についてまとめてみます。
ほとんどの問題は特定の日本語メイラがインターネットスタンダードに準拠していないことに起因するのですが、そのようなメイラが送出する不正なメッセージを処理できないままでいいかというとなかなかそういうわけにもいかないんですね(*)。
対処する必要がある項目は解っているものだけで以下のようなものがあります。
*:もちろんそういったメイラを使っている人には、できるかぎり正しいメッセージを送信できるメイラ(または設定)に変えて下さいと啓蒙していくべきです。
[ヘッダの問題]
[ボディの問題]
[その他]
では、これらに対処する方法について見ていきましょう。
[問題点1, 2]についてです。encoded-wordとはヘッダに非ASCII文字が存在する場合のエンコート゛を適用したもので、ヘッダの文字列中の"=?ISO-2022-JP?B?・・・・?="のような部分のことです。RFC2047で規定されています。
JavaMailはRFC2047に忠実に従って、このような形式の含まれる文字列を元の文字列に復元してくれるのですが、実は他のMUAが送信するメッセージのヘッダがRFC2047に正しく従っていないため、JavaMailで受信したメッセージのヘッダがデコードされずに"=?ISO-2022-JP?B?・・・・?="のような状態のままになることがあります。
最も多い誤りは、encoded-wordの前後には()で囲まれていない限り、空白(ここではSPACE/HTABの何れかを指します)が存在しなければならないにもかかわらず、空白の存在しない文字位置からエンコードを開始/終了してしまっているというものです。
例えば"JavaMail 1.2について"というSubject:に対して、ある(有名な)MUAでは以下のようなエンコードを行います。
Subject: JavaMail 1.2=?ISO-2022-JP?B?GyRCJEskRCQkJEYbKEI=?=
正しくは、以下のようにエンコードしなければなりません。
Subject: JavaMail =?ISO-2022-JP?B?MS4yGyRCJEskRCQkJEYbKEI=?=
ASCII部とencoded-wordの境界には必ず空白が必要なのに、前者は空白の有無に関係なく非ASCII文字が見つかった位置からエンコードを行っているという間違いです。このような間違いを起こすくらいなら、Subject全体をエンコードしてしまった方がよいのですが、慣習的に"Re: "といったASCIIのみの部分をencoded-wordに含めないように実装しているものがあるということです。細切れにエンコードすること自体には意味があるのですが、間違った位置でエンコードを始められてしまうとJavaMailのようなRFCに忠実なMUAでは正しくデコードできないというわけです。
ちなみに、encoded-wordの末尾に空白が存在しない場合、JavaMailはその後空白が存在する位置までを削除してしまいます。これはencoded-wordの終端の判定を空白文字位置を検査する事によって行っているためです。encoded-wordの開始位置から次の空白までをencoded-wordだと認識するが、実際には?=までのデコードを行って、その後の文字は捨てられるというわけです。
さらにもう一点、From:等のヘッダの日本語部分について、勝手に""で囲ってしまうMUAもあるようです。
「木下 信 <shin@localhost>」というメイルアドレスが、
From: "=?ISO-2022-JP?B?GyRCJDckcxsoQg==?=" <shin@localhost>
のようになってしまうのです。"をつけるのは本来は誤りです。ひどいものだと以下のようにquoteされた内部をfoldingしてしまうものもあるようです。
From: "=?ISO-2022-JP?B?GyRCJDckcxsoQg==?= @ =?ISO-2022-JP?B?GyRCJDckcxsoQg==?=" <shin@localhost>
この場合、encoded-wordの前後の空白以前の問題として、「構造化フィールド内でquoteされた文字列はそのまま表示する」という規約に忠実なMUAでは上記の文字列がそのまま表示されてしまいます。
この問題に対処するためには、Message#getSubject()等をそのまま用いず、getHeader("Subject",
null)のようにしてエンコードされたヘッダをそのままの状態で取得して、自分でデコードを行う必要があります。また、全てに通用するわけではありませんが、MimeMessageのサブクラスを作成して、getSubject()等をオーバーライドする方法もあります。
なお、この問題はインターネットの慣習である「送信は厳密に、受信は寛容に」に従って、近い将来JavaMail本体で対処してくれる予定もあるようです。とはいえこの手の特定の国に固有の問題の全てにJavaMail側で対処していては、ライブラリがどんどん巨大になってしまいますので限度がありますね。
以降で、上記のような間違ったエンコードが施されたものもデコードできるような改良版decodeText()を紹介します。
MIME登場以前のヘッダへの日本語表記について、ISO-2022-JP(いわゆるJISコード。JUNETコードとも呼ばれていました)をそのまま記述するという慣習がありました。これはISO-2022-JPが7bitで表現されることから、そのままでもメッセージ転送に関しては問題ないとされていたため、互いのMUAがISO-2022-JPであると決め打つことでヘッダ上での日本語を表現していたのです。
しかし、本来ヘッダにISO-2022-JPのコードが存在してはなりません。特にFrom:To:等の構造化フィールドについてはISO-2022-JPに含まれるエスケープコード(0x1b)によって一部のMTAが誤動作を引き起こすため絶対にだめとされています。
現在はMIMEでヘッダのエンコード方法が規定されましたので、それに従っていればMIME対応MUAであれば問題なく日本語が使用でき、しかも安全なコードしか用いられないので転送における問題は絶対に発生しません。しかし、未だにISO-2022-JPのコードをそのままヘッダに記述するMUAもいくつかあります。
JavaMailは当然このようなヘッダを正しく解釈できませんので、自分で回避コードを書く必要があります。
具体的にはMIMEに従ったデコードを実行する前に、ISO-2022-JPそのままの文字列であるかのチェックを行い、その場合はISO-2022-JPからJavaのUnicodeで構成されたStringオブジェクトに変換する必要があります。
さらに、[問題点4]に記したように、MIMEエンコードされていながら、そこで指定した文字エンコーディングスキームと、実際のエンコードされたバイト列が異なるメイルと言うのも筆者は見ています。
Subject: =?utf-8?Q?・・・(ISO-2022-JPのバイト列をQエンコードしたもの)・・・?=
と言ったものですT_T。
このようなメッセージについてまで対処すべきかどうかは微妙なところではありますが、少なくともこのケースでは、通常のデコード処理を行った結果の文字列に、通常あり得ないESCコード(0x1b)が存在した場合にはISO-2022-JPと見なす、と言う方法で回避できる可能性があります。
しかし、通常のデコード処理時に文字列が変に改変されていると失敗するかもしれません。筆者のところでは現状まだ、この対処を入れたもので問題は発生していませんので、一応ご紹介しておきます。以下に示すコードがいくつかの日本語ヘッダの問題点を補正してデコードを行うものです。
public static String decodeText(String source) throws ParseException {
if (source == null) return null;
// specially for Japanese
if (source.indexOf('\u001b') > 0) {
// ISO-2022-JP
try {
return new String(
source.getBytes("ISO-8859-1"),
"ISO-2022-JP");
} catch (UnsupportedEncodingException e) {
throw new InternalError();
}
}
int startIndex;
int endIndex = 0;
String encodedWord;
StringBuffer buf = new StringBuffer();
while (true) {
startIndex = source.indexOf("=?", endIndex);
if (startIndex == -1) {
buf.append(source.substring(endIndex));
break;
} else if (startIndex > endIndex) {
String work = source.substring(endIndex, startIndex);
if (indexOfNonLWSP(work, 0, false) > -1) {
buf.append(work);
}
// encoded-word同士の間のLWSPは削除
}
// skip "=?..?..?"
// because In the case of "Q" encoding,
// it exists that a word is the case of "=?..?Q?=1B...?=".
endIndex = source.indexOf('?', startIndex + 2);
if (endIndex == -1) {
buf.append(source.substring(startIndex));
break;
}
endIndex = source.indexOf('?', endIndex + 1);
if (endIndex == -1) {
buf.append(source.substring(startIndex));
break;
}
endIndex = source.indexOf("?=", endIndex + 1);
if (endIndex == -1) {
buf.append(source.substring(startIndex));
break;
}
endIndex += 2;
encodedWord = source.substring(startIndex, endIndex);
try {
buf.append(MimeUtility.decodeWord(encodedWord));
} catch (UnsupportedEncodingException ex) {
buf.append(encodedWord);
}
}
String decodedText = new String(buf);
if (decodedText.indexOf('\u001b') > 0) {
try {
return new String(
decodedText.getBytes("ISO-8859-1"),
"ISO-2022-JP");
} catch (UnsupportedEncodingException e) {
throw new InternalError();
}
}
return decodedText;
}
これはMimeUtility#decodeText()と同じように使用できます。もちろんこれによって常に送信者が記述した通りのヘッダが復元できるとは限りません。元がRFCに従っていないため、保証のしようがないわけです。ただ、RFCに従っているものがおかしくなるようなことはないように気をつけてはいます。
これはJavaMailの問題と言えます。[問題点5]に記した通り、JavaMailは、MimeUtility#encodeText()でMIMEエンコードを施すか施さないかの判断に、渡された文字列内に非ASCII文字が含まれるか否かという条件を用います。そして、非ASCII文字が一字でも含まれた場合は、渡された文字列全体をエンコードします。これはRFC2047に違反しているわけではありませんが、例えばSubject:の"Re:
"という文字列などはencoded-wordに含めない方がよいとされています。この部分をチェックして、「返信」であることを判断するようなMUAもあり、そのようなMUAの全てがSubject:をデコードした後の文字列で"Re:
"のチェックを行うとは限らないためでした。しかし、これは過去の問題ともいえます。現在は、MIMEエンコードされたヘッダの扱いは、まずデコードしてから内容のチェックを行うと言うことは、大概のMUAが行っています。
結局、現在のJavaMailの挙動で問題ないと言えるのですが、より「優しい」メッセージを送信するなら、encoded-wordを形成するのは、非ASCII文字を含み、空白で区切られた範囲のみとします。
これに対応するencodeWord()をご紹介しておきます。
public static String encodeText(String source,
String charset, String encoding)
throws UnsupportedEncodingException {
int startIndex;
int endIndex = 0;
int lastLWSPIndex;
String encodeTargetText;
StringBuffer buf = new StringBuffer();
while (true) {
startIndex = indexOfNonAscii(source, endIndex);
if (startIndex == -1) {
buf.append(source.substring(endIndex));
return new String(buf);
}
// any LWSP has taken.
lastLWSPIndex = indexOfLWSP(source, startIndex, true, '(');
startIndex = indexOfNonLWSP(source, lastLWSPIndex, true) + 1;
if (source.charAt(startIndex) == '(') {
startIndex++;
}
startIndex = (endIndex > startIndex) ? endIndex : startIndex;
if (startIndex > endIndex) {
// ASCII part
buf.append(source.substring(endIndex, startIndex));
buf.append("\r\n ");
startIndex++;
} else if (endIndex > 0) {
// prev is encoded-word
buf.append("\r\n ");
}
// any LWSP has taken.
endIndex = indexOfNonLWSP(source, startIndex, false);
endIndex = indexOfLWSP(source, endIndex, false, ')');
endIndex = indexOfNonLWSP(source, endIndex, false);
if (endIndex < 0) {
endIndex = source.length();
} else if (source.charAt(endIndex) != ')') {
endIndex--;
}
encodeTargetText = source.substring(startIndex, endIndex);
buf.append(MimeUtility.encodeWord(
encodeTargetText, charset, encoding));
}
}
/**
* 指定位置から最初に見つかった非ASCII文字のIndexを返します。
* @param source 検索する文字列
* @param startIndex 検索開始位置
* @return 検出した非ASCII文字Index。見つからなければ-1。
*/
public static int indexOfNonAscii(String source, int startIndex) {
for (int i = startIndex; i < source.length(); i++) {
if (source.charAt(i) > 0x7f) {
return i;
}
}
return -1;
}
/**
* 指定位置から最初に見つかったLWSP以外の文字のIndexを返します。
* @param source 検索する文字列
* @param startIndex 検索開始位置
* @param decrease trueで後方検索
* @return 検出した非ASCII文字Index。見つからなければ-1。
*/
public static int indexOfNonLWSP(String source, int startIndex,
boolean decrease) {
char c;
int inc = 1;
if (decrease) inc = -1;
for (int i = startIndex; i >= 0 && i < source.length(); i += inc) {
c = source.charAt(i);
if (!isLWSP(c)) {
return i;
}
}
return -1;
}
/**
* 指定位置から最初に見つかったLWSPのIndexを返します。
* @param source 検索する文字列
* @param startIndex 検索開始位置
* @param decrease trueで後方検索
* @param additionalDelimiter LWSP以外に区切りとみなす文字(1字のみ)
* @return 検出した非ASCII文字Index。見つからなければ-1。
*/
public static int indexOfLWSP(String source, int startIndex,
boolean decrease, char additionalDelimiter) {
char c;
int inc = 1;
if (decrease) inc = -1;
for (int i = startIndex; i >= 0 && i < source.length(); i += inc) {
c = source.charAt(i);
if (isLWSP(c) || c == additionalDelimiter) {
return i;
}
}
return -1;
}
public static boolean isLWSP(char c) {
return c == '\r' || c == '\n' || c == ' ' || c == '\t';
}
実はJavaMailは添付ファイル名の他国語対応を全く行っていません[問題点6,
7]。従って、添付ファイル名に日本語が用いられている場合は、正しく受信できませんし、そのようなメッセージを生成/送信することもできないのです。
この、添付ファイル名の他国語対応(およびファイル名が長い場合の対応)についてはRFC2231で規定されているのですが、未だ対応するMUAが少ないという理由で、JavaMailでの対応も先送りにされています。
今回これに対応するコードを書きましたが、この点についてはRFE(Request
For Enhancement:将来の機能拡張要求)にも挙げられていますので、みなさんVoteしましょう。
RFC2231適合に関して
http://developer.java.sun.com/developer/bugParade/bugs/4107342.html
RFC2231で規定された添付ファイル名の他国語表記法を守っていないMUAはいったいどうやって日本語の添付ファイル名を扱えるようにしているのでしょう?もともとこの要求はRFC2231が規定されるより以前からあったため、各ベンダは独自の方法で日本語添付ファイル名をヘッダに記述していました。その方法は、Content-Disposition: ヘッダのファイル名パラメタ部にMIMEエンコードした日本語ファイル名を記述するものや、ISO-2022-JPの日本語コードをそのまま記述するもの等です[問題点8, 9, 10]。
このようにメイラによって日本語を含む添付ファイル名のエンコード方法がばらばらであることも問題ですが、そもそもJavaMailがファイル名の扱いに関してAsciiを前提にしている点をなんとかしなければなりません。
本書はRFC準拠を第一としますので、まずは、RFC2231に対応したヘッダのパラメタ部のエンコードに対応するコードを紹介します。
public static void setFileName(Part part, String filename,
String charset, String lang)
throws MessagingException {
ContentDisposition disposition;
String[] strings = part.getHeader("Content-Disposition");
if (strings == null || strings.length < 1) {
disposition = new ContentDisposition(Part.ATTACHMENT);
} else {
disposition = new ContentDisposition(strings[0]);
disposition.getParameterList().remove("filename");
}
part.setHeader("Content-Disposition",
disposition.toString() +
encodeParameter("filename", filename, charset, lang));
ContentType cType;
strings = part.getHeader("Content-Type");
if (strings == null || strings.length < 1) {
cType = new ContentType(part.getDataHandler().getContentType());
} else {
cType = new ContentType(strings[0]);
}
try {
// I want to public the MimeUtility#doEncode()!!!
String mimeString = MimeUtility.encodeWord(filename, charset, "B");
// cut <CRLF>...
StringBuffer sb = new StringBuffer();
int i;
while ((i = mimeString.indexOf('\r')) != -1) {
sb.append(mimeString.substring(0, i));
mimeString = mimeString.substring(i + 2);
}
sb.append(mimeString);
cType.setParameter("name", new String(sb));
} catch (UnsupportedEncodingException e) {
throw new MessagingException("Encoding error", e);
}
part.setHeader("Content-Type", cType.toString());
}
public static String encodeParameter(String name, String value,
String encoding, String lang) {
StringBuffer result = new StringBuffer();
StringBuffer encodedPart = new StringBuffer();
boolean needWriteCES = !isAllAscii(value);
boolean CESWasWritten = false;
boolean encoded;
boolean needFolding = false;
int sequenceNo = 0;
int column;
while (value.length() > 0) {
// index of boundary of ascii/non ascii
int lastIndex;
boolean isAscii = value.charAt(0) < 0x80;
for (lastIndex = 1; lastIndex < value.length(); lastIndex++) {
if (value.charAt(lastIndex) < 0x80) {
if (!isAscii) break;
} else {
if (isAscii) break;
}
}
if (lastIndex != value.length()) needFolding = true;
RETRY: while (true) {
encodedPart.delete(0, encodedPart.length());
String target = value.substring(0, lastIndex);
byte[] bytes;
try {
if (isAscii) {
bytes = target.getBytes("us-ascii");
} else {
bytes = target.getBytes(encoding);
}
} catch (UnsupportedEncodingException e) {
bytes = target.getBytes(); // use default encoding
encoding = MimeUtility.mimeCharset(
MimeUtility.getDefaultJavaCharset());
}
encoded = false;
// It is not strict.
column = name.length() + 7; // size of " " and "*nn*=" and ";"
for (int i = 0; i < bytes.length; i++) {
if (bytes[i] > ' ' && bytes[i] < 'z'
&& HeaderTokenizer.MIME.indexOf((char)bytes[i]) < 0) {
encodedPart.append((char)bytes[i]);
column++;
} else {
encoded = true;
encodedPart.append('%');
String hex = Integer.toString(bytes[i] & 0xff, 16);
if (hex.length() == 1) {
encodedPart.append('0');
}
encodedPart.append(hex);
column += 3;
}
if (column > 76) {
needFolding = true;
lastIndex /= 2;
continue RETRY;
}
}
result.append(";\r\n ").append(name);
if (needFolding) {
result.append('*').append(sequenceNo);
sequenceNo++;
}
if (!CESWasWritten && needWriteCES) {
result.append("*=");
CESWasWritten = true;
result.append(encoding).append('\'');
if (lang != null) result.append(lang);
result.append('\'');
} else if (encoded) {
result.append("*=");
} else {
result.append('=');
}
result.append(new String(encodedPart));
value = value.substring(lastIndex);
break;
}
}
return new String(result);
}
/** check if contains only ascii characters in text. */
public static boolean isAllAscii(String text) {
for (int i = 0; i < text.length(); i++) {
if (text.charAt(i) > 0x7f) { // non-ascii
return false;
}
}
return true;
}
次に、RFC2231に準拠したパラメタをデコードする処理です。
public static String getFileName(Part part) throws MessagingException {
String[] disposition = part.getHeader("Content-Disposition");
if (disposition == null || disposition.length < 1) {
return null;
}
// 本来そのまま返すところだが日本固有のデコード処理を挟む。
return decodeParameterSpciallyJapanese(
getParameter(disposition[0], "filename"));
}
static class Encoding {
String encoding = "us-ascii";
String lang = "";
}
public static String getParameter(String header, String name) {
HeaderTokenizer tokenizer =
new HeaderTokenizer(header, HeaderTokenizer.MIME, true);
HeaderTokenizer.Token token;
StringBuffer sb = new StringBuffer();
// It is specified in first encoded-part.
Encoding encoding = new Encoding();
String n;
String v;
try {
while (true) {
token = tokenizer.next();
if (token.getType() == token.EOF) break;
if (token.getType() != ';') continue;
token = tokenizer.next();
checkType(token);
n = token.getValue();
token = tokenizer.next();
if (token.getType() != '=') {
throw new ParseException(
"Illegal token : " + token.getValue());
}
token = tokenizer.next();
checkType(token);
v = token.getValue();
if (n.equalsIgnoreCase(name)) {
// It is not divided and is not encoded.
return v;
}
int index = name.length();
if (!n.startsWith(name) || n.charAt(index) != '*') {
// another parameter
continue;
}
// be folded, or be encoded
int lastIndex = n.length() - 1;
if (n.charAt(lastIndex) == '*') {
sb.append(decodeRFC2231(v, encoding));
} else {
sb.append(v);
}
if (index == lastIndex) {
// not folding
break;
}
}
return new String(sb);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
} catch (ParseException e) {
e.printStackTrace();
}
throw new InternalError();
}
private static void checkType(HeaderTokenizer.Token token)
throws ParseException {
int t = token.getType();
if (t != HeaderTokenizer.Token.ATOM &&
t != HeaderTokenizer.Token.QUOTEDSTRING) {
throw new ParseException("Illegal token : " + token.getValue());
}
}
// "lang" tag is ignored...
private static String decodeRFC2231(String s, Encoding encoding)
throws ParseException, UnsupportedEncodingException {
StringBuffer sb = new StringBuffer();
int i = 0;
int work = s.indexOf('\'');
if (work > 0) {
encoding.encoding = s.substring(0, work);
work++;
i = s.indexOf('\'', work);
encoding.lang = s.substring(work, i);
i++;
}
try {
for (; i < s.length(); i++) {
if (s.charAt(i) == '%') {
sb.append((char)Integer.parseInt(
s.substring(i + 1, i + 3), 16));
i += 2;
continue;
}
sb.append(s.charAt(i));
}
return new String(
new String(sb).getBytes("ISO-8859-1"),
encoding.encoding);
} catch (IndexOutOfBoundsException e) {
throw new ParseException(s + " :: this string were not decoded.");
}
}
ここまではJavaMail本体に組み込んでもらうように提言しているのですが、受信に関しては、これだけでは足りないのは先に述べた通りです。RFC2231規定前に実装された多くのMUAが送信するファイル名パラメタを解釈できるように対処する必要があります(「受信は寛容に」の法則に従えばMIMEエンコーディングの解釈を試みる部分まではJavaMailが対応してもいいかもしれませんが)。
というわけで、Content-Disposition:のfilenameパラメタがMIMEエンコードされている可能性とISO-2022-JPまたはShift-JISコードで記述されている可能性をチェックして日本語に変換するフィルタも紹介します。
注:このコードは一部日本語を前提としていることになります。
public static String decodeParameterSpciallyJapanese(String s)
throws ParseException {
try {
boolean unicode = false;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) > 0xff) { // Unicode
unicode = true;
break;
}
}
if (!unicode) {
// decode by character encoding.
s = new String(s.getBytes("ISO-8859-1"), "JISAutoDetect");
}
// decode by RFC2047.
// if variable s isn't encoded-word, it's ignored.
return decodeText(s);
} catch (UnsupportedEncodingException e) {
}
throw new ParseException("Unsupported Encoding");
}
[問題点11]に記した問題は、以下のようなDate:を持つメッセージに関するものです。
Date: Wed, 8 Nov 2000 17:49:26 JST
このような日時書式のメッセージを送信するMUAは意外に多く、例によって大手のフリーで配布されるメイラによく見られるのですが、JavaMailはこの形式の日付を正しく解釈できません。Date:ヘッダに限らずjavax.mail.MailDateFormat#parse()を用いる箇所は全て駄目です。理由は、上記の形式はRFCに適合していないためであり、実際には以下のような形式でなければなりません。
Date: Wed, 8 Nov 2000 17:49:26 +0900 (JST)
タイムゾーンは'+'/'-'の後に続く4桁の数字で決定されます。あるいは"UT"/"GMT"/EST"等のアルファベットで表す記法も使用できるのですが、"JST"という文字列はどこにも規定されていません。日本時間を表すには"+0900"を用いる必要があるというわけです。ちなみに"(JST)"というのは単なるコメントですので無視されるものです。なくても構いません。
さて、なんにしてもJavaMailは前者のようなヘッダをMessage#getSentDate()等で取得しようとすると、それがGMTであると解釈してDateオブジェクトが生成されてしまうため、そのDateオブジェクトをDateFormatクラスなどを用いて日本のタイムゾーンで表示しようとすると、意図した時間より9時間先の時間が表示されてしまいます。つまり、このようなヘッダを持つメッセージも正しく解釈できるようにするためには、日付部分を自分で補正しなければならないということになるのです。せっかくJavaMailがヘッダの解析をしてくれようとしているのになんという事でしょうね。これは日本特有の問題ですのでJavaMail側で対応してもらうのはよくないでしょう。
仕方がないですので、日本専用という事で、この問題を補正する処理を用意する事にします。
private static MailDateFormat mailDateFormat = new MailDateFormat();
public static Date parseDate(String rfc822DateString) {
if (rfc822DateString == null) {
return null;
}
try {
if (rfc822DateString.indexOf(" JST") == -1 ||
rfc822DateString.indexOf('+') > 0) {
synchronized (mailDateFormat) {
return mailDateFormat.parse(rfc822DateString);
}
}
// 誤った日付書式である可能性が非常に高い
// ただし、"( JST)"等となっていると判断を誤る
StringBuffer buf = new StringBuffer(
rfc822DateString.substring(
0, rfc822DateString.indexOf("JST")));
buf.append("+0900");
synchronized (mailDateFormat) {
return mailDateFormat.parse(new String(buf));
}
} catch (java.text.ParseException e) {
return null;
}
}
このメソッドを用いて、Date date = MailUtility.parseDate(message.getHeader("Date",
null));のようにする事で(*)、Date:ヘッダなどのタイムゾーンの部分に"JST"が用いられている場合に"+0900"相当のタイムゾーンを用いてparseしたDateオブジェクトを得ることができます。
この問題自体は日本固有ですが、このようなDate:はたとえ英文メイルであっても含まれてしまうため、世界中に飛び交っているわけですから問題ですね。これは各メイラにはなんとかしてもらいたいところです。海外のメイラではこのようなDate:を正しく解釈しないものも多いはずですので、知らず知らずのうちに迷惑をかけているかもしれません。
上記のコードは完全なチェックを行っているわけではありませんので、必要とあらばこのコードを参考に、より厳密なチェックを行う処理を記述してもよいでしょう。
*:この章のサンプルコードは、実際には皆MailUtilityという名前の筆者のライブラリクラス上に作成しています。このクラスはインターネット上で公開しています。
JavaMailで、たまに本文のデコードに失敗するケースがあります。これにはいくつかの原因がありますが、現状でも比較的見かけるのは、MIME非対応MUAから送信されたため、Content-Type:ヘッダが存在しなかったり、Content-Type:
textのようにメディアタイプ/サブタイプの形式になっていないものが記述されたメッセージです。[問題11,
12]
JavaMailのjavax.mail.internetパッケージのクラス群や付属のプロバイダはMIMEメッセージしか扱えない仕様となっています(MimeMessageですし^^)。しかし、これでは困りますので、なんとかこれらのメッセージも正常に解釈できるようにしたいところです。
以下のようにMimeMessageの保持するContent-Type:を単純に書き換えてしまえばISO-2022-JPで記述されたメッセージを正しく取り出すことができます。
message.setHeader("Content-Type", "text/plain; charset=ISO-2022-JP");
しかし、この問題に直面する時のMessageオブジェクトは往々にしてプロバイダによって受信したメッセージであり、それは読み込み専用であることが多いでしょう。そうなると上記メソッド呼び出し自体が実行できないということになります。
一つの解決策は、JavaMail1.2で追加されたMimeMessageのコピーコンストラクタを用いて変更可能なMessageオブジェクトを作り出してから上記処理を実行する方法です。この方法を利用したgetContent()メソッドを作ってみました。
public static Object getContent(MimeMessage original, String charset)
throws MessagingException, IOException {
String correctedContentType = null;
try {
ContentType contentType =
new ContentType(original.getContentType());
if (contentType.getParameter("charset") == null) {
// Content-Type:が存在しない場合は"text/plain"になってしまう。
// 本当にtext/plainだった場合は正しくない事になるが、
// charset=ISO-2022-JPにする場合は一応表示上は問題ない。
contentType.setParameter("charset", charset);
}
correctedContentType = contentType.toString();
} catch (ParseException e) {
correctedContentType = "text/plain; charset=" + charset;
}
if (correctedContentType != null) {
try {
original.setHeader("Content-Type", correctedContentType);
} catch (MessagingException e) {
Message message = new MimeMessage(original);
message.setHeader("Content-Type", correctedContentType);
return message.getContent();
}
}
return original.getContent();
}
Content-Type: textとなっているメッセージはContentTypeクラスのコンストラクタでjavax.mail.internet.ParseExceptionが発生します。Content-Type:が存在しないメッセージに対しては、getContentType()メソッドが"text/plain"を返すようになっています。これはコメントに記した通り、本当に"text/plain"のみのメッセージであるかの判別が困難なため若干問題ありなのですが、"text/plain"はcharset=us-asciiを意味するため、これを"text/plain; charset=ISO-2022-JP"に変換する分には本文取得に関しての問題は発生しないので取り敢えずよしとします。
このメソッドを用いて本文を取得するようにする事で回避ができることになるわけですが、この方法では扱うメッセージが巨大な場合でもそのコピーを生成する事になりますので、場合によってはちょっと効率が悪いです。なんとか元のMessageオブジェクトのContent-Type:を書き換えることはできないでしょうか?
というわけで、それを行うコードも示す事にします。これはJavaMail1.1.3以前でも通用する方法です。以下のコードをご覧ください。
[CorrectedContentTypeDataSource.java]
package com.sk_jp.mail;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.IOException;
import javax.mail.Part;
import javax.mail.MessagingException;
import javax.mail.internet.ContentType;
import javax.mail.internet.ParseException;
import javax.activation.DataSource;
public class CorrectedContentTypeDataSource implements DataSource {
private DataSource source;
private String charset;
public CorrectedContentTypeDataSource() {}
public CorrectedContentTypeDataSource(DataSource dataSource,
String defaultCharset) {
setDataSource(dataSource);
setDefaultCharset(defaultCharset);
}
public CorrectedContentTypeDataSource(Part part,
String defaultCharset)
throws MessagingException {
setPart(part);
setDefaultCharset(defaultCharset);
}
public void setPart(Part part) throws MessagingException {
// getDataHandler() method creates a implicit DataSource.
setDataSource(part.getDataHandler().getDataSource());
}
public void setDataSource(DataSource newSource) {
source = newSource;
}
public void setDefaultCharset(String defaultCharset) {
charset = defaultCharset;
}
public String getContentType() {
ContentType contentType = null;
try {
contentType = new ContentType(source.getContentType());
} catch (ParseException e) {
return "text/plain; charset=" + charset;
}
String specifiedCharset = contentType.getParameter("charset");
if (specifiedCharset == null) {
// Content-Type:が存在しない場合は"text/plain"になってしまう。
// 本当にtext/plainだった場合は正しくない事になるが、
// charset=ISO-2022-JPにする場合は一応表示上は問題ない。
contentType.setParameter("charset", charset);
}
return contentType.toString();
}
public String getName() {
return source.getName();
}
public InputStream getInputStream() throws IOException {
return source.getInputStream();
}
public OutputStream getOutputStream() throws IOException {
return source.getOutputStream();
}
}
DataSourceはPartオブジェクトが内部的にボディにアクセスする際に使用されるinterfaceです。上記クラスは他のDataSourceオブジェクトをラップし、getContentType()メソッドのみ補正処理を挟むようになっています。このあたりはJAFの知識になるため詳細な説明は略しますが、上記クラスを用いて以下のようなコードを用意する事で元の例と同様の効果を得ることができます。
public static Object getContent(MimeMessage message, String charset)
throws MessagingException, IOException {
DataHandler dh = new DataHandler(
new CorrectedContentTypeDataSource(message, charset));
return dh.getContent();
}
この方法であれば、巨大なメッセージのコピーを作成されることなく、元のメッセージのContent-Type:のみ騙してボディを取得することができます。
比較的最近のMUAではContent-Type:のcharsetやヘッダのMIMEエンコード時の文字エンコーディングスキームにUTF-7を用いるものがあります。UTF-7は文字どおり7bitのエンコーディングですので、インターネットメイルに使用してもよいものではあるのですが、なんと現在のJDK1.3ではUTF-7の(Unicodeとの)コンバータが存在しません。
従って、これらのメッセージはJavaMailでは全く解釈できず、java.io.UnsupportedEncodingExceptionが発生してしまいます。これは困りました。
これに現状で対処するにはUTF-7のデコーダを作成するしかありません。
しかし、現在のJavaの仕組みでは、外部から独自のコンバータをインストールするすべが与えられていません。これは以前から要望として挙げられていたものなのですが、結局JDK1.3でも見送られたようです。
UTF-7をデコードするためのByteToCharConverterを以下に記します。このコードは筆者がJDKのコンバータとして登録できるかを模索した名残で、sun.io.ByteToCharConverterを継承して作成されていますが、これは将来外部からコンバータを与えられるようになった時に修正が容易になる事を考えてのものです。sun.io.ByteToCharConverterを継承しないで作成する事も可能です。
package com.sk_jp.io;
import java.io.*;
public class ByteToCharUTF7 extends sun.io.ByteToCharConverter {
public String getCharacterEncoding() {
return "UTF7";
}
public int flush(char[] chars, int off, int len) {
byteOff = 0;
charOff = 0;
b64Context = false;
currentB64Off = 0;
currentChar = 0;
return 0;
}
public void reset() {
byteOff = 0;
charOff = 0;
b64Context = false;
currentB64Off = 0;
currentChar = 0;
}
private boolean b64Context = false;
private int currentB64Off = 0;
private char currentChar = 0;
public int convert(byte[] bytes, int byteStart, int byteEnd,
char[] chars, int charStart, int charEnd)
throws sun.io.ConversionBufferFullException,
sun.io.UnknownCharacterException {
charOff = charStart;
for (byteOff = byteStart; byteOff < byteEnd; byteOff++) {
if (charOff >= charEnd) {
throw new sun.io.ConversionBufferFullException();
}
if (b64Context) {
if (bytes[byteOff] == '-') {
if (currentB64Off != 0 && currentChar > 0) {
chars[charOff] = currentChar;
charOff++;
}
b64Context = false;
continue;
}
int part = ("ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"abcdefghijklmnopqrstuvwxyz0123456789+/").
indexOf(bytes[byteOff]);
if (part == -1) {
throw new sun.io.UnknownCharacterException(
"Invalid UTF-7 code: " + (char)bytes[byteOff]);
}
switch (currentB64Off) {
case 0:
currentChar = (char)(part << 10);
break;
case 1:
currentChar |= (char)(part << 4);
break;
case 2:
currentChar |= (char)(part >> 2);
chars[charOff] = currentChar;
charOff++;
currentChar = (char)((part & 0x03) << 14);
break;
case 3:
currentChar |= (char)(part << 8);
break;
case 4:
currentChar |= (char)(part << 2);
break;
case 5:
currentChar |= (char)(part >> 4);
chars[charOff] = currentChar;
charOff++;
currentChar = (char)((part & 0x0f) << 12);
break;
case 6:
currentChar |= (char)(part << 6);
break;
case 7:
currentChar |= (char)part;
chars[charOff] = currentChar;
charOff++;
break;
}
currentB64Off = (currentB64Off + 1) % 8;
continue;
}
if (bytes[byteOff] =='+') {
// shift character
// This is start of the Base64 sequence.
b64Context = true;
currentB64Off = 0;
continue;
}
chars[charOff] = (char)bytes[byteOff];
charOff++;
}
return charOff - charStart;
}
}
さて、コンバータを提供したところで、このコンバータをいつ使えばよいのでしょう?このコンバータをJDKにインストールして、"UTF-7"という文字エンコーディングスキームに対して自動的にこのコンバータが使用されるようになれば万事解決なのですが、現状はそうはいきません。
現状でUTF-7に対応するためには、ヘッダに使用される場合と本文に使用される場合で別個に対応する必要があります。筆者は両方に対応するコードを書こうと思ったのですが、ヘッダの方に関しては現状のJavaMailの仕組みではヘッダのデコード処理部分にフックする仕組みがないため、ほとんど全て記述しなければならないことになってしまうので、今回は見送る事にしました(*)。JavaMail自体にこのあたりの融通性を望むところです。メッセージボディに関しては大きな対処コードを書く必要がなかったのでここに記す事にします。
*:JavaMail開発者には要望を出しています。また、要望があれば筆者の方でも対処コードを作成/公開しようかと思っています。
メッセージボディにUTF-7が使用されていた場合は、Message#getContent()でのUnsupportedEncodingExceptionをcatchして、MimePart#getEncoding()が"UTF-7"を返した場合に、Message#getInputStream()でUTF-7のストリームを得てByteToCharUTF7を使ってデコードする、という流れが思いつきますが、この方法では、JAFのMIMEタイプに応じて返すオブジェクトを選択する仕組みが利用できなくなります。現状text/*のMIMEタイプの場合は全てStringオブジェクトを返すようになっているため、Stringオブジェクトを生成するようにすればよいところなのですが、ここでは、先程のCorrectedContentTypeDataSourceを拡張して、JAFのフレームワークに沿った形で対応してみました。
// [CorrectedContentTypeDataSourceUTF7Support.java]
package com.sk_jp.mail;
import java.io.InputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.mail.Part;
import javax.mail.MessagingException;
import javax.mail.internet.ContentType;
import javax.mail.internet.ParseException;
import javax.activation.DataSource;
import com.sk_jp.io.ByteToCharUTF7;
public class CorrectedContentTypeDataSourceUTF7Support
extends CorrectedContentTypeDataSource {
private boolean utf7 = false;
public CorrectedContentTypeDataSourceUTF7Support() {}
public CorrectedContentTypeDataSourceUTF7Support(
DataSource dataSource,
String defaultCharset) {
super(dataSource, defaultCharset);
}
public CorrectedContentTypeDataSourceUTF7Support(
Part part,
String defaultCharset)
throws MessagingException {
super(part, defaultCharset);
}
public void setDataSource(DataSource newSource) {
super.setDataSource(newSource);
utf7 = false;
}
public void setDefaultCharset(String defaultCharset) {
super.setDefaultCharset(defaultCharset);
utf7 = false;
}
public String getContentType() {
try {
ContentType contentType = new ContentType(super.getContentType());
String specifiedCharset = contentType.getParameter("charset");
if ("UTF-7".equalsIgnoreCase(specifiedCharset)) {
// UTF-7コンバータが存在しないため、
// 独自フィルタストリームを用いる。
contentType.setParameter("charset", "UTF-16");
utf7 = true;
}
return contentType.toString();
} catch (ParseException e) {
throw new InternalError();
}
}
public InputStream getInputStream() throws IOException {
if (!utf7) {
return super.getInputStream();
}
InputStream in = super.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream();
int c;
while ((c = in.read()) != -1) {
out.write(c);
}
ByteToCharUTF7 btc = new ByteToCharUTF7();
byte[] bytes = out.toByteArray();
char[] chars = new char[bytes.length / 2 + 1];
btc.convert(bytes, 0, bytes.length,
chars, 0, chars.length);
String string = new String(chars);
return new ByteArrayInputStream(string.getBytes("UTF-16"));
}
}
先程出てきたCorrectedContentTypeDataSourceの代わりにこのクラスを用いる事でContent-Type:のcharsetパラメタが"UTF-7"の本文も通常と変わらない手間で取得できるようになります。このクラスが何をやっているかというと、データソースのContent-Typeのcharsetパラメタが"UTF-7"であれば、それを"UTF-16"に変換して、getInputStream()でUTF-7のストリームを読み込んでUnicodeに変換後、UTF-16のバイト列として新たなInputStreamを作り出して返すということをしています。
JDK1.4でUTF-7コンバータか、あるいは外部コンバータの登録機構が公開されるまでの回避コードという事でこのような方式でもよいでしょう(JDK1.4でこれらがサポートされるかは執筆時点では未確定です)。
これは、日本ではそれほど発生している問題ではありませんし、筆者も出くわしたことがないのですが、中にはContent-Transfer-Encoding:
7-bit (本当は"7bit")のようにIANA登録名にない転送エンコーディングスキームを用いるMUAがあるらしいです。
このようなメッセージを受信すると、Part#getContent()やPart#getInputStream()でのデコードに失敗し、IOExceptionが発生します。
このような場合の対処は実際に問題のメッセージを確認する毎に行っていく必要があるのでここでは具体的なコードの紹介は無しですが、対処方法のひな形になるコードの断片だけ挙げておきます。
try {
object = msg.getContent();
} catch (IOException e) {
// 特定の例があれば、ここに対処コードを記述
if (何らかの条件) {
// ヘッダの補正
} else {
// 原因不明な場合は取り敢えず"7bit"とする。
msg.setHeader("Content-Transfer-Encoding", "7bit");
}
object = msg.getContent();
}
JavaMail1.2ではJavaMailがデコードを行う前のボディを取り出すためのgetRawInputStream()というメソッドが追加されており、場合によってはこれを用いる必要が出てくるでしょうけれど、Content-Transfer-Encoding:の指定が間違っていると解るようなケースでは、上記のような方法でもよいでしょう。
これはJavaで複数の文字エンコーディングを取り扱う時の有名な問題なのですが、(いわゆる)Shift_JISコードにおける'〜'等の文字(*)をJavaで読み込んで、Unicodeに変換されたそれらの文字をISO-2022-JPやEUC-JPで出力する、あるいは逆にISO-2022-JPやEUC-JPの文字列を読み込んでShift_JISで出力する場合に、'?'にされてしまうというものです。
*:"―\〜‖…〕−¢£"の9文字です。字形(グリフといいます)はフォントによって変化するのでこれらに限らず「文字」には名称が付けられています。詳細は「XML日本語プロファイル 解説」(http://www.y-adagio.com/public/standards/tr_xml_jpf/kaisetsu.htm#synth-B.2)に記載されています。
日本語において特定の文字だけが'?'になるような現象を見たらこの問題の可能性を疑う必要があります。特にインターネットメイルを扱うアプリケーションではISO-2022-JPを用いることが多くなりますので、実行環境がWindowsであった場合などはほとんどの人が経験する現象といえます。この原因は、Shift_JISの文字に対する"MS932"と呼ばれるWindows用JDKのデフォルトとなっているUnicodeとの相互変換コンバータと、ISO-2022-JPやEUC-JPの文字用のUnicodeとの相互変換コンバータで変換表が一部異なっており、同じ文字がUnicodeにされた時に違うコードになってしまうというものです。
具体的に言うと、Shift_JISで記述された"〜"という文字を"MS932"コンバータでUnicodeに変換するとU+ff5eというコードになるのですが、ISO-2022-JPで記述された"〜"を"ISO-2022-JP"コンバータでUnicodeに変換するとU+301cというコードになります。これはUnicodeの上では同じ文字に見えますが、それぞれ違う方のコンバータで逆変換(Shift_JISやISO-2022-JPに変換)しようとすると"?"に変換されてしまうのですね。
実はShift_JISとUnicodeのコンバータには"MS932"の他に"SJIS"という名称のものがあります。JDK
1.2以降で"Shift_JIS"というエンコーディング名が用いられた場合は"MS932"コンバータが用いられますが、JDK1.1.7以前では"Shift_JIS"と言えば"SJIS"コンバータを表しました。"SJIS"コンバータによってUnicodeに変換された文字列は、同じ文字であれば"ISO-2022-JP"コンバータで変換した場合と同じコードになってくれるため、これらの間の相互変換で文字化けは発生しなくなります。
"MS932"というコンバータが作られた(しかもWindowsのデフォルトとして"SJIS"コンバータに置き換えられた)理由は、WindowsのAPIによるコード変換が"MS932"コンバータに相当する変換表によって行われているため、"〜"等をGUIに表示しようとすると化けるという報告が続出したためです。"MS932"コンバータ(現在のWindowsのデフォルト)を使っていれば、GUI上の文字の表示の問題は解消しますが、代わりにISO-2022-JPエンコーディングなどとの相互変換で支障が出たというわけです。
UnicodeからShift_JISやISO-2022-JP/EUC-JP等への変換時に関しては、Unicodeを得る時に用いたものと異なるコンバータで変換しても変換揺れを吸収して欲しいというのはRFE(JDKへの要望)として挙がっています。
問題が複雑なので説明もしきれませんし、取り敢えずちゃんと表示/送信できるようにするにはどうするんだ?ということの方が本書の主眼ですので原因の説明はこれくらいにして、具体的な対処について説明します。
この問題が原因となる「文字化け」の対処には、ケースに応じて以下のような方法があります。
*:ただし今後Webのフォームから送信される文字列のエンコーディングとして、表示中ページ自体のエンコーディングに関らずUTF-8を用いるものが主流になると思われます。この過渡期には何らかの文字コード判別手段が必要になると思われますが、まだはっきりした指針は出ていないようです。
それにしても起こり得るケースだけでこんなにあるんですね…。「→」として、個々のケースについて対処可能なものについては記述したのですが、「後述」と記したものについては他の項目にあるような回避方法が通用しないケースになります。
結局のところ、「後述」と記述したケースについては、既にUnicodeに変換されてしまっており、それを出力する時に、Unicodeを生成する時に使ったコンバータと異なるコンバータを用いざるを得ないケースなのです。
これは、Unicode文字列のコードを出力コンバータに合わせて補正してあげるしかないといえるでしょう。また、それができれば実は全てのケースで問題を回避できます。Unicode文字列をある文字エンコーディングで出力する時に、どの出力コンバータであっても正しく出力可能にするということです。
この処理を行うための変換ルーチンがあります。筆者のWebページ上(http://www.sk-jp.com/java/library/)で公開しているcom.sk_jp.io.UnicodeCorrectorクラスを始めとするいくつかのクラス群です。このクラス群は出力時のWriterとして振る舞ったりStringオブジェクトを構成するUnicodeの補正を行うことができます。
このクラス群の肝となる部分は、風間一洋さんのJavaHouse-Brewers投稿記事(http://java-house.etl.go.jp/ml/archive/j-h-b/014452.html)にあるCp932.javaです。Unicode文字列の特定コードを、出力エンコーディングに合わせて補正するという内容です。
上に列挙した対処法で間に合わない場合は、Webページ上で公開されている風間さんや筆者のクラスも参考にされてはいかがでしょうか。