使用 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.PEEK
的FETCH
命令
另一个常见的任务是在不将电子邮件标记为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-RAW
是SEARCH
命令的扩展,因此我们可以将上面的代码重用于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 错误。
结论
总体而言,自定义套接字方法为开发人员提供了更多的自由。 它可以实现 IMAP RFC3501 中的所有命令。 它还可以让您更好地控制代码,因为您不必怀疑“幕后”发生了什么。
我们在本文中实现的完整imap_driver
类可以在这里找到。 它可以按原样使用,开发人员只需几分钟即可向其 IMAP 服务器编写新功能或请求。 我还在该类中包含了一个用于详细输出的调试功能。