个性化阅读
专注于IT技术分析

使用PHP构建IMAP电子邮件客户端

本文概述

开发人员有时会遇到需要访问电子邮件邮箱的任务。在大多数情况下, 这是使用Internet消息访问协议或IMAP完成的。作为PHP开发人员, 我首先转向PHP的内置IMAP库, 但是该库存在错误, 无法调试或修改。也无法自定义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)

状态可以是”正常”(表示成功), “否”(表示失败)或”错误”(表示命令无效或语法错误)。

实施基本命令

让我们做一个函数, 将命令发送到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对安全性非常严格:如果我们使用默认设置, 则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('j-M-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: <[email protected]om>
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('~^([a-z][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错误。

总结

你有邮件。怎么办?阅读如何使用PHP构建自定义IMAP电子邮件客户端, 并按照你的条款检查邮件。

鸣叫

总体而言, 自定义套接字方法为开发人员提供了更多自由。这样就可以在IMAP RFC3501中实现所有命令。它还使你可以更好地控制代码, 因为你不必怀疑”幕后”发生了什么。

在本文中可以找到我们实现的完整imap_driver类。它可以原样使用, 开发人员只需几分钟即可向其IMAP服务器写入新功能或请求。我还在类中包括了调试功能, 用于输出详细信息。

赞(0)
未经允许不得转载:srcmini » 使用PHP构建IMAP电子邮件客户端

评论 抢沙发

评论前必须登录!