.NET Framework 4 のメール送信で、長い日本語ファイル名の添付に失敗する原因を追ってみた

始まりは突然に

「添付ファイルが壊れてしまう」

社内の業務用アプリケーションを使ってる人から、そう相談されたところから始まった原因分析とその解決方法を模索した一日。
その時発見した .NET Framework 4 のメール送信ライブラリに潜むバグと思われる挙動について記述しておきたい。


その業務用アプリケーションというのは、特定の相手に対して、日本語名の添付ファイル付きでメールを送るものです。

もともと、.NET Framework 2.0 で作ってあったツールなのだけど、ほかのツールはすでに .NET Framework 4 にしていたこともあって、最近修正が必要になった際に、.NET Framework 4 で動かすようにしていました。

原因を探す

メールのソースを見てみると、添付ファイルのヘッダが妙。Content-type ヘッダの name 属性がやけに長いのです。
base64 エンコーディングなので、元の文字列に比べたら3割ほど増えるのは当然なのですが、それにしても長すぎるので試行錯誤してみると、どうやら MIME ヘッダエンコードが2回行われているらしいという結論にたどり着きました。

MIME ヘッダエンコード(RFC2047)の2重エンコード
いろはにほへと
↓
=?utf-8?B?w6PCgcKEw6PCgsKNw6PCgcKvw6PCgcKrw6PCgcK7w6PCgcK4w6PCgcKo?=
↓
=?utf-8?B?PT91dGYtOD9CP3c2UENnY0tFdzZQQ2dzS053NlBDZ2NLdnc2UENnY0tydzZQ?=
 =?utf-8?B?Q2djSzd3NlBDZ2NLNHc2UENnY0tvPz0=?=

("=?" から始まり、 "?=" で終わるカタマリは75文字を超えてはいけないという制限があるため、長くなると改行される。)

しかし、今まで問題なく動いていたのは確認している。
最近行った修正というのはメール周りには手をつけていない。
ということは、考えにくいけど、.NET Framework に問題があるんじゃないかと思い、コードを追ってみることにした。

ソースコードを取得

.NET Framework のリファレンスソースコードが Reference Source Code Center ( http://referencesource.microsoft.com/netframework.aspx ) にありました。この中の Product Name .NET, Version 4 というのが .NET Framework 4 のソースコードです。

シンボル(pdbファイル)も含まれているので、当初は、ソースレベルデバッグを掛けて追ってみようと思っていたのですが、どう設定してもうまくいかない。
(どなたか、 C# 2010 Express で .NET Framework ライブラリ内のステップ実行を行う方法知っていたら、具体的な設定方法を教えて下さい。)

仕方ないので、静的解析を行いました。

検索してみると、System.Net.Mail 名前空間は下記のディレクトリに存在しているようです。

 [ソースコードインストールディレクトリ]\Source\.Net\4.0\DEVDIV_TFS\Dev10\Releases\RTMRel\ndp\fx\src\Net\System\Net\Mail

ディレクトリの規則がいまいち分からないのですが、だいたい、クラス Hoge が定義されているファイル名は Hoge.cs となっているので、比較的目的のクラスは見つけやすいです。

原因を求めて処理を追ってみる

  MailMessage mail = new MailMessage(略);
  Attachment attach = new Attachment(attachmentFilepath);
  mail.Attachments.Add(attach);
  
  smtpClient.Send(mail); // SmtpClient smtpClient 

実際に利用していたコードはこんな感じでした。
attachmentFilepath には、日本語のファイル名が含まれ、このファイル名がそのまま添付ファイル名として利用されています。


とりあえず、添付ファイルオブジェクトの構築部分から見ていくことにします。

Attachment コンストラクタでは、 Name setter によって、パス部分を除いたファイル名が設定されます。
Name プロパティは、こんな感じ。

Attachment.Name プロパティ
public string Name {
    get {
        return name;
    } 
    set {
        Encoding nameEncoding = MimeBasePart.DecodeEncoding(value); 
        if(nameEncoding != null){ 
            this.nameEncoding = nameEncoding;
            this.name = MimeBasePart.DecodeHeaderValue(value); 
            MimePart.ContentType.Name = value;
        }
        else{
            this.name = value; 
            SetContentTypeName();
        } 
    } 
}

MimeBasePart.DecodeEncoding (静的メソッド)は、既に RFC2047 に従った MIME エンコードがされている場合はその文字エンコーディングを返す。というものなので、今回は null が返って、 else 節が実行されます。

で、 SetContentTypeName に処理が渡されます。

SetContentTypeName
internal void SetContentTypeName(){
    if (name != null && name.Length != 0 && !MimeBasePart.IsAscii(name,false)) { 
        Encoding encoding = NameEncoding; 
        if(encoding == null){
            encoding = Encoding.GetEncoding(MimeBasePart.defaultCharSet); 
        }
        MimePart.ContentType.Name = MimeBasePart.EncodeHeaderValue(name, encoding ,MimeBasePart.ShouldUseBase64Encoding(encoding));
    }
    else{ 
        MimePart.ContentType.Name = name;
    } 
} 

まず、最初の if 条件。既に Name setter で this.name には ファイル名が入っているので、前2つは真。

MimeBasePart.IsAscii 静的メソッドは、name が ASCII (含まれる キャラクタのコード値が全て 0x7f 以下) の場合、真を返します。
第2引数は、改行コード('\r' または '\n') が含まれていても ASCII と見なすかどうかのフラグで、 false の場合は許容しません。
日本語ファイル名なので、当然、ここでの if 文は真と評価されます。


NameEncoding は、Attachment クラスのプロパティで、現時点では何も指定されてないので、null が返ります。なので、次の if 節により、 encoding には 'utf-8' 文字エンコーディングを表す Encoding オブジェクトが入り、MimeBasePart.EncodeHeaderValue 静的メソッドに渡されます。(MimeBasePart.defaultCharSet は MimeBasePart クラスの定数で 'utf-8' と定義されています。)

MimeBasePart.ShouldUseBase64Encoding 静的メソッドは、 引数の文字エンコーディングに応じて真偽値を返すもので、今回の場合は utf-8 に対してなので常に真が返ります。

そして、MimeBasePart.EncodeHeaderValue 静的メソッド。詳細は後回しにして、このメソッドが MIME ヘッダエンコードをしています。

この時点で =?utf8?B?... という形になり、これが、MimePart.ContentType.Name に渡されます。

問題となる2度目のMIME ヘッダエンコード

MimePart というのは、 Attachment クラスの継承元である AttachmentBase クラスのプロパティで、getter により MimePart 型の private 変数 part を返します。さらにその ContentType プロパティは、 MimePart クラスの継承元 MimeBasePart クラスのプロパティで、必要に応じて ContentType クラスのオブジェクトを生成して返します。
この ContentType クラスのオブジェクトが、 Content-type ヘッダに相当します。

で、 ContentType の Name プロパティは、以下のようになっています。

ContentType.Name プロパティ
public string Name {
    get {
        string value = Parameters["name"];
        Encoding nameEncoding = MimeBasePart.DecodeEncoding(value); 
        if(nameEncoding != null)
            value = MimeBasePart.DecodeHeaderValue(value); 
        return value; 
    }
    set { 
        if (value == null || value == string.Empty) {
            Parameters.Remove("name");
        }
        else{ 
            if (MimeBasePart.IsAscii(value, false)) {
                Parameters["name"] = value; 
            } else { 
                Encoding encoding = Encoding.GetEncoding(MimeBasePart.defaultCharSet);
                Parameters["name"] = MimeBasePart.EncodeHeaderValue(value, encoding, MimeBasePart.ShouldUseBase64Encoding(encoding)); 
            }
        }
    }
} 

Parameters は、まぁ、属性を表す String の Dictionary のようなものだと思えばいいんじゃないかなと。(実際には TrackingStringDictionaryですが。)

ここで、渡されてきたのは MIME ヘッダエンコード されたファイル名。当然 ASCII で構成されているので、MimeBasePart.IsAscii(value, false) が真となり、そのまま代入されると思いきや、実はここに問題が。

RFC2047 による MIME ヘッダエンコードは、一定以上の長さの場合改行されてしまうのです。実際に、先に出てきた(そしてここでも利用されることになる) MimeBasePart.EncodeHeaderValue 静的メソッドを追ってみると、 Base64Stream.EncodeBytes が呼び出され、一定の文字数を超えると "\r\n" が追加される処理があります。

よって、日本語ファイル名であっても短いファイル名であれば、改行が含まれることはなく MimeBasePart.IsAscii(value, false) が真を返すのですが、ある程度の長さをもつファイル名が渡された場合は、この時点で value には改行が含まれており、再度 MimeBasePart.EncodeHeaderValue が実行される (2重エンコードされる)という訳です。

どう解決するか

ちなみに、 .NET Framework 2.0 の場合は、コレが起こることはありません。Base64 エンコードをする際に、 new Base64Stream(-1) を使っているので、どれだけ長くなろうと改行が入らないようになっているからです。

ただ、この場合、1行あたりの文字数がとてつもなく長くなる可能性があります。そうすると、"1行中の文字数を CRLF を除いて 998文字以下(MUST) あるいは78文字以下(SHOULD)" と規定している RFC5322 に違反する可能性が生まれる。

とはいえ、そもそも Content-type の name 属性に このエンコード方式を用いることは RFC2047 の "5. Use of encoded-words in message headers" で禁止されていて、そのために RFC2231 があるのだけど、これに対応したメーラは未だ少ないのも事実。RFC2231 使うとデコードできなくて化けてしまうのです。

実際、現状においてほとんどの日本語添付ファイルは、.NET Framework 4 のやり方 (適度に改行する)方式で対応してたりします。


閑話休題


どうにか .NET Framework 4 のままで解決できないか試してみました。

改行が含まれてしまうのが問題なので、あらかじめ手動で MIME ヘッダエンコードしてしまえばどうか。
つまり、 .NET Framework 2.0 のやり方を手動でやってしまおうと。

Attachment attach = new Attachment(attachmentFilepath);
string f = Path.GetFileName(attachmentFilepath);
byte[] b = Encoding.GetEncoding("utf-8").GetBytes(f);
string n = "=?utf-8?B?" + Convert.ToBase64String(b) + "?=";
attach.Name = n;
mail.Attachments.Add(attach);

めちゃくちゃべた書きですが、とりあえずこれで試してみたところ……だめでした。

Attachiment の Name setter では、MimeBasePart.DecodeEncoding により utf8 が返り、渡した値がそのまま ContentType.Name に渡され、その中でもエンコードされることなくセットされるはずです。また、 name メンバ変数には、デコードされた結果である元の日本語ファイル名が入ります。

問題はその先にありました。

SmtpClient の Send メソッドから追うと

SmtpClient.Send -> MailMessage.Send -> MailMessage.SetContent -> Attachment.PrepareForSending

という順でメソッドが呼ばれていきます。

この、PrepareForSending メソッドの中で、SetContentTypeName が呼ばれてしまいます。結局、その時点での name メンバ変数が再度 MIME ヘッダエンコードし直され、2重エンコードされ、と、手動でエンコードした意味がないのです。

仕方ないので、 .NET Framework 2.0 に戻すことで対応しました。
幸い、書き直す必要があったのは、

  • .NET Framework 4 になってから非推奨になっていた MailMessage.ReplyTo を MailMessage.ReplyList に変更していたこと。
  • SmtpClient が IDisposable を実装していたので、明示的に Dispose を呼んでいたこと。
  • 勝手に付け加えられていた Linq の using句。

程度で済んだので、そんなに手間かからずに戻せました。

所感

初めて .NET ライブラリのソースコードを読んでみましたが、やはりクラス階層はうまく分けられているなぁ。という印象。全部見た訳じゃないですけどね。VCL も時代から(というと少々語弊がありますが)利用しているライブラリであるので結構愛着持ってます。

とはいえ、

  • MailMessagew.cs の中に、 MailMessage クラスが重複して定義されてる。(using も2ヶ所あるところを見ると、単なるミス?)
  • string hoge に対して、空文字かどうかを比較するのに、 hoge == String.Empty だったり hoge.Lnegth == 0 だったりと揺れてる。
  • internal なクラスばかりで拡張したくてもむずかしい。

などなど、気になるところはちらほら。

とりあえず、ここまで説明してきた ASCII 以外を含む長いファイル名の添付において、ファイル名が2重にエンコードされる問題 はどうにかして欲しい。
回避する手段があればまだ良いのですが、今のところ見つかっていません。

どなたか良い解決方法があれば教えてください。


おまけ

以下に、.NET Framework 4 と .NET Framework2 のエンコード結果を列挙しておきます。

.NET Framework 4 (いろはにほへとちりぬるをわか.txt) / 2重エンコードされた
Content-Type: application/octet-stream;
\name="=?utf-8?B?PT91dGYtOD9CPzQ0R0U0NEtONDRHdjQ0R3I0NEc3NDRHNDQ0R280NEdo?=\

\=?utf-8?B?NDRLSzQ0R3M0NEtMNDRLUzQ0S1A0NEdMPz0NCiA9P3V0Zi04P0I/TG5S?=\
 =?utf-8?B?NGRBPT0/PQ==?="
Content-Transfer-Encoding: base64
Content-Disposition: attachment
.NET Framework 4 (いろはにほへとちりぬるを.txt) / 正常
Content-Type: application/octet-stream; name="=?utf-8?B?44GE44KN44Gv44Gr44G744G444Go44Gh44KK44Gs44KL44KSLnR4dA==?="
Content-Transfer-Encoding: base64
Content-Disposition: attachment
.NET Framework 2.0 (いろはにほへとちりぬるをわか.txt)
Content-Type: application/octet-stream; name="=?utf-8?B?44GE44KN44Gv44Gr44G744G444Go44Gh44KK44Gs44KL44KS44KP44GLLnR4dA==?="
Content-Transfer-Encoding: base64
.NET Framework 2.0 (いろはにほへとちりぬるを.txt)
Content-Type: application/octet-stream; name="=?utf-8?B?44GE44KN44Gv44Gr44G744G444Go44Gh44KK44Gs44KL44KSLnR4dA==?="
Content-Transfer-Encoding: base64


実は、.NET Framework 2.0 だと、そのままでは Content-Disposition が吐かれなかったりする。

リンク

Reference Source Code Center
http://referencesource.microsoft.com/
RFC2047 (MIME Part Three)
http://tools.ietf.org/html/rfc2047
RFC2231 (MIME Parameter Value and Encoded Word Extensions)
http://tools.ietf.org/html/rfc2231
RFC5322 (Internet Message Format)
http://tools.ietf.org/html/rfc5322
添付ファイルにおける日本語のファイル名に関して
http://www.emaillab.org/essay/japanese-filename.html