RustでSMTPプロトコルを使ってメール送信してみた

RustでSMTPプロトコルを使ってメール送信してみた

Rustで、Tokio::net::TcpStreamを使ってSMTPクライアントを実装し、ローカル環境で起動したMailhogにメールを送信してみました
Clock Icon2024.10.16

こんばんは、リテールアプリ共創部のmorimorikochanです👨‍✈️

先日、情報処理安全確保支援士の試験を受けてきました。
午後試験の問2がメールに関する問題で、舐めてかかったら全くわからず、試験時間40分を無駄にして結局他の問題に変更するという事態になりました。

めちゃくちゃ悔しかったので、メール送信・受信に関する知識を身につけるためにRustでメールを送信する実装をしてみました。

SMTPとは

Wikipediaによると、SMTPは以下のように説明されています。

Simple Mail Transfer Protocol(シンプル メール トランスファー プロトコル、SMTP)または簡易メール転送プロトコルは、インターネットで電子メールを転送するプロトコルである。通常 ポート番号 25 を利用する。 転送先のサーバを特定するために、DNS の MXレコードが使われる。RFC 5321 で標準化されている。

Simple Mail Transfer Protocol - Wikipedia

昨今では、メールクライアントソフトからメール送信時に25番ポートはあまり利用されておらず、Submissionポートである587番ポートが利用されることが多いみたいです。
これは25番ポートを利用したメール送信が、スパムメールの送信に利用されることが多いためです。

また、SMTPには様々なプロトコルによって拡張が行われています。
例えば通信を暗号化するためのSTARTTLSSMTPS、メールのコンテンツ自体を暗号化するためのPGPS/MIME、メールの送信元を確認するためのSPFDKIMおよびそれらを利用したDMARKなどがあります。
ここまで拡張が行われてきたのは、SMTPがRFCで標準になった1982年から40年以上経過しているのでそれなりに改良を重ねてきた結果なんだろうなと思います。ちなみに日頃私がお世話になっているHTTPが開発されたのは1989年なので、SMTPはHTTPより7年ほど先輩です

HTTP は World Wide Web を支えるプロトコルです。1989 年から 1991 年にかけてティム・バーナーズ=リーとそのチームによって開発された HTTP は、その柔軟性を形成しながら分かりやすさを維持するために、多くの変化を遂げてきました。HTTP が、実験室でファイルを交換するためのプロトコルから、高解像度や 3D の画像や動画を伝送する現代のインターネットの迷路へと進化していった経緯をご紹介します。

HTTP の進化 - HTTP | MDN

RustでSMTPクライアントを実装する

ゴール

本記事のゴールは、ローカルで起動しているMailhog(テスト用メール受信サーバー)に対して、SMTPクライアントを実装し、メールを送信します。

SMTPには様々な拡張が存在しますが、今回は最も基本的・簡単なフォーマットであるプレーンテキスト形式でメールを送信します。

また、SMTPで具体的に何をどういうふうに送れば良いのか全くわからないので、Simple Mail Transfer Protocol - Wikipediaに記載されている以下の例でそのまま試してみて、結果から逆算して仕様を勉強します

S: 220 foo.com ESMTP Postfix # 挨拶応答。サーバーがfoo.comであることを示す。
C: EHLO example.com # クライアントがexample.comであることを示す。
S: 250 foo.com greets example.com
C: MAIL FROM:[email protected] # 送信元
S: 250 Ok
C: RCPT TO:[email protected] # 宛先
S: 250 Ok
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: From: Bob Example [email protected] # メールデータの開始
C: To: Alice Example [email protected]
C: Date: Tue, 15 Jan 2008 16:02:43 -0500
C: Subject: Test message
C:
C: Hello Alice.
C: This is a test message with 4 header fields and 4 lines in the message body.
C: Your friend,
C: Bob
C: . # メールデータの終了
S: 250 Ok
C: QUIT
S: 221 Bye

CはSMTPクライアント、SはSMTPサーバーを表しています。

また、本来はSMTPサーバーからのレスポンスに基づいて処理をするべきですが、そこまでやるとコードがめちゃくちゃ長くなりそうなので一方的にコマンドの送信だけします。また気が向けば書きたいです

