PHPを使用したIMAP電子メールクライアントの構築

公開: 2022-03-11

開発者は、電子メールメールボックスへのアクセスを必要とするタスクに遭遇することがあります。 ほとんどの場合、これはインターネットメッセージアクセスプロトコル(IMAP)を使用して行われます。 PHP開発者として、私は最初にPHPの組み込みIMAPライブラリに目を向けましたが、このライブラリはバグがあり、デバッグや変更が不可能です。 また、プロトコルの機能を最大限に活用するためにIMAPコマンドをカスタマイズすることもできません。

そこで本日は、PHPを使用してゼロから機能するIMAP電子メールクライアントを作成します。 Gmailの特別なコマンドの使用方法についても説明します。

カスタムクラスimap_driverにIMAPを実装します。 クラスを構築する際の各ステップについて説明します。 記事の最後にimap_driver.php全体をダウンロードできます。

接続の確立

IMAPは接続ベースのプロトコルであり、通常はSSLセキュリティを備えたTCP / IPを介して動作するため、IMAP呼び出しを行う前に、接続を開く必要があります。

接続するIMAPサーバーのURLとポート番号を知る必要があります。 この情報は通常、サービスのWebサイトまたはドキュメントで宣伝されます。 たとえば、Gmailの場合、URLはポート993のssl://imap.gmail.comです。

初期化が成功したかどうかを知りたいので、クラスコンストラクターを空のままにし、すべての接続はカスタムinit()メソッドで行われ、接続を確立できない場合はfalseを返します。

 class imap_driver { private $fp; // file pointer public $error; // error message ... public function init($host, $port) { if (!($this->fp = fsockopen($host, $port, $errno, $errstr, 15))) { $this->error = "Could not connect to host ($errno) $errstr"; return false; } if (!stream_set_timeout($this->fp, 15)) { $this->error = "Could not set timeout"; return false; } $line = fgets($this->fp); // discard the first line of the stream return true; } private function close() { fclose($this->fp); } ... }

上記のコードでは、 fsockopen()が接続を確立するためと、データストリーム自体が開いたときに要求に応答するための両方で、15秒のタイムアウトを設定しました。 多くの場合、サーバーが応答しないため、ネットワークへのすべての呼び出しに対してタイムアウトを設定することが重要です。このようなフリーズを処理できる必要があります。

また、ストリームの最初の行を取得して無視します。 通常、これはサーバーからの単なる挨拶メッセージ、またはサーバーが接続されていることの確認です。 特定のメールサービスのドキュメントをチェックして、これが当てはまることを確認してください。

次に、上記のコードを実行して、 init()が成功することを確認します。

 include("imap_driver.php"); // test for init() $imap_driver = new imap_driver(); if ($imap_driver->init('ssl://imap.gmail.com', 993) === false) { echo "init() failed: " . $imap_driver->error . "\n"; exit; }

基本的なIMAP構文

IMAPサーバーに対してアクティブなソケットが開いているので、IMAPコマンドの送信を開始できます。 IMAP構文を見てみましょう。

正式なドキュメントは、インターネット技術特別調査委員会(IETF)RFC3501にあります。 IMAPの相互作用は通常、クライアントがコマンドを送信し、サーバーが成功の兆候で応答し、要求された可能性のあるデータで構成されます。

コマンドの基本的な構文は次のとおりです。

 line_number command arg1 arg2 ...

行番号または「タグ」は、コマンドの一意の識別子であり、サーバーが一度に複数のコマンドを処理している場合に、サーバーが応答しているコマンドを示すために使用します。

以下に、 LOGINコマンドを示す例を示します。

 00000001 LOGIN [email protected] password

サーバーの応答は、「タグなし」のデータ応答で始まる場合があります。 たとえば、Gmailはログインの成功に、サーバーの機能とオプションに関する情報を含むタグなしの応答で応答し、電子メールメッセージをフェッチするコマンドは、メッセージ本文を含むタグなしの応答を受信します。 いずれの場合も、応答は常に「タグ付き」コマンド完了応答行で終了する必要があります。これは、応答が適用されるコマンドの行番号、完了ステータスインジケーター、およびコマンドに関する追加のメタデータ(存在する場合)を識別します。

 line_number status metadata1 metadata2 ...

GmailがLOGINコマンドに応答する方法は次のとおりです。

  • 成功:
 * CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS 00000001 OK [email protected] authenticated (Success)
  • 失敗:
 00000001 NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)

ステータスは、成功を示すOK 、失敗を示すNO 、または無効なコマンドまたは不正な構文を示すBADのいずれかになります。

