【C#】【MailKit】MailKitを使ったメールの受信

# MailKitを使ったメールの受信

ひょんなことから、メールを自動受信するプログラムってどうやったら書けるのかな？
‥‥と思い、調べてみると、C#でMailKitというライブラリを使えばとても簡単に取得が出来るようです。ここでは、こういう形でやればとりあえず可能ですよ、という形で備忘録に残します。

#### 環境
C# - Winodws Form Application (Visual Studio 2022 , .NET Framework 4.8)

## ライブラリ
### MailKit

MailKitは、C#および.NET向けのオープンソースのメールクライアントライブラリで、IMAP、POP3、SMTPなどのプロトコルを簡単に扱うことができるとのこと。learn.microsoftのSystem.Net.Mailの項目に「SmtpClientはもう古いからMailKitなどのライブラリを使おう」とか書かれるくらいには認められたもの。

## 初期設定
適当なフォームアプリを生成し、
[ツール]>[NuGetパッケージマネージャー]>[ソリューションのNuGetパッケージの管理]
参照タブ、検索に「MailKit」と入力し、MailKitをインストール。
![ss_20241123_081628.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1443439/8b207832-b612-1191-f22f-31d9c299ff01.jpeg)
この画面では導入済なのでアンインストールボタンになっちゃってますが。
134Mって1億3千万DLってこと？

## メール受信(疎通確認)
とりあえず動くことを確認してみます。
私の使っているメールサーバーはIMAPでしたのでそちらで書いてます。(POP3は補足します)
button1というボタンと複数行表示できるtextBox1をご用意ください。
~~~csharp
using MimeKit;
using MailKit.Net.Pop3;
using MailKit.Net.Imap;
using MailKit;
~~~
~~~csharp
        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = "";
            // IMAP接続
            using (var client = new ImapClient())
            {
                try
                {
                    var host = "xxxxx.ne.jp";        // サーバーのホスト名
                    var port = 993;                  // サーバーのポート (通常993)
                    var user = "xxxxxxxx";           // メールアドレス
                    var password = "yyyyyyyy";       // パスワード
                    // サーバーに接続・認証
                    client.Connect(host, port, true);
                    client.Authenticate(user, password);
                    textBox1.Text += "サーバーに接続しました。\r\n";

                    // 受信トレイを開く
                    var inbox = client.Inbox;
                    inbox.Open(FolderAccess.ReadOnly);
                    textBox1.Text += $"受信トレイ内のメール数: {inbox.Count}\r\n";
                    // メールを取得
                    for (int i=0; i < inbox.Count; i++)
                    {
                        var message = inbox.GetMessage(i);
                        textBox1.Text += $"件名: {message.Subject}\r\n";
                        textBox1.Text += $"　送信者: {message.From}\r\n";
                        textBox1.Text += $"　日付: {message.Date}\r\n";
                    }
                    // サーバーから切断
                    client.Disconnect(true);
                    textBox1.Text += $"サーバーから切断しました。\r\n";
                }
                catch (Exception ex)
                {
                    textBox1.Text += $"エラーが発生しました: {ex.Message}\r\n";
                }
            }
        }
~~~
繋がりました。
![ss_20241123_090440.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1443439/d171d98b-89a6-8b38-fc4c-73507a144485.jpeg)
リアルの私用メールなので伏せてます。（まぁ、ガス会社からの請求書です）

解説する箇所はあまりありませんね。
接続し、認証し、受信トレイを開くとメールの件数が分かるので、GetMessageで一件ずつ取得します。ここで取得できるのが、``MimeMessage``型のメールオブジェクト。この中身を取り出していろいろやることになりますね。

また、私の方で試して見たかぎり、``for (int i=0; i < inbox.Count; i++)``のループ順は古いメールから順になるようです。なので、直近10件の場合は、``for (int i = inbox.Count - 1; i >= Math.Max(0, inbox.Count - 10); i--)``のように最大添字からバックしていく必要があります。


<details><summary>POP3サーバー</summary>
POP3サーバーへの接続の場合は、

* clientがImapClient型でなくPop3Client型になる
* 「受信トレイを開く」という行程は不要で、clientから直接メールボックス内の件数・メールを取る

という点が異なります。それ以外は概ね、IMAPと同じ形式でやれるようにMailKitがしてくれているようです。取得できるのも同じ``MimeMessage``型のメールオブジェクトで、後の処理は共通化できます。

~~~csharp
        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = "";
            // POP3接続
            using (var client = new Pop3Client())
            {
                try
                {
                    var host = "xxxxx.ne.jp";        // サーバーのホスト名
                    var port = 993;                  // サーバーのポート (通常993)
                    var user = "xxxxxxxx";           // メールアドレス
                    var password = "yyyyyyyy";       // パスワード
                    // サーバーに接続・認証
                    client.Connect(host, port, true);
                    client.Authenticate(user, password);
                    textBox1.Text += "サーバーに接続しました。\r\n";
                    textBox1.Text += $"メールボックス内のメール数: {client.Count}\r\n";

                    // メールを取得
                    for (int i = 0; i < client.Count; i++)
                    {
                        var message = client.GetMessage(i);
                        textBox1.Text += $"件名: {message.Subject}\r\n";
                        textBox1.Text += $"　送信者: {message.From}\r\n";
                        textBox1.Text += $"　日付: {message.Date}\r\n";
                    }
                    // サーバーから切断
                    client.Disconnect(true);
                    textBox1.Text += $"サーバーから切断しました。\r\n";
                }
                catch (Exception ex)
                {
                    textBox1.Text += $"エラーが発生しました: {ex.Message}\r\n";
                }
            }
        }
~~~
</details>

## メールの情報の加工

幾つか操作して気づいたことなどを。
下記例で``src``というのが、取得してきた``MimeMessage`` 型のメールオブジェクトです。

### 送信者と送信者メールアドレス
単に``From``を``ToString``すると、``"名前"<アドレス>``になるのですが、これは、``InternetAddressList``型で、複数のアドレスを入れられます。（Fromでそんなことある？ただ、TO、CCやBCCも同じ型なので統一したものではあるのでしょう）

で、下記のように``ToArray``で文字列配列化⇒先頭を取得で、名前部分とアドレス部分が取れました。
~~~C#
string fromName = src.From.OfType<MailboxAddress>().Select(f => f.Name).ToArray()[0];
string fromAddress = src.From.OfType<MailboxAddress>().Select(f => f.Address).ToArray()[0];
~~~
TOやCCでも同様。ただ、こちらは複数指定が前提なもので、適切な処置が必要ですね。

### 送信時刻
送信時刻は``DateTimeOffest``型で、``2024/11/28 23:13:31 -06:00``のように時差が入ってます。``DateTime``は時差情報がなく、変換すると失われるので、表示程度なら変換せず扱った方がよさそうです。``DateTime``にするなら、ローカルPCのロケールにコンバートしてからにしましょう。分かっていればコードはシンプルです。
~~~C#
DateTime sendDate = TimeZoneInfo.ConvertTime(src.Date, TimeZoneInfo.Local).DateTime;
~~~

### 本文
本文は``TextBody``ですが、``GetTextBody`` でフォーマットを指定して本文が取得できるようで、``plain``を指定してプレーンテキストで取るのが安心みたいですね。
~~~C#
string Body = src.GetTextBody(MimeKit.Text.TextFormat.Plain);
~~~
で、これで取った本文が空のケースがあります。HTMLメールです。そちらは``HtmlBody``に入っているので、そちらもチェックして空でなかったらHtmlメールとして扱う‥‥でしょうか。
~~~C#
string body = src.GetTextBody(MimeKit.Text.TextFormat.Plain);
bool isHtmlMail = String.IsNullOrEmpty(body) && !String.IsNullOrEmpty(src.HtmlBody);
body = isHtmlMail ? @"(HTMLメールです)": body;
// body = isHtmlMail ? src.HtmlBody : body;
~~~
HTMLタグぶちまけて良いなら後者。

### 添付ファイル
``Attachments``が添付ファイルですが、ここには「HTMLメールに埋め込んだインライン画像」が含まれていないようでした。これは困るので、どうするかというと、``BodyParts``をループすれば良いようです。

~~~C#
var saveDir = @"C:\_Temp_\Mail";
string messageDir = Path.Combine(saveDir, dist.SendDate.ToString("yyyyMMdd_HHmmss") + "_" + src.MessageId);

