У меня PHP IMAP используется для следующих задач:
- Удаление старых писем из почтовых ящиков. К сожалению, в панели управления корпоративным аккаунтом G Suite можно настроить только срок удаления писем из всех почтовых ящиков организации через N дней после получения. Мне же требуется удалять письма только в заданных почтовых ящиках и через заданное разное количество дней после получения.
- Фильтрация, разбор и маркировка писем. С нашего сайта в автоматическом режиме отправляется множество писем, некоторые из которых не доходят до адресатов, о чём, соответственно, приходят отчёты. Нужно эти отчёты отлавливать, разбирать, находить клиента по email и формировать человекочитаемое письмо для менеджера, чтобы тот связался с клиентом и уточнил актуальность адреса электронной почты.
Эти две задачи мы и будем решать при помощи API Gmail в данной статье (а заодно и отключим в настройках почтовых ящиков доступ для небезопасных приложений, который был включён для работы PHP IMAP, и, собственно, перестанет работать в страшный день в феврале 2021). Использовать будем так называемый сервисный аккаунт приложения Gmail, который при соответствующей настройке даёт возможность подключения ко всем почтовым ящикам организации и выполнения в них любых действий.
1. Создаём проект в консоли разработчика Google API
При помощи этого проекта мы и будем осуществлять API-взаимодействие с Gmail, и в нём же создадим тот самый сервисный аккаунт.
Для создания проекта:
- Переходим в консоль разработчика Google API и логинимся по администратором G Suite (ну или кто у Вас там пользователь со всеми правами)
- Ищем кнопку Создать проект.Я нашёл здесь:
И затем здесь:
Заполняем имя проекта и сохраняемСоздание проекта
- Переходим к проекту и нажимаем кнопку Включить API и
сервисыВключить API и сервисы
Выбираем Gmail API
2. Создаём и настраиваем сервисный аккаунт
Для этого можно воспользоваться официальным мануалом или продолжить чтение:
- Переходим в наш добавленный Gmail API, нажимаем кнопку Создать
учётные данные и выбираем Сервисный аккаунтСоздание сервисного аккаунта
Что-нибудь заполняем и нажимаем СоздатьСведения о сервисном аккаунте
Всё остальное можно не заполнятьПрава доступа для сервисного аккаунта
Предоставление пользователям доступа к сервисному аккаунту
- Далее, сервисному аккаунту нужно дать права на чтение или
управление почтовыми ящиками. Для этого переходим в консоль
администрирования G Suite, открываем главное меню и переходим в
пункт Безопасность Управление API.Управление API
- Прокручиваем страницу вниз и выбираем пункт Настроить
делегирование доступа к данным в доменеДелегирование доступа к данным в домене
Нажимаем Добавить, в поле Идентификатор клиента копируем соответствующую строку из карточки сервисного аккаунта, а поле Области действия OAuth вставляем права одно или несколько из следующих значений через запятую:- https://mail.google.com/ - для полного доступа
- https://www.googleapis.com/auth/gmail.modify - для редактирования меток
- https://www.googleapis.com/auth/gmail.readonly - для чтения
- https://www.googleapis.com/auth/gmail.metadata - для доступа к метаданнымСведения о сервисном аккаунте
- Возвращаемся к карточке сервисного аккаунта и включаем ещё одну
разрешающую галку Включить делегирование доступа к данным в домене
G Suite:Статус сервисного аккаунта
А также заполняем название Вашего продукта в поле ниже.
- Теперь нужно создать ключ сервисного аккаунта: это файл,
который должен быть доступен в Вашем приложении. Он, собственно, и
будет использоваться для авторизации.
Для этого со страницы Учётные данные Вашего проекта переходим по ссылке Управление сервисными аккаунтамиУчётные данные
и выбираем Действия Создать ключ, тип: JSONУправление сервисными аккаунтами
После этого будет сформирован и скачан на Ваш компьютер файл ключа, который нужно поместить в свой проект и дать к нему доступ при вызове API Gmail.
На этом настройка API Gmail закончена, далее будет немного моего кака-кода, собственно, реализующего функции, которые до сих пор решались расширением IMAP PHP.
3. Пишем код
По API Gmail есть вполне себе неплохая официальная документация (клик и клик), которой я и пользовался. Но раз уж взялся написать подробный мануал, то приложу и свой собственный кака-код.
Итак, первым делом устанавливаем Google Client Library (apiclient) при помощи composer:
composer require google/apiclient
(Сначала я, как истинный буквоед, установил именно версию 2.0 api-клиента, как указано в PHP Quickstart, но при первом же запуске на PHP 7.4 посыпались всякие ворнинги и алармы, поэтому Вам так же делать не советую)
Затем на основе примеров из официальной документации пишем свой класс для работы с Gmail, не забывая указать файл ключа сервисного аккаунта:
<?php// Класс для работы с Gmailclass GmailAPI{ private $credentials_file = __DIR__ . '/../Gmail/credentials.json'; // Ключ сервисного аккаунта // --------------------------------------------------------------------------------------------- /** * Функция возвращает Google_Service_Gmail Authorized Gmail API instance * * @param string $strEmail Почта пользователя * @return Google_Service_Gmail Authorized Gmail API instance * @throws Exception */ function getService(string $strEmail){ // Подключаемся к почтовому ящику try{ $client = new Google_Client(); $client->setAuthConfig($this->credentials_file); $client->setApplicationName('My Super Project'); $client->setScopes(Google_Service_Gmail::MAIL_GOOGLE_COM); $client->setSubject($strEmail); $service = new Google_Service_Gmail($client); }catch (Exception $e) { throw new \Exception('Исключение в функции getService: '.$e->getMessage()); } return $service; } // --------------------------------------------------------------------------------------------- /** * Функция возвращает массив ID сообщений в ящике пользователя * * @param Google_Service_Gmail $service Authorized Gmail API instance. * @param string $strEmail Почта пользователя * @param array $arrOptionalParams любые дополнительные параметры для выборки писем * Из них мы сделаем стандартную строку поиска в Gmail вида after: 2020/08/20 in:inbox label: * и запишем её в переменную q массива $opt_param * @return array Массив ID писем или массив ошибок array('arrErrors' => $arrErrors), если они есть * @throws Exception */ function listMessageIDs(Google_Service_Gmail $service, string $strEmail, array $arrOptionalParams = array()) { $arrIDs = array(); // Массив ID писем $pageToken = NULL; // Токен страницы в почтовом ящике $messages = array(); // Массив писем в ящике // Параметры выборки $opt_param = array(); // Если параметры выборки есть, делаем из них строку поиска в Gmail и записываем её в переменную q if (count($arrOptionalParams)) $opt_param['q'] = str_replace('=', ':', http_build_query($arrOptionalParams, null, ' ')); // Получаем массив писем, соответствующих условию выборки, со всех страниц почтового ящика do { try { if ($pageToken) { $opt_param['pageToken'] = $pageToken; } $messagesResponse = $service->users_messages->listUsersMessages($strEmail, $opt_param); if ($messagesResponse->getMessages()) { $messages = array_merge($messages, $messagesResponse->getMessages()); $pageToken = $messagesResponse->getNextPageToken(); } } catch (Exception $e) { throw new \Exception('Исключение в функции listMessageIDs: '.$e->getMessage()); } } while ($pageToken); // Получаем массив ID этих писем if (count($messages)) { foreach ($messages as $message) { $arrIDs[] = $message->getId(); } } return $arrIDs; } // --------------------------------------------------------------------------------------------- /** * Удаляем сообщения из массива их ID функцией batchDelete * * @param Google_Service_Gmail $service Authorized Gmail API instance. * @param string $strEmail Почта пользователя * @param array $arrIDs массив ID писем для удаления из функции listMessageIDs * @throws Exception */ function deleteMessages(Google_Service_Gmail $service, string $strEmail, array $arrIDs){ // Разбиваем массив на части по 1000 элементов, так как столько поддерживает метод batchDelete $arrParts = array_chunk($arrIDs, 999); if (count($arrParts)){ foreach ($arrParts as $arrPartIDs){ try{ // Получаем объект запроса удаляемых писем $objBatchDeleteMessages = new Google_Service_Gmail_BatchDeleteMessagesRequest(); // Назначаем удаляемые письма $objBatchDeleteMessages->setIds($arrPartIDs); // Удаляем их $service->users_messages->batchDelete($strEmail,$objBatchDeleteMessages); }catch (Exception $e) { throw new \Exception('Исключение в функции deleteMessages: '.$e->getMessage()); } } } } // --------------------------------------------------------------------------------------------- /** * Получаем содержиме сообщения функцией get * * @param Google_Service_Gmail $service Authorized Gmail API instance. * @param string $strEmail Почта пользователя * @param string $strMessageID ID письма * @param string $strFormat The format to return the message in. * Acceptable values are: * "full": Returns the full email message data with body content parsed in the payload field; the raw field is not used. (default) * "metadata": Returns only email message ID, labels, and email headers. * "minimal": Returns only email message ID and labels; does not return the email headers, body, or payload. * "raw": Returns the full email message data with body content in the raw field as a base64url encoded string; the payload field is not used. * @param array $arrMetadataHeaders When given and format is METADATA, only include headers specified. * @return object Message * @throws Exception */ function getMessage(Google_Service_Gmail $service, string $strEmail, string $strMessageID, string $strFormat = 'full', array $arrMetadataHeaders = array()){ $arrOptionalParams = array( 'format' => $strFormat // Формат, в котором возвращаем письмо ); // Если формат - metadata, перечисляем только нужные нам заголовки if (($strFormat == 'metadata') and count($arrMetadataHeaders)) $arrOptionalParams['metadataHeaders'] = implode(',',$arrMetadataHeaders); try{ $objMessage = $service->users_messages->get($strEmail, $strMessageID,$arrOptionalParams); return $objMessage; }catch (Exception $e) { throw new \Exception('Исключение в функции getMessage: '.$e->getMessage()); } } // --------------------------------------------------------------------------------------------- /** * Выводим список меток, имеющихся в почтовом ящике * * @param Google_Service_Gmail $service Authorized Gmail API instance. * @param string $strEmail Почта пользователя * @return object $objLabels - объект - список меток * @throws Exception */ function listLabels(Google_Service_Gmail $service, string $strEmail){ try{ $objLabels = $service->users_labels->listUsersLabels($strEmail); return $objLabels; }catch (Exception $e) { throw new \Exception('Исключение в функции listLabels: '.$e->getMessage()); } } // --------------------------------------------------------------------------------------------- /** * Добавляем или удаляем метку (флаг) к письму * * @param Google_Service_Gmail $service Authorized Gmail API instance. * @param string $strEmail Почта пользователя * @param string $strMessageID ID письма * @param array $arrAddLabelIds Массив ID меток, которые мы добавляем к письму * @param array $arrRemoveLabelIds Массив ID меток, которые мы удаляем в письме * @return object Message - текущее письмо * @throws Exception */ function modifyLabels(Google_Service_Gmail $service, string $strEmail, string $strMessageID, array $arrAddLabelIds = array(), array $arrRemoveLabelIds = array()){ try{ $objPostBody = new Google_Service_Gmail_ModifyMessageRequest(); $objPostBody->setAddLabelIds($arrAddLabelIds); $objPostBody->setRemoveLabelIds($arrRemoveLabelIds); $objMessage = $service->users_messages->modify($strEmail,$strMessageID,$objPostBody); return $objMessage; }catch (Exception $e) { throw new \Exception('Исключение в функции modifyLabels: '.$e->getMessage()); } } // ---------------------------------------------------------------------------------------------}
При любом взаимодействии с Gmail первым делом мы вызываем функцию getService($strEmail) класса GmailAPI, которая возвращает авторизованный объект для работы с почтовым ящиком $strEmail. Далее этот объект уже передаётся в любую другую функцию для уже непосредственно выполнения нужных нам действий. Все остальные функции в классе GmailAPI уже выполняют конкретные задачи:
- listMessageIDs находит письма по заданным критериям и возвращает их ID (передаваемая в функцию listUsersMessages Gmail API строка поиска писем должна быть аналогична строке поиска в веб-интерфейсе почтового ящика),
- deleteMessages удаляет письма с переданными в неё ID (функция batchDelete API Gmail удаляет не более 1000 писем за один проход, поэтому пришлось разбить массив переданных в функцию ID на несколько массивов по 999 писем и выполнить удаление несколько раз),
- getMessage получает всю информацию о сообщении с переданным в неё ID,
- listLabels возвращает список флагов в почтовом ящике (я использовал её, чтобы получить ID флага, который изначально был создан в веб-интерфейсе ящика, и присваивается нужным сообщениям)
- modifyLabels добавляет или удаляет флаги к сообщению
Далее, у нас есть задача удаления старых писем в различных почтовых ящиках. При этом старыми мы считаем письма, полученные своё количество дней назад для каждого почтового ящика. Для реализации этой задачи пишем следующий скрипт, ежедневно запускаемый cron'ом:
<?php/** * Удаляем письма в почтовых ящиках Gmail * Используем сервисный аккаунт и его ключ */require __DIR__ .'/../general/config/config.php'; // Общий файл конфигурацииrequire __DIR__ .'/../vendor/autoload.php'; // Загрузчик внешних компонент// Задаём количества дней хранения почты в ящиках$arrMailBoxesForClean = array( 'a@domain.com' => 30, 'b@domain.com' => 30, 'c@domain.com' => 7, 'd@domain.com' => 7, 'e@domain.com' => 7, 'f@domain.com' => 1);$arrErrors = array(); // Массив ошибок$objGmailAPI = new GmailAPI(); // Класс для работы с GMail// Проходим по списку почтовых ящиков, из которых нужно удалить старые письмаforeach ($arrMailBoxesForClean as $strEmail => $intDays) { try{ // Подключаемся к почтовому ящику $service = $objGmailAPI->getService($strEmail); // Указываем условие выборки писем в почтовом ящике $arrParams = array('before' => date('Y/m/d', (time() - 60 * 60 * 24 * $intDays))); // Получаем массив писем, подходящих для удаления $arrIDs = $objGmailAPI->listMessageIDs($service,$strEmail,$arrParams); // Удаляем письма по их ID в массиве $arrIDs if (count($arrIDs)) $objGmailAPI->deleteMessages($service,$strEmail,$arrIDs); // Удаляем все использованные переменные unset($service,$arrIDs); }catch (Exception $e) { $arrErrors[] = $e->getMessage(); }}if (count($arrErrors)){ $strTo = 'my_email@domain.com'; $strSubj = 'Ошибка при удалении старых писем из почтовых ящиков'; $strMessage = 'При удалении старых писем из почтовых ящиков возникли следующие ошибки:'. '<ul><li>'.implode('</li><li>',$arrErrors).'</li></ul>'. '<br/>URL: '.filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL); $objMailSender = new mailSender(); $objMailSender->sendMail($strTo,$strSubj,$strMessage);}
Скрипт подключается к каждому заданному почтовому ящику, выбирает старые письма и удаляет их.
Задача формирования отчётов для менеджера о недоставленных письмах на основании автоматических отчётов решается следующим скриптом:
<?php/* * Подключаемся к ящику a@domain.com * Берём с него письма о том, что наши письма не доставлены: отправитель: mailer-daemon@googlemail.com * Проверяем почтовые ящики в этих письмах. Если они есть у клиентов на нашем сайте, отправляем на b@domain.com * письмо об этом */require __DIR__ .'/../general/config/config.php'; // Общий файл конфигурацииrequire __DIR__ .'/../vendor/autoload.php'; // Загрузчик внешних компонент$strEmail = 'a@domain.com';$strLabelID = 'Label_2399611988534712153'; // Флаг reportProcessed - устанавливаем при обработке письма// Параметры выборки$arrParams = array( 'from' => 'mailer-daemon@googlemail.com', // Письма об ошибках приходят с этого адреса 'in' => 'inbox', // Во входящих 'after' => date('Y/m/d', (time() - 60 * 60 * 24)), // За последние сутки 'has' => 'nouserlabels' // Без флага);$arrErrors = array(); // Массив ошибок$objGmailAPI = new GmailAPI(); // Класс для работы с GMail$arrClientEmails = array(); // Массив адресов электронной почты, на которые не удалось отправить сообщениеtry{ // Подключаемся к почтовому ящику $service = $objGmailAPI->getService($strEmail); // Находим в нём отчёты за последние сутки о том, что письма не доставлены $arrIDs = $objGmailAPI->listMessageIDs($service,$strEmail, $arrParams); // Для найденных писем получаем заголовок 'X-Failed-Recipients', в котором содержится адрес, на который пыталось быть отправлено письмо if (count($arrIDs)){ foreach ($arrIDs as $strMessageID){ // Получаем метаданные письма $objMessage = $objGmailAPI->getMessage($service,$strEmail,$strMessageID,'metadata',array('X-Failed-Recipients')); // Заголовки письма $arrHeaders = $objMessage->getPayload()->getHeaders(); // Находим нужный foreach ($arrHeaders as $objMessagePartHeader){ if ($objMessagePartHeader->getName() == 'X-Failed-Recipients'){ $strClientEmail = mb_strtolower(trim($objMessagePartHeader->getValue()), 'UTF-8'); if (!empty($strClientEmail)) { if (!in_array($strClientEmail, $arrClientEmails)) $arrClientEmails[] = $strClientEmail; } // Помечаем письмо флагом reportProcessed, чтобы не выбирать его в следующий раз $objGmailAPI->modifyLabels($service,$strEmail,$strMessageID,array($strLabelID)); } } } } unset($service,$arrIDs,$strMessageID);}catch (Exception $e) { $arrErrors[] = $e->getMessage();}// Если найдены адреса электронной почты, на которые не удалось доставить сообщения, проверяем их в базеif (count($arrClientEmails)) { $objClients = new clients(); // Получаем все email всех клиентов $arrAllClientsEmails = $objClients->getAllEmails(); foreach ($arrClientEmails as $strClientEmail){ $arrUsages = array(); foreach ($arrAllClientsEmails as $arrRow){ if (strpos($arrRow['email'], $strClientEmail) !== false) { $arrUsages[] = 'как основной email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"'; } if (strpos($arrRow['email2'], $strClientEmail) !== false) { $arrUsages[] = 'как дополнительный email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"'; } if (strpos($arrRow['site_user_settings_contact_email'], $strClientEmail) !== false) { $arrUsages[] = 'как контактный email клиентом "<a href="'.MANAGEURL.'?m=admin&sm=clients&edit='.$arrRow['s_users_id'].'">'.$arrRow['name'].'</a>"'; } } $intUsagesCnt = count($arrUsages); if ($intUsagesCnt > 0){ $strMessage = 'Не удалось доставить письмо с сайта по адресу электронной почты <span style="color: #000099;">'.$strClientEmail.'</span><br/> Этот адрес используется'; if ($intUsagesCnt == 1){ $strMessage .= ' '.$arrUsages[0].'<br/>'; }else{ $strMessage .= ':<ul>'; foreach ($arrUsages as $strUsage){ $strMessage .= '<li>'.$strUsage.'</li>'; } $strMessage .= '</ul>'; } $strMessage .= '<br/>Пожалуйста, уточните у клиента актуальность этого адреса электронной почты.<br/><br/> Это письмо было отправлено автоматически, не отвечайте на него'; if (empty($objMailSender)) $objMailSender = new mailSender(); $objMailSender->sendMail('b@domain.com','Проверьте email клиента',$strMessage); } }}if (count($arrErrors)){ $strTo = 'my_email@domain.com'; $strSubj = 'Ошибка при обработке отчётов о недоставленных письмах'; $strMessage = 'При обработке отчётов о недоставленных письмах возникли следующие ошибки:'. '<ul><li>'.implode('</li><li>',$arrErrors).'</li></ul>'. '<br/>URL: '.filter_input(INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL); if (empty($objMailSender)) $objMailSender = new mailSender(); $objMailSender->sendMail($strTo,$strSubj,$strMessage);}
Этот скрипт так же, как и первый, подключается к заданному почтовому ящику, выбирает из него нужные письма (отчёты о недоставленных сообщениях) без флага, находит в письме адрес электронной почты, на которой пыталось быть отправлено письмо и маркирует это письмо флагом Обработано. Затем уже с найденным адресом электронной почты производятся манипуляции, в результате которых формируется человекочитаемое письмо ответственному сотруднику.
Исходники доступны на GitHub.
Вот и всё, что я хотел поведать в этой статье. Спасибо за прочтение! Если у Вас защипало в глазах от моего кода, просто сверните спойлер или напишите свои замечания буду рад конструктивной критике.