Создание почтового клиента IMAP с PHP
Опубликовано: 2022-03-11Разработчики иногда сталкиваются с задачами, требующими доступа к почтовым ящикам электронной почты. В большинстве случаев это делается с помощью протокола доступа к сообщениям в Интернете или IMAP. Как разработчик PHP, я сначала обратился к встроенной в PHP библиотеке IMAP, но эта библиотека содержит ошибки и ее невозможно отлаживать или модифицировать. Также невозможно настроить команды IMAP, чтобы в полной мере использовать возможности протокола.
Итак, сегодня мы создадим работающий почтовый клиент IMAP с нуля, используя PHP. Мы также увидим, как использовать специальные команды Gmail.
Мы реализуем IMAP в пользовательском классе imap_driver
. Я объясню каждый шаг при построении класса. Вы можете скачать весь imap_driver.php
в конце статьи.
Установление соединения
IMAP — это протокол, основанный на соединении, и обычно он работает через TCP/IP с защитой SSL, поэтому, прежде чем мы сможем совершать какие-либо вызовы IMAP, мы должны открыть соединение.
Нам нужно знать URL-адрес и номер порта сервера IMAP, к которому мы хотим подключиться. Эта информация обычно рекламируется на веб-сайте службы или в документации. Например, для Gmail URL-адрес ssl://imap.gmail.com
на порту 993.
Поскольку мы хотим знать, прошла ли инициализация успешно, мы оставим конструктор нашего класса пустым, а все соединения будут выполняться в пользовательском методе 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.
Официальную документацию можно найти в документе RFC3501 инженерной группы Интернета (IETF). Взаимодействия 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, чтобы сделать что-то полезное с нашей электронной почтой. Синтаксис похож на LOGIN
благодаря нашему методу command()
. Вместо этого мы используем команду 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
в качестве аргумента с пробелом в качестве разделителя. Например, если мы хотим получить все электронные письма с 20 ноября 2015 года, мы должны передать следующую команду:
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; }
Команда FETCH
с BODY.PEEK
Другая распространенная задача — получить заголовки электронной почты, не помечая письмо как 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; }
IMAP-расширения Gmail
Gmail предоставляет список специальных команд, которые могут значительно облегчить нашу жизнь. Список команд расширения IMAP Gmail доступен здесь. Давайте рассмотрим команду, которая, на мой взгляд, является самой важной: 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 г. Gmail часто путает категорию «Основные» с категорией «Обновления» в некоторых учетных записях. Это ошибка Gmail, которая еще не исправлена.
Заключение
В целом, индивидуальный подход к сокетам предоставляет разработчику больше свободы. Это позволяет реализовать все команды в IMAP RFC3501. Это также даст вам лучший контроль над вашим кодом, поскольку вам не придется задаваться вопросом, что происходит «за кулисами».
Полный класс imap_driver
, который мы реализовали в этой статье, можно найти здесь. Его можно использовать как есть, и разработчику потребуется всего несколько минут, чтобы написать новую функцию или запрос к своему IMAP-серверу. Я также включил в класс функцию отладки для подробного вывода.