基本コマンドの実装:

コマンドをIMAPサーバーに送信し、応答とエンドラインを取得する関数を作成してみましょう。

 class imap_driver { private $command_counter = "00000001"; public $last_response = array(); public $last_endline = ""; private function command($command) { $this->last_response = array(); $this->last_endline = ""; fwrite($this->fp, "$this->command_counter $command\r\n"); // send the command while ($line = fgets($this->fp)) { // fetch the response one line at a time $line = trim($line); // trim the response $line_arr = preg_split('/\s+/', $line, 0, PREG_SPLIT_NO_EMPTY); // split the response into non-empty pieces by whitespace if (count($line_arr) > 0) { $code = array_shift($line_arr); // take the first segment from the response, which will be the line number if (strtoupper($code) == $this->command_counter) { $this->last_endline = join(' ', $line_arr); // save the completion response line to parse later break; } else { $this->last_response[] = $line; // append the current line to the saved response } } else { $this->last_response[] = $line; } } $this->increment_counter(); } private function increment_counter() { $this->command_counter = sprintf('%08d', intval($this->command_counter) + 1); } ... }

LOGINコマンド

これで、内部でcommand()関数を呼び出す特定のコマンドの関数を記述できます。 LOGINコマンドの関数を書いてみましょう。

 class imap_driver { ... public function login($login, $pwd) { $this->command("LOGIN $login $pwd"); if (preg_match('~^OK~', $this->last_endline)) { return true; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

これで、このようにテストできます。 (テストするには、アクティブな電子メールアカウントが必要であることに注意してください。)

 ... // test for login() if ($imap_driver->login('[email protected]', 'password') === false) { echo "login() failed: " . $imap_driver->error . "\n"; exit; }

Gmailはデフォルトでセキュリティに関して非常に厳格であることに注意してください。デフォルト設定があり、アカウントプロファイルの国以外の国からアクセスしようとすると、IMAPでメールアカウントにアクセスできなくなります。 しかし、修正するのは簡単です。 ここで説明するように、Gmailアカウントで安全性の低い設定を設定するだけです。

SELECTコマンド

それでは、メールで何か役立つことをするためにIMAPフォルダを選択する方法を見てみましょう。 command()メソッドのおかげで、構文はLOGINの構文と似ています。 代わりにSELECTコマンドを使用して、フォルダーを指定します。

 class imap_driver { ... public function select_folder($folder) { $this->command("SELECT $folder"); if (preg_match('~^OK~', $this->last_endline)) { return true; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

それをテストするために、INBOXを選択してみましょう。

 ... // test for select_folder() if ($imap_driver->select_folder("INBOX") === false) { echo "select_folder() failed: " . $imap_driver->error . "\n"; return false; }

高度なコマンドの実装

IMAPのより高度なコマンドのいくつかを実装する方法を見てみましょう。

SEARCHコマンド

電子メール分析の一般的なルーチンは、特定の日付範囲の電子メールを検索したり、フラグが立てられた電子メールを検索したりすることです。 検索条件は、スペースを区切り文字として、引数としてSEARCHコマンドに渡す必要があります。 たとえば、2015年11月20日以降にすべてのメールを取得する場合は、次のコマンドを渡す必要があります。

 00000005 SEARCH SINCE 20-Nov-2015

そして、応答は次のようになります。

 * SEARCH 881 882 00000005 OK SEARCH completed

可能な検索用語の詳細なドキュメントはここにありますSEARCHコマンドの出力は、空白で区切られた電子メールのUIDのリストです。 UIDは、ユーザーのアカウント内の電子メールを時系列で一意に識別するものです。ここで、1は最も古い電子メールです。 SEARCHコマンドを実装するには、結果のUIDを返すだけです。

 class imap_driver { ... public function get_uids_by_search($criteria) { $this->command("SEARCH $criteria"); if (preg_match('~^OK~', $this->last_endline) && is_array($this->last_response) && count($this->last_response) == 1) { $splitted_response = explode(' ', $this->last_response[0]); $uids = array(); foreach ($splitted_response as $item) { if (preg_match('~^\d+$~', $item)) { $uids[] = $item; // put the returned UIDs into an array } } return $uids; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

このコマンドをテストするために、過去3日間のメールを受け取ります。

 ... // test for get_uids_by_search() $ids = $imap_driver->get_uids_by_search('SINCE ' . date('jM-Y', time() - 60 * 60 * 24 * 3)); if ($ids === false) { echo "get_uids_failed: " . $imap_driver->error . "\n"; exit; }

BODY.PEEKを使用したFETCHコマンド

もう1つの一般的なタスクは、電子メールをSEENとしてマークせずに電子メールヘッダーを取得することです。 IMAPマニュアルから、電子メールの全部または一部を取得するためのコマンドはFETCHです。 最初の引数は、関心のある部分を示し、通常はBODYが渡されます。これにより、メッセージ全体とそのヘッダーが返され、 SEENとしてマークされます。 代替引数BODY.PEEKは、メッセージをSEENとしてマークせずに、同じことを行います。

IMAP構文では、取得する電子メールのセクション(この例では[HEADER]も角かっこで指定する必要があります。 その結果、コマンドは次のようになります。

 00000006 FETCH 2 BODY.PEEK[HEADER]

そして、次のような応答が期待されます。

 * 2 FETCH (BODY[HEADER] {438} MIME-Version: 1.0 x-no-auto-attachment: 1 Received: by 10.170.97.214; Fri, 30 May 2014 09:13:45 -0700 (PDT) Date: Fri, 30 May 2014 09:13:45 -0700 Message-ID: <CACYy8gU+UFFukbE0Cih8kYRENMXcx1DTVhvg3TBbJ52D8OF6nQ@mail.gmail.com> Subject: The best of Gmail, wherever you are From: Gmail Team <[email protected]> To: Example Test <[email protected]> Content-Type: multipart/alternative; boundary=001a1139e3966e26ed04faa054f4 ) 00000006 OK Success

ヘッダーをフェッチするための関数を作成するには、ハッシュ構造(キーと値のペア)で応答を返すことができる必要があります。

 class imap_driver { ... public function get_headers_from_uid($uid) { $this->command("FETCH $uid BODY.PEEK[HEADER]"); if (preg_match('~^OK~', $this->last_endline)) { array_shift($this->last_response); // skip the first line $headers = array(); $prev_match = ''; foreach ($this->last_response as $item) { if (preg_match('~^([az][a-z0-9-_]+):~is', $item, $match)) { $header_name = strtolower($match[1]); $prev_match = $header_name; $headers[$header_name] = trim(substr($item, strlen($header_name) + 1)); } else { $headers[$prev_match] .= " " . $item; } } return $headers; } else { $this->error = join(', ', $this->last_response); $this->close(); return false; } } ... }

そして、このコードをテストするために、関心のあるメッセージのUIDを指定するだけです。

 ... // test for get_headers_by_uid if (($headers = $imap_driver->get_headers_from_uid(2)) === false) { echo "get_headers_by_uid() failed: " . $imap_driver->error . "\n"; return false; }

GmailIMAP拡張機能

Gmailには、私たちの生活をはるかに楽にすることができる特別なコマンドのリストが用意されています。 GmailのIMAP拡張コマンドのリストはこちらから入手できます。 私の意見では、最も重要なコマンドであるX-GM-RAWを確認してみましょう。 これにより、IMAPでGmail検索構文を使用できるようになります。 たとえば、[プライマリ]、[ソーシャル]、[プロモーション]、[更新]、または[フォーラム]のカテゴリにある電子メールを検索できます。

機能的には、 X-GM-RAWSEARCHコマンドの拡張であるため、上記のコードをSEARCHコマンドに再利用できます。 キーワードX-GM-RAWと基準を追加するだけです。

 ... // test for gmail extended search functionality $ids = $imap_driver->get_uids_by_search(' X-GM-RAW "category:primary"'); if ($ids === false) { echo "get_uids_failed: " . $imap_driver->error . "\n"; return false; }

上記のコードは、「プライマリ」カテゴリにリストされているすべてのUIDを返します。

注:2015年12月の時点で、Gmailは一部のアカウントで「プライマリ」カテゴリと「更新」カテゴリを混同することがよくあります。 これはまだ修正されていないGmailのバグです。

結論

メールが届いています。 それで? PHPでカスタムIMAP電子メールクライアントを構築する方法を読み、条件に基づいてメールを確認してください。
つぶやき

全体として、カスタムソケットアプローチは開発者により多くの自由を提供します。 IMAPRFC3501ですべてのコマンドを実装することが可能になります。 また、「舞台裏」で何が起こっているのか不思議に思う必要がないため、コードをより適切に制御できます。

この記事で実装した完全なimap_driverクラスはここにあります。 そのまま使用でき、開発者が新しい関数を記述したり、IMAPサーバーに要求したりするのに数分しかかかりません。 また、詳細な出力のために、クラスにデバッグ機能を含めました。