foreach (var part in src.BodyParts)
{
    if (part is MimePart mimePart && !string.IsNullOrEmpty(mimePart.FileName))
    {
        //フォルダの生成
        Directory.CreateDirectory(messageDir);
        // ファイル名と保存先パスを設定
        var fileName = mimePart.FileName;
        var filePath = Path.Combine(messageDir, fileName);
        try
        {
            // ファイルを保存
            using (var stream = File.Create(filePath))
            {
                mimePart.Content.DecodeTo(stream);
            }
            Console.WriteLine($"  添付ファイル保存: {filePath}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"  添付ファイルの保存に失敗しました: {fileName}, エラー: {ex.Message}");
        }
    }
}

~~~

<details><summary>(2024-12-08追記)：本文もHTMLもBodyPartsにある</summary>
BodyPartsは全てのメールのパーツを含むため、本文もHTMLもここに含まれますが、このプログラムでは取り除かれます。どこで？、というとIf文の ``!string.IsNullOrEmpty(mimePart.FileName)`` の部分で、これらはFileNameを持たないのでした。なのでこの空チェックを外し、空の場合に適当なファイル名を指定すれば保存ができます。

なお、どちらがどちらかは``mimePart.ContentType``で判別できます。
本文は ``Content-Type: text/plain; charset="UTF-8"`` で、
HTMLは ``Content-Type: text/html; charset="UTF-8"`` でした。

あと、``part is MimePart``という句がありますが、partはこれ以外に``MessagePart``というのがあるようです。ただ、私に最近の届くメールは、本文含め全てがMimePartでした。
</details>

せっかくフォルダを掘ったので、ついでにHtmlメールも保存してしまいます。
~~~C#
if (isHtmlMail)
{
     Directory.CreateDirectory(messageDir);
     File.WriteAllText(Path.Combine(messageDir, "mail.html"), src.HtmlBody);
}
~~~
## メールヘッダを見てから本文を受信する

さて、上記のプログラムでは、
``for (int i=0; i < inbox.Count; i++){ var message = inbox.GetMessage(i); ～～}``
とやっていますが、コレは全てのメールを受信しているので、実用でやると、通信の無駄が発生しています。ヘッダだけを見て、本文を受信するかを決めたい。そういう時は``Fetch``コマンドが使えるようです。

ここでは送信日時を見て、送信日時が今日のメールのみを本文取得してみます。

上の取得プログラムでのfor文(//メールを取得の部分)を下記に差し替えてください。つまり、受信トレイを開いてからサーバーから切断するまでの間、です。
~~~C#
  // サマリを取得
  var summaries = inbox.Fetch(0, -1, MessageSummaryItems.Envelope | MessageSummaryItems.UniqueId);
  foreach (var summary in summaries)
  {
      Console.WriteLine($"MessageId: {summary.Envelope.MessageId}");
      Console.WriteLine($"Subject: {summary.Envelope.Subject}");
      Console.WriteLine($"Date: {summary.Envelope.Date}");
      Console.WriteLine($"From: {string.Join(", ", summary.Envelope.From)}");
      Console.WriteLine($"To: {string.Join(", ", summary.Envelope.To)}");
      Console.WriteLine($"UID: {summary.UniqueId}");
      // 送信日時が今日であればメールを取得
      var date = TimeZoneInfo.ConvertTime(summary.Envelope.Date ?? DateTime.MinValue, TimeZoneInfo.Local).DateTime;
      if(date >= DateTime.Today)
      {
          var message = inbox.GetMessage(summary.UniqueId);
          textBox1.Text += $"件名: {message.Subject}\r\n";
          textBox1.Text += $"　送信者: {message.From}\r\n";
          textBox1.Text += $"　日付: {message.Date}\r\n";
          textBox1.Text += $"　本文: {message.TextBody}\r\n";
      }
  }
~~~

``inbox.Fetch``の引数、0と-1は、最小番号と最大番号で、0と10だと、1件目から11件目が取れるという、つまり前のプログラムでいう``i``の範囲そのものです。ヘッダだけとはいえ、サーバーにメールが1万件とかあるからヘッダーを絞りたい、というならここを弄れば良さそうです。

なお、注意点としては、メソッドのコメントを読む限り、取得までの間に横やり(別クライアントからのメールの追加や削除)が入ることは想定していないようです。あまり分岐などでモタモタしない方が良さそう。神経質になるなら受信しようしたものと受信したもので``MeesageId``が一致するか、とかそういう感じですかね。

(2024-12-08追記)：なお、FetchはIMAPのみでPOP3では対応していないようです。

<details><summary>(2024-12-08追記)：【免責】正直よくわかっていません</summary>

MailKitのドキュメントには、Fetchの項に Fetches the message summarie (サマリを取得する)と書いており、属性として指定しているのは、Envelope（封筒）なので、封筒でわかる情報(宛先など)で本文は含まれないと取るのが自然(確かにプロパティにはない)ですが、Fetchで指定している属性には本文を取るものもあるし、「サーバーからサマリを取ってきてる」のか「サーバーから本文ごと全部とってきてサマリのオブジェクトに変換している」のかは分からない。

</details>


## まとめ
一旦MailKitでメール受信をする機能について一通りまとめてみました。
ただこれは、機能の紹介のための雑コードになっているので、使いやすくするにはかなりの工夫が必要です。
私のほうでも工夫したものは別の日に纏めてみようと思っています。

----

独学のため正確でない可能性があります。
（っ・ｘ・）っ きゅ