2014-09-25T04:15:37の更新内容

programming/netfx/tips/receive_mail_imap/index.wiki.txt

current previous
1,412 0,0
+
${smdncms:title,メールの受信(IMAP)}
+
//${smdncms:header_title,メールの受信(IMAP) (C#・VB)}
+
${smdncms:keywords,C#,VB.NET,TcpClient,IMAP,メール,受信,SSL}
+
${smdncms:tags,api/.net,lang/c#,lang/vb}
+
${smdncms:document_versions,codelang=cs,codelang=vb}
+

          
+
&msdn(netfx,type,System.Net.Sockets.TcpClient){System.Net.Sockets.TcpClientクラス};を使ってIMAPサーバーからメールを受信するサンプル。
+

          
+
-関連するページ
+
--[[programming/netfx/tips/modified_utf7]]
+
--[[programming/netfx/tips/cram_md5]]
+
--[[programming/netfx/tips/quoted_printable]]
+
--[[programming/netfx/text_format_conversion]]
+
--[[programming/netfx/tips/echo_server]]
+
--[[works/libs/Smdn.Net.Imap4.Client]] (C#で実装したIMAPクライアントライブラリ)
+
--[[works/libs/Smdn.Formats.Mime]] (C#で実装したメール解析ライブラリ)
+

          
+
#adunit
+

          
+
*IMAPクライアントの実装 [#implementation]
+
以下のコードはIMAPサーバーに接続してメールを取得し、ファイルに保存するIMAPクライアントのサンプル。 &msdn(netfx,type,System.Net.Security.SslStream){SslStream};を使ってSSLによる接続を行う。
+

          
+
.NET Framework 4.5、Mono 3.8で動作確認済み。 またIMAPサーバーはGMailサーバー(gimap)およびDovecot v2.2.13での動作を確認している。 送信するコマンドとレスポンス解析上の要点などについては[[後述する>#imap_outline]]。
+

          
+
#code(cs){{
+
using System;
+
using System.Collections.Generic;
+
using System.IO;
+
using System.Linq;
+
using System.Net;
+
using System.Net.Security;
+
using System.Net.Sockets;
+
using System.Security.Cryptography.X509Certificates;
+
using System.Text;
+
using System.Text.RegularExpressions;
+

          
+
class Sample {
+
  const string host = "imap.gmail.com"; // 接続先IMAPサーバーのホスト名
+
  const int port = 993; // 接続先のポート番号
+
  const string user = "user"; // ログインユーザー名
+
  const string pass = "pass"; // ログインパスワード
+
  const string mailbox = "INBOX"; // 取得したいメールがあるメールボックス
+
  const int mailNumber = 1; // 取得するメールの番号(1から始まるメールボックス内での通番)
+
  const bool setSeen = false; // 取得と同時にメールを既読状態にするか否か
+
  const bool deleteAfterFetch = false; // 取得後にメールを削除するか否か
+
  const string mailFilePath = "mail.eml"; // 取得したメールを保存するファイルのパス
+

          
+
  public static void Main()
+
  {
+
    // IMAPサーバーに接続してTcpClientを作成
+
    using (var client = new ImapClient(host, port)) {
+
      // 接続をSSLにアップグレード
+
      client.UpgradeToSsl();
+

          
+
      /*
+
       * initial greetingを受信
+
       */
+
      var response = client.Receive();
+

          
+
      if (!response.StartsWith("* OK"))
+
        // レスポンスがOK以外の場合は例外をスローする
+
        throw new ApplicationException("unexpected or error response (greeting banner)");
+

          
+
      /*
+
       * LOGINコマンドを送信してログイン
+
       */
+
      client.Send(string.Format("00 LOGIN {0} {1}\r\n", Quote(user), Quote(pass)));
+

          
+
      response = client.Receive();
+

          
+
      if (!response.SplitIntoLines().Last().StartsWith("00 OK"))
+
        // コマンドの結果がOK以外の場合は例外をスローする
+
        throw new ApplicationException("unexpected or error response (LOGIN)");
+

          
+
      /*
+
       * SELECTコマンドを送信してメールボックスを選択
+
       */
+
      client.Send(string.Format("01 SELECT {0}\r\n", Quote(mailbox)));
+

          
+
      response = client.Receive();
+

          
+
      if (!response.SplitIntoLines().Last().StartsWith("01 OK"))
+
        throw new ApplicationException("unexpected or error response (SELECT)");
+

          
+
      /*
+
       * FETCHコマンドを送信してメール本文を取得する
+
       */
+
      if (setSeen)
+
        // 本文取得後にメールを既読にする場合
+
        client.Send(string.Format("02 FETCH {0} BODY[]\r\n", mailNumber));
+
      else
+
        client.Send(string.Format("02 FETCH {0} BODY.PEEK[]\r\n", mailNumber));
+

          
+
      response = client.Receive();
+

          
+
      if (!response.Contains("\r\n02 OK"))
+
        throw new ApplicationException("unexpected or error response (FETCH)");
+

          
+
      /*
+
       * 受信したFETCHレスポンスからメール本文を抽出する
+
       */
+
      var match = Regex.Match(response, @"BODY\[\] \{([0-9]+)\}\r\n");
+

          
+
      if (match.Success) {
+
        // "BODY[] {...}\r\n"の部分からメール本文の長さを抽出する
+
        var length = int.Parse(match.Groups[1].Value);
+
        // "BODY[] {...}\r\n"以降の部分を抽出する
+
        var body = response.Substring(match.Index + match.Length, length);
+

          
+
        // 抽出したメール本文をファイルに保存する
+
        File.WriteAllText(mailFilePath, body, Encoding.ASCII);
+
      }
+
      else {
+
        throw new ApplicationException("fetching message body failed");
+
      }
+

          
+
      // 本文取得後にメールを削除する場合
+
      if (deleteAfterFetch) {
+
        /*
+
         * STOREコマンドを送信して\Deleted(削除予定)フラグを設定する
+
         */
+
        client.Send(string.Format("02A STORE {0} +FLAGS \\Deleted\r\n", mailNumber));
+

          
+
        response = client.Receive();
+

          
+
        if (!response.SplitIntoLines().Last().StartsWith("02A OK"))
+
          throw new ApplicationException("unexpected or error response (STORE)");
+
      }
+

          
+
      /*
+
       * CLOSEコマンドを送信してメールボックスを閉じる
+
       */
+
      client.Send("03 CLOSE\r\n");
+

          
+
      response = client.Receive();
+

          
+
      if (!response.SplitIntoLines().Last().StartsWith("03 OK"))
+
        throw new ApplicationException("unexpected or error response (CLOSE)");
+

          
+
      /*
+
       * LOGOUTコマンドを送信してログアウトする
+
       */
+
      client.Send("04 LOGOUT\r\n");
+

          
+
      client.Receive(); // レスポンスの検証は省略
+
    }
+
  }
+

          
+
  /// <summary>文字列をクオートした結果を返す。</summary>
+
  private static string Quote(string str)
+
  {
+
    if (string.IsNullOrEmpty(str))
+
      return "\"\"";
+
    else
+
      return string.Concat("\"", str.Replace("\"", "\\\""), "\"");
+
  }
+
}
+

          
+
/// <summary>TcpClientを継承してSSL接続とIMAPコマンドの送受信に最適化したクライアント。</summary>
+
class ImapClient : TcpClient {
+
  private Stream stream;
+
  private string host;
+
  private byte[] receiveBuffer = new byte[1024];
+

          
+
  public ImapClient(string host, int port)
+
    : base(host, port)
+
  {
+
    this.host = host;
+
    this.stream = GetStream();
+
  }
+

          
+
  /// <summary>現在の接続をSSLにアップグレードする。</summary>
+
  public void UpgradeToSsl()
+
  {
+
    var sslStream = new SslStream(stream, false, ValidateRemoteCertificate);
+

          
+
    sslStream.AuthenticateAsClient(host);
+

          
+
    stream = sslStream;
+
  }
+

          
+
  /// <summary>サーバー証明書の検証を行う。</summary>
+
  private static bool ValidateRemoteCertificate(object sender,
+
                                                X509Certificate certificate,
+
                                                X509Chain chain,
+
                                                SslPolicyErrors sslPolicyErrors)
+
  {
+
    // 証明書の検証を省略したい場合は、常にtrueを返す
+
    //return true;
+

          
+
    if (sslPolicyErrors == SslPolicyErrors.None) {
+
      return true;
+
    }
+
    else {
+
      // エラーがあれば標準エラーに表示
+
      Console.Error.WriteLine(sslPolicyErrors);
+
      return false;
+
    }
+
  }
+

          
+
  /// <summary>IMAPコマンドを送信する。</summary>
+
  public void Send(string command)
+
  {
+
    var commandBytes = Encoding.ASCII.GetBytes(command);
+

          
+
    stream.Write(commandBytes, 0, commandBytes.Length);
+

          
+
    // 送信した内容をコンソールに表示
+
    Console.Write("C:\t{0}", command);
+
  }
+

          
+
  /// <summary>IMAPレスポンスを受信する。</summary>
+
  public string Receive()
+
  {
+
    var sb = new StringBuilder();
+

          
+
    for (;;) {
+
      var len = stream.Read(receiveBuffer, 0, receiveBuffer.Length);
+

          
+
      sb.Append(Encoding.ASCII.GetString(receiveBuffer, 0, len));
+

          
+
      // 読み取り可能なデータがある場合はさらに受信を続ける
+
      if (0 < Available)
+
        continue;
+

          
+
      // CRLFで終端されるまで受信した場合は受信を終了する
+
      if (2 <= sb.Length && sb[sb.Length - 2] == '\r' && sb[sb.Length - 1] == '\n')
+
        break;
+
    }
+

          
+
    var response = sb.ToString();
+

          
+
    // 受信した内容を整形してコンソールに表示
+
    Console.Write("S:\t{0}", sb.Replace("\r\n", "\r\n\t").ToString(0, sb.Length - 1));
+

          
+
    return response;
+
  }
+
}
+

          
+
static class StringExtensions {
+
  // <summary>文字列を改行文字で分割してIEnumerable<string>で返す。 stringの拡張メソッドとして使用可能。</summary>
+
  public static IEnumerable<string> SplitIntoLines(this string str)
+
  {
+
    if (string.IsNullOrEmpty(str))
+
      yield break;
+

          
+
    var reader = new StringReader(str);
+

          
+
    for (;;) {
+
      var line = reader.ReadLine();
+

          
+
      if (line == null)
+
        break;
+

          
+
      yield return line;
+
    }
+
  }
+
}
+
}}
+

          
+
上記サンプルの制限事項や改良可能な点など。
+
+サーバーがLOGINコマンドでのログインを制限している場合は、認証に失敗する (AUTHENTICATEコマンドを使用するように書き換える必要がある)
+
+メール本文がliteralではなくquotedやatomの形式で返送された場合、本文の抽出に失敗する (ただし、そういった実装のサーバーは稀と思われる)
+
+レスポンスをバイト配列ではなく文字列に変換して解析するため、少なくとも受信するメールの二倍のサイズのメモリを消費する
+
+また、レスポンス全体をバッファに格納してから解析を行うため、巨大なファイルが添付されたメールを受信する場合などには注意を要する
+
+Encoding.ASCIIを使ってレスポンスを文字列に変換するため、``Content-Transfer-Encoding: 8bit``のメールでは文字化けが発生する可能性がある
+
+UpgradeToSsl()の呼び出しを行わなければSSLを使用しない通常のIMAP接続を行うことができる
+
+STARTTLSコマンドを送信してレスポンスを受信した後にUpgradeToSsl()を呼び出すようにすればSTARTTLSに対応することができる
+

          
+

          
+

          
+
*IMAPプロトコル概説 [#imap_outline]
+
IMAPサーバーからメールを受信するために必要となる知識として、IMAPコマンドとレスポンスについて解説する。 以下はIMAPサーバーに接続してメールを受信する処理を行うために送受信するコマンドとレスポンスの流れ。 (この例ではGMailに接続している)
+

          
+
``C:``の行はクライアントが送信するコマンド、``S:``の行はサーバーから返送されるレスポンスを区別して表すためのもので、実際に送受信される内容ではない点に注意。 またIMAPでは、コマンド・レスポンスとも行末は常に``CRLF``で終端される。
+

          
+
#code(,IMAPでメールを受信する際の送受信内容){{
+
S:	* OK Gimap ready for requests from xxx.xxx.xxx.xxx av5mb180262435pbd
+
C:	00 LOGIN "user" "pass"
+
S:	* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT
+
	00 OK user@gmail.com authenticated (Success)
+
C:	01 SELECT "INBOX"
+
S:	* FLAGS (\Answered \Flagged \Draft \Deleted \Seen unknown-1 $label2 $label3 $Phishing $label1 $label4 $label5 NonJunk $NotPhishing Junk)
+
	* OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen unknown-1 $label2 $label3 $Phishing $label1 $label4 $label5 NonJunk $NotPhishing Junk \*)] Flags permitted.
+
	* OK [UIDVALIDITY 59] UIDs valid.
+
	* 1 EXISTS
+
	* 0 RECENT
+
	* OK [UIDNEXT 2] Predicted next UID.
+
	* OK [HIGHESTMODSEQ 414981]
+
	01 OK [READ-WRITE] INBOX2 selected. (Success)
+
C:	02 FETCH 1 BODY.PEEK[]
+
S:	* 1 FETCH (BODY[] {388}
+
	Message-ID: <5422CEEF.2080103@example.com>
+
	Date: Wed, 24 Sep 2014 23:02:23 +0900
+
	From: me@example.com
+
	User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.1.1
+
	MIME-Version: 1.0
+
	To: you@example.com
+
	Subject: =?ISO-2022-JP?B?GyRCJUYlOSVIGyhC?=
+
	Content-Type: text/plain; charset=iso-2022-jp
+
	Content-Transfer-Encoding: base64
+
	
+
	GyRCJUYlOSVIJWEhPCVrGyhC
+
	)
+
	02 OK Success
+
C:	03 CLOSE
+
S:	03 OK Returned to authenticated state. (Success)
+
C:	04 LOGOUT
+
S:	* BYE LOGOUT Requested
+
	04 OK 73 good day (Success)
+
}}
+

          
+
以下で上記の送受信内容について1ステップずつ解説する。 メールの受信を行う際の要点のみを解説するので、IMAPの仕様については適宜&urn2url(urn:ietf:rfc:3501);や文中のリンクを参照のこと。
+

          
+
**接続
+
#code(,initial greeting){{
+
S:	* OK Gimap ready for requests from xxx.xxx.xxx.xxx av5mb180262435pbd
+
}}
+

          
+
接続が確立した後(SSL接続の場合はセッションが確立した後)、サーバー側からグリーティングバナーが送信されてくる。 ``*``の後に``OK``が続く場合は成功であり、以降IMAPコマンドを受信できる状態であることを表す。 ``OK``以降の文字列はサーバーによって異なる。
+

          
+
**ログイン (LOGINコマンド)
+
#code(,LOGINコマンドとレスポンス){{
+
C:	00 LOGIN "user" "pass"
+
S:	* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT
+
	00 OK user@gmail.com authenticated (Success)
+
}}
+

          
+
``LOGIN``コマンドを使い、ユーザー名とパスワードを指定してIMAPサーバーにログインする。 ここではユーザー名に``user``、パスワードに``pass``を指定している。 空白がコマンド引数の区切りとみなされるため、引数に空白を含む場合は必ず二重引用符``"``でクォートする必要がある。
+

          
+
コマンドの``00 LOGIN``と、レスポンスの``00 OK``に前置されている``00``は、''タグ(tag)''と呼ばれるもの。 タグはコマンドとそれに対応するレスポンスを識別し、他のコマンドの結果との区別を行うための文字列。 タグには任意の文字列を使用することができるが、以降ここではコマンドの通番を使用する。
+

          
+
コマンド``00 LOGIN``の結果は、サーバーから返送されるレスポンス二行目の``00 OK``となる。 認証に失敗したなどコマンドが失敗した場合は``OK``ではなく``NO``や``BAD``となる。 ``OK``以降の文字列はコマンドの結果を表す一種のコメントでありプロトコル上意味のある文字列では無く、その内容はサーバーによって異なる。
+

          
+
一行目の``* CAPABILITY``はサーバーの能力(サポートする機能)を表す付加的なレスポンスで、コマンドの結果を表すものではない。 このような付加的なレスポンスが送信されるかどうか、またその内容はサーバーによって異なる。
+

          
+
``LOGIN``コマンドは平文での認証を行う。 ``DIGEST-MD5``や``CRAM-MD5``などを使って認証したい場合は``AUTHENTICATE``コマンドを使用する。 (&urn2url(urn:ietf:rfc:3501,#6.2.2,short);、[[programming/netfx/tips/cram_md5]])
+

          
+

          
+
**メールボックスの選択 (SELECTコマンド)
+
#code(,SELECTコマンドとレスポンス){{
+
C:	01 SELECT "INBOX"
+
S:	* FLAGS (\Answered \Flagged \Draft \Deleted \Seen unknown-1 $label2 $label3 $Phishing $label1 $label4 $label5 NonJunk $NotPhishing Junk)
+
	* OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen unknown-1 $label2 $label3 $Phishing $label1 $label4 $label5 NonJunk $NotPhishing Junk \*)] Flags permitted.
+
	* OK [UIDVALIDITY 59] UIDs valid.
+
	* 1 EXISTS
+
	* 0 RECENT
+
	* OK [UIDNEXT 2] Predicted next UID.
+
	* OK [HIGHESTMODSEQ 414981]
+
	01 OK [READ-WRITE] INBOX selected. (Success)
+
}}
+

          
+
取得したいメールが含まれているメールボックスを選択するために``SELECT``コマンドを送信する。 引数に選択したいメールボックス名を指定する。 ここではデフォルトで存在するメールボックスである``INBOX``を指定している。 (メールクライアント上ではINBOXは「受信ボックス」などの表記で表示される)
+

          
+
日本語など非ASCII文字を含むメールボックス名を指定したい場合は、Modified UTF-7でメールボックス名をエンコードする必要がある。 (&urn2url(urn:ietf:rfc:3501,#5.1.3,short);、[[programming/netfx/tips/modified_utf7]])
+

          
+
コマンド``01 SELECT``の結果は、サーバーから返送されるレスポンス最後の行の``01 OK``となる。
+

          
+
レスポンスにある``* 1 EXISTS``の行は、メールボックスにメールが1件格納されていることを表す。 同じく``* 0 RECENT``の行は、新着メールが0件格納されている、つまり新着メールはないことを表す。 ``EXISTS``および``RECENT``は、``SELECT``コマンドのレスポンスとして''常に''返送されてくる。
+

          
+

          
+
**メールの取得 (FETCHコマンド)
+
#code(,FETCHコマンドとレスポンス){{
+
C:	02 FETCH 1 BODY.PEEK[]
+
S:	* 1 FETCH (BODY[] {388}
+
	Message-ID: <5422CEEF.2080103@example.com>
+
	Date: Wed, 24 Sep 2014 23:02:23 +0900
+
	From: me@example.com
+
	User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:31.0) Gecko/20100101 Thunderbird/31.1.1
+
	MIME-Version: 1.0
+
	To: you@example.com
+
	Subject: =?ISO-2022-JP?B?GyRCJUYlOSVIGyhC?=
+
	Content-Type: text/plain; charset=iso-2022-jp
+
	Content-Transfer-Encoding: base64
+
	
+
	GyRCJUYlOSVIJWEhPCVrGyhC
+
	)
+
	02 OK Success
+
}}
+

          
+
メールの内容を取得するために``FETCH``コマンドを送信する。 引数に取得したいメールの番号(メールボックス内での通番)と、取得したい内容を指定する。 ここではメール番号に``1``、メール本文の内容を取得するために``BODY.PEEK[]``を指定している。
+

          
+
コマンド``02 FETCH``の結果は、サーバーから返送されるレスポンス最後の行の``02 OK``となる。 指定された番号のメールがない場合などには``OK``ではなく``NO``となる。
+

          
+
取得したメールの内容はレスポンスの``BODY[] {388}``とそれに続く``CRLF``以降の部分に格納される。 ``388``は``BODY[]``のオクテット数を表すもので、``{388}``とその直後の``CRLF``に続く388オクテット分が``BODY[]``の内容(つまり取得したメール)となる。
+

          
+
``FETCH``コマンドでは、``BODY.PEEK[]``の代わりに``BODY[]``を指定するとメール本文の取得と同時にメールを既読状態にすることができる。
+

          
+
メール番号はメールボックスに格納された順で連続した番号が割り当てられる。 メールの削除を行った場合にはその番号が割り当て直されて変化する点に注意。
+

          
+
``FETCH``コマンドではなく、``UID FETCH``コマンドを使用するとメール番号の代わりにメールの''ユニークID''を指定して取得を行うことができる。 ユニークIDはメールボックスに格納される時に割り当てられた以降は変化することがない。 メールクライアントのひとつであるThunderbirdでは、「受信順」のカラムを表示することでメールに割り当てられたユニークIDを確認することができる。
+

          
+
``FETCH``コマンドでは、引数を変えることによりメール本文以外にもメールに関する様々な情報を取得することができる。 (&urn2url(urn:ietf:rfc:3501,#6.4.5,short);)
+

          
+
``SEARCH``コマンドを使用すると、メールの件名などの条件からメール番号を検索することができる。 (&urn2url(urn:ietf:rfc:3501,#6.4.4,short);)
+

          
+

          
+
**クローズ・ログアウト (CLOSEコマンド・LOGOUTコマンド)
+
#code(,CLOSE・LOGOUTコマンドとレスポンス){{
+
C:	03 CLOSE
+
S:	03 OK Returned to authenticated state. (Success)
+
C:	04 LOGOUT
+
S:	* BYE LOGOUT Requested
+
	04 OK 73 good day (Success)
+
}}
+

          
+
メールの取得のみを行う場合はこのコマンドを送信せず即座に切断することもできる。 メールの削除を行う場合は``CLOSE``コマンドを送信してメールボックスを閉じない限り削除されない。
+

          
+
``LOGOUT``コマンドを送信すると、レスポンスが返送された後接続がサーバー側から切断される。
+

          
+

          
+