1.mailhogのインストール・起動

brew install mailhog
# サーバー起動
mailhog

起動後に http://localhost:8025 にアクセスすると、MailhogのWebUIが表示されます。

image.png

2.リクエスト送信してみる

cargoでプロジェクトを作成し、tokioをインストールします

cargo init
Cargo.toml
[dependencies]
+tokio = { version = "1", features = ["full"] }

src/main.rsに実装し、実行します。

https://github.com/diggymo/rust-email/blob/616def58a3f5877fdd28d249ad526effebefd8db/src/main.rs#L9-L79

cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/rust-email`
Connecting to server...
connected!
send EHLO
send MAIL FROM
send RCPT TO
send DATA
send QUIT
[src/main.rs:20] bytes_read = 35
[src/main.rs:22] response = "220 mailhog.example ESMTP MailHog\r\n"
[src/main.rs:20] bytes_read = 55
[src/main.rs:22] response = "250-Hello example.com\r\n250-PIPELINING\r\n250 AUTH PLAIN\r\n"
[src/main.rs:20] bytes_read = 68
[src/main.rs:22] response = "250 Sender [email protected] ok\r\n250 Recipient [email protected] ok\r\n"
[src/main.rs:20] bytes_read = 37
[src/main.rs:22] response = "354 End data with <CR><LF>.<CR><LF>\r\n"
[src/main.rs:20] bytes_read = 80
[src/main.rs:22] response = "250 Ok: queued as [email protected]\r\n"
[src/main.rs:20] bytes_read = 0
[src/main.rs:22] response = ""

うまく実行できました

mailhogのログ全文
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Starting session
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: INVALID] Started session, switching to ESTABLISH state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 35 bytes: '220 mailhog.example ESMTP MailHog\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Received 239 bytes: 'EHLO example.com\r\nMAIL FROM:<[email protected]>\r\nRCPT TO:<[email protected]>\r\nDATA\r\nFrom: Bob Example <[email protected]>\r\nTo: Alice Example <[email protected]>\r\nDate: Tue, 15 Jan 2008 16:02:43 -0500\r\nSubject: Test message\r\n\r\nHello Alice\r\n.\r\nQUIT'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: ESTABLISH] Processing line: EHLO example.com
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: ESTABLISH] In state 1, got command 'EHLO', args 'example.com'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: ESTABLISH] In ESTABLISH state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: ESTABLISH] Got EHLO command, switching to MAIL state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 23 bytes: '250-Hello example.com\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 16 bytes: '250-PIPELINING\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 16 bytes: '250 AUTH PLAIN\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: MAIL] Processing line: MAIL FROM:<[email protected]>
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: MAIL] In state 6, got command 'MAIL', args 'FROM:<[email protected]>'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: MAIL] In MAIL state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: MAIL] Got MAIL command, switching to RCPT state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 36 bytes: '250 Sender [email protected] ok\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] Processing line: RCPT TO:<[email protected]>
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] In state 7, got command 'RCPT', args 'TO:<[email protected]>'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] In RCPT state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] Got RCPT command
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 32 bytes: '250 Recipient [email protected] ok\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] Processing line: DATA
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] In state 7, got command 'DATA', args ''
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] In RCPT state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: RCPT] Got DATA command, switching to DATA state
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 37 bytes: '354 End data with <CR><LF>.<CR><LF>\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] [PROTO: DATA] Got EOF, storing message and switching to MAIL state
2024/10/16 18:58:07 Parsing Content from string: 'From: Bob Example <[email protected]>
To: Alice Example <[email protected]>
Date: Tue, 15 Jan 2008 16:02:43 -0500
Subject: Test message

Hello Alice'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Storing message [email protected]
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Sent 80 bytes: '250 Ok: queued as [email protected]\r\n'
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Connection closed by remote host
2024/10/16 18:58:07 [SMTP 127.0.0.1:55887] Session ended
Got message in APIv2 websocket channel
[APIv2] BROADCAST /api/v2/websocket
Got message in APIv1 event stream
Sending content: {
  "ID": "[email protected]",
  "From": {
    "Relays": null,
    "Mailbox": "hogehoge",
    "Domain": "example.com",
    "Params": ""
  },
  "To": [
    {
      "Relays": null,
      "Mailbox": "alice",
      "Domain": "foo.com",
      "Params": ""
    }
  ],
  "Content": {
    "Headers": {
      "Date": [
        "Tue, 15 Jan 2008 16:02:43 -0500"
      ],
      "From": [
        "Bob Example \[email protected]\u003e"
      ],
      "Message-ID": [
        "[email protected]"
      ],
      "Received": [
        "from example.com by mailhog.example (MailHog)\r\n          id [email protected]; Wed, 16 Oct 2024 18:58:07 +0900"
      ],
      "Return-Path": [
        "\[email protected]\u003e"
      ],
      "Subject": [
        "Test message"
      ],
      "To": [
        "Alice Example \[email protected]\u003e"
      ]
    },
    "Body": "Hello Alice",
    "Size": 147,
    "MIME": null
  },
  "Created": "2024-10-16T18:58:07.927451+09:00",
  "MIME": null,
  "Raw": {
    "From": "[email protected]",
    "To": [
      "[email protected]"
    ],
    "Data": "From: Bob Example \[email protected]\u003e\r\nTo: Alice Example \[email protected]\u003e\r\nDate: Tue, 15 Jan 2008 16:02:43 -0500\r\nSubject: Test message\r\n\r\nHello Alice",
    "Helo": "example.com"
  }
}

[APIv1] BROADCAST /api/v1/events

コードやmailhogのログを見てみると色々わかりますね

  • 各コマンドは\r\nで終わる
  • はじめにEHLOコマンドでクライアントがサーバーに接続を開く
  • つぎにMAIL FROMコマンドで送信元メールアドレスを指定
    • これがエンベロープFROM
      • HTTPリクエストの感覚でみてしまうと、これがヘッダーFROMのように感じますが、実際は違うみたいです
  • その後RCPT TOコマンドで宛先を指定
    • これがエンベロープTO
  • その後DATAコマンドでメールヘッダおよびメール本文を送信
    • From: ~~がヘッダーFROM
    • To: ~~がヘッダーTO
    • Date: ~~の部分で日時を指定する
      • ここで過去の日付を指定しても問題なく送信できました
    • Subject: ~~がメールタイトル
    • 1行空行を入れることでヘッダーと本文を区切る
    • 末尾に<CR><LF>.<CR><LF>を付与しメール本文を閉じる
  • 最後にQUITコマンドで接続を閉じる

また、MailhogのWebUIから送られたメールが詳しく閲覧できます。ここからも色々わかりますね

  • Return-PathヘッダーにはエンベロープFROMが入っている
  • Receivedヘッダーに経路情報が入っている
  • Dateヘッダーに過去の日付を入力したにも関わらず、問題なく表示されている

スクリーンショット 2024-10-16 19.00.15

また、Receivedヘッダーには本来経路情報が入っているのですが、このデータだけではイメージがつきません。
そこで、MailhogのWebUIから自サーバーに転送してみます

3.転送してみる

以下のように設定して転送します

image.png

転送したメールを見ると、Receivedヘッダーが変化しています。

image.png

転送されるとReceivedヘッダーの先頭に情報が追加されるようですね。
また、転送時にエンベロープTOに指定した[email protected]がどこにも記載がありません。エンベロープTOはあくまで転送時にのみ利用される情報であるということがわかりますね

ソースコード

今回書いたソースコードはこちらです

https://github.com/diggymo/rust-email/blob/616def58a3f5877fdd28d249ad526effebefd8db/src/main.rs#L9-L79

いくつか実装をピックアップして解説してみます。

TcpStreamについて

Rustのstd::net::TcpStreamは、TCP接続を行うための構造体です。
また、非同期ランタイムであるtokioでもこのTcpStreamを扱うことができます。その場合はtokio::net::TcpStreamを指定します。

このTcpStreamは、std::io::Readトレイトとstd::io::Writeトレイトを実装しているため、read()メソッドとwrite()メソッドを利用してデータの読み書きが行えます。
また、split()を実行することで読み込みと書き込みでそれぞれ別のstreamに分割することができます。今回はこのメソッドを使って読み込みと書き込みを別スレッドで行っていきます。

ちなみに、read()メソッドを利用するとデータが送られるたびに取得でき、データが送られるまではずっとハングします。
もしそうではなくその時点でのデータを取得したい場合はtry_read()メソッドを利用する必要があります。ただし、データが取得できない場合はErr(Kind(WouldBlock))が返却されるので適宜無視するようハンドリングしなければなりません

また、read_to_string()EOLが受信されるまでハングされるので注意が必要です。今回のようにSMTPクライアントを実装する場合、相手からの応答に応じて通信内容を変化させる必要があるのでこのメソッドは利用できません

あまり詳しくないので自信がないですが、私が調べた限りではここでいうEOLはコネクションが切れた際に発生するシグナルだと思って良いと思います。

処理フローについて

TCP接続を扱う際、処理フローの制御で苦戦したので、軽くコードにコメントで整理しておきます

// 0.読み取り/書き込みで別のstreamに分割
let (mut reader, mut writer) = tcp_stream.into_split();

// 1.tokioで別スレッドを作成
let reading_task = tokio::task::spawn(async move { 
    loop {
        // 2.SMTPサーバーからデータが送られると`buffer`にデータを代入、データが送られるまではずっとハングする
        // 5."3"でコネクションがcloseされたため、0が返却される
        let bytes_read = reader.read(&mut buffer).await.unwrap(); 
        if bytes_read == 0 {
            break;
        }
    }
});

 // 3.書き込み側を閉じる
writer.shutdown().await.unwrap();

 // 4.別スレッドの終了を待つ(こうしないとログ出力の前にプログラムが終了する)
reading_task.await.unwrap();

Tokioとは

Rustの非同期ランタイムの一種です。
あまり詳しくないですが、TokioではOSのスレッドを扱わずグリーンスレッドを扱うため軽量に非同期処理を扱うことができます。

Javascriptのasync/awaitと書き味が似てるので、Javascriptを書いたことがある人には馴染みやすいかもしれません。

今回は、TCPの応答をログ出力する処理を別スレッドで動かすために利用しました

https://tokio.rs/

まとめ・所感

  • SMTPを使ってEHLO, MAIL FROM, RCPT TO, DATA, QUITのコマンドを送信することで、メールを送信することができる
  • SMTPの歴史がとてつもなく古い
  • SMTPの通信について
    • 各コマンドは\r\nで終わる
    • はじめにEHLOコマンドでクライアントがサーバーに接続を開く
    • つぎにMAIL FROMコマンドで送信元メールアドレスを指定
    • これがエンベロープFROM
      • HTTPリクエストの感覚でみてしまうと、これがヘッダーFROMのように感じますが、実際は違うみたいです
    • その後RCPT TOコマンドで宛先を指定
    • これがエンベロープTO
    • その後DATAコマンドでメールヘッダおよびメール本文を送信
    • From: ~~がヘッダーFROM
    • To: ~~がヘッダーTO
    • Date: ~~の部分で日時を指定する
      • ここで過去の日付を指定しても問題なく送信できました
    • Subject: ~~がメールタイトル
    • 1行空行を入れることでヘッダーと本文を区切る
    • 末尾に<CR><LF>.<CR><LF>を付与しメール本文を閉じる
    • 最後にQUITコマンドで接続を閉じる
  • メーラーでの表示について
    • Return-PathヘッダーにはエンベロープFROMが入っている
    • Receivedヘッダーに経路情報が入っている
      • 転送されるとReceivedヘッダーの先頭に順次追加される
    • Dateヘッダーに過去の日付を入力したにも関わらず、問題なく表示されている
  • TcpStreamの各メソッドの使い分け
    • read()メソッドを利用するとデータが送られるたびに取得でき、データが送られるまではずっとハングする
    • try_read()メソッドを利用すると即時的にその時点でのデータを取得することができる
    • read_to_string()EOLが送信されるまでハングされる
  • 所感🤨
    • まだネットワークに流せるレベルではないですね...
    • 気が向けばここら辺も実装して実用的なレベルまで発展させたいなと思ってます(伸び代しかない)
      • SMTPサーバーからの応答に対する通信の制御
      • エンコード・添付ファイル
      • DKIM
      • S/MIME

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.