System.Net.Sockets.TcpClientクラスを使ってIMAPサーバーからメールを受信するサンプル。 .NET FrameworkにはIMAPクライアントにあたるクラスが用意されていないため、独自に実装する必要がある。

§1 IMAPクライアントの実装

以下のコードはIMAPサーバーに接続してメールを取得し、ファイルに保存するIMAPクライアントのサンプル。 SslStreamを使ってSSLによる接続を行う。

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

IMAPサーバーに接続してメールを取得するIMAPクライアント
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"; // 取得したメールを保存するファイルのパス

  static void Main()
  {
    // IMAPサーバーに接続
    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;
    }
  }
}

上記サンプルの制限事項や改良可能な点など。

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

§2 IMAPプロトコル概説

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

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

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の仕様については適宜RFC 3501 - INTERNET MESSAGE ACCESS PROTOCOL - VERSION 4rev1や文中のリンクを参照のこと。

§2.1 接続

initial greeting
S:	* OK Gimap ready for requests from xxx.xxx.xxx.xxx av5mb180262435pbd

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

§2.2 ログイン (LOGINコマンド)

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ではなくNOBADとなる。 OK以降の文字列はコマンドの結果を表す一種のコメントでありプロトコル上意味のある文字列では無く、その内容はサーバーによって異なる。

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

LOGINコマンドは平文での認証を行う。 DIGEST-MD5CRAM-MD5などを使って認証したい場合はAUTHENTICATEコマンドを使用する。 (RFC 3501 6.2.2CRAM-MD5による認証)

§2.3 メールボックスの選択 (SELECTコマンド)

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でメールボックス名をエンコードする必要がある。 (RFC 3501 5.1.3Modified UTF-7のエンコード・デコード)

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

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

§2.4 メールの取得 (FETCHコマンド)

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以降の部分に格納される。 388BODY[]のオクテット数を表すもので、{388}とその直後のCRLFに続く388オクテット分がBODY[]の内容(つまり取得したメール)となる。

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

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

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

FETCHコマンドでは、引数を変えることによりメール本文以外にもメールに関する様々な情報を取得することができる。 (RFC 3501 6.4.5)

SEARCHコマンドを使用すると、メールの件名などの条件からメール番号を検索することができる。 (RFC 3501 6.4.4)

§2.5 クローズ・ログアウト (CLOSEコマンド・LOGOUTコマンド)

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コマンドを送信すると、レスポンスが返送された後接続がサーバー側から切断される。