使用 PHP 構建 IMAP 電子郵件客戶端

已發表: 2022-03-11

開發人員有時會遇到需要訪問電子郵件郵箱的任務。 在大多數情況下,這是使用 Internet 消息訪問協議或 IMAP 完成的。 作為一名 PHP 開發人員,我首先求助於 PHP 內置的 IMAP 庫,但這個庫有 bug,無法調試或修改。 也無法自定義 IMAP 命令以充分利用協議的功能。

所以今天,我們將使用 PHP 從頭開始創建一個有效的 IMAP 電子郵件客戶端。 我們還將了解如何使用 Gmail 的特殊命令。

我們將在自定義類imap_driver中實現 IMAP。 我將在構建課程時解釋每個步驟。 您可以在文末下載整個imap_driver.php

建立連接

IMAP 是一種基於連接的協議,通常在具有 SSL 安全性的 TCP/IP 上運行,因此在進行任何 IMAP 調用之前,我們必須打開連接。

我們需要知道要連接的 IMAP 服務器的 URL 和端口號。 此信息通常在服務的網站或文檔中進行宣傳。 例如,對於 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); } ... }

在上面的代碼中,我設置了 15 秒的超時時間,既用於fsockopen()建立連接,也用於數據流本身在打開後響應請求。 對網絡的每次調用都有一個超時是很重要的,因為服務器通常不會響應,我們必須能夠處理這樣的凍結。

我還抓住了流的第一行並忽略它。 通常這只是來自服務器的問候消息,或確認已連接。 檢查您的特定郵件服務的文檔以確保是這種情況。

現在我們要運行上面的代碼,看看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 語法。

可以在 Internet 工程任務組 (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; } } ... }

為了測試它,讓我們嘗試選擇收件箱:

 ... // 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; } } ... }

為了測試這個命令,我們將收到過去三天的電子郵件:

 ... // 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.PEEKFETCH命令

另一個常見的任務是在不將電子郵件標記為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; }

Gmail IMAP 擴展程序

Gmail 提供了一系列特殊命令,可以讓我們的生活更輕鬆。 此處提供了 Gmail 的 IMAP 擴展命令列表。 讓我們回顧一下我認為最重要的命令: X-GM-RAW 。 它允許我們將 Gmail 搜索語法與 IMAP 一起使用。 例如,我們可以搜索屬於主要、社交、促銷、更新或論壇類別的電子郵件。

從功能上講, 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 電子郵件客戶端,並根據您的條件檢查郵件。
鳴叫

總體而言,自定義套接字方法為開發人員提供了更多的自由。 它可以實現 IMAP RFC3501 中的所有命令。 它還可以讓您更好地控制代碼,因為您不必懷疑“幕後”發生了什麼。

我們在本文中實現的完整imap_driver類可以在這裡找到。 它可以按原樣使用,開發人員只需幾分鐘即可向其 IMAP 服務器編寫新功能或請求。 我還在該類中包含了一個用於詳細輸出的調試功能。