Настройка Gmail API для замены расширения PHP IMAP и работы по протоколу OAuth2

  • Tutorial
Оказавшись одним из счастливчиков, совершенно не готовым к тому, что с 15 февраля 2021 года авторизация в Gmail и других продуктах будет работать только через OAuth, я прочитал статью "Google хоронит расширение PHP IMAP" и загрустил начал предпринимать действия по замене расширения PHP IMAP в своём проекте на API Google. Вопросов было больше, чем ответов, поэтому заодно нацарапал мануал.

У меня PHP IMAP используется для следующих задач:

  1. Удаление старых писем из почтовых ящиков. К сожалению, в панели управления корпоративным аккаунтом G Suite можно настроить только срок удаления писем из всех почтовых ящиков организации через N дней после получения. Мне же требуется удалять письма только в заданных почтовых ящиках и через заданное разное количество дней после получения.
  2. Фильтрация, разбор и маркировка писем. С нашего сайта в автоматическом режиме отправляется множество писем, некоторые из которых не доходят до адресатов, о чём, соответственно, приходят отчёты. Нужно эти отчёты отлавливать, разбирать, находить клиента по email и формировать человекочитаемое письмо для менеджера, чтобы тот связался с клиентом и уточнил актуальность адреса электронной почты.

Эти две задачи мы и будем решать при помощи API Gmail в данной статье (а заодно и отключим в настройках почтовых ящиков доступ для небезопасных приложений, который был включён для работы PHP IMAP, и, собственно, перестанет работать в страшный день в феврале 2021). Использовать будем так называемый сервисный аккаунт приложения Gmail, который при соответствующей настройке даёт возможность подключения ко всем почтовым ящикам организации и выполнения в них любых действий.

1. Создаём проект в консоли разработчика Google API


При помощи этого проекта мы и будем осуществлять API-взаимодействие с Gmail, и в нём же создадим тот самый сервисный аккаунт.

Для создания проекта:

  1. Переходим в консоль разработчика Google API и логинимся по администратором G Suite (ну или кто у Вас там пользователь со всеми правами)
  2. Ищем кнопку «Создать проект».

    Я нашёл здесь:
    image

    И затем здесь:
    image

    Заполняем имя проекта и сохраняем:

    Создание проекта
    image

  3. Переходим к проекту и нажимаем кнопку «Включить API и сервисы»:

    Включить API и сервисы
    image

    Выбираем Gmail API

2. Создаём и настраиваем сервисный аккаунт


Для этого можно воспользоваться официальным мануалом или продолжить чтение:

  1. Переходим в наш добавленный Gmail API, нажимаем кнопку «Создать учётные данные» и выбираем «Сервисный аккаунт»:

    Создание сервисного аккаунта
    image

    Что-нибудь заполняем и нажимаем «Создать»:

    Сведения о сервисном аккаунте
    image

    Всё остальное можно не заполнять:

    Права доступа для сервисного аккаунта
    image

    Предоставление пользователям доступа к сервисному аккаунту
    image

  2. Далее, сервисному аккаунту нужно дать права на чтение или управление почтовыми ящиками. Для этого переходим в консоль администрирования G Suite, открываем главное меню и переходим в пункт «Безопасность — Управление API».

    Управление API
    image
    image

  3. Прокручиваем страницу вниз и выбираем пункт «Настроить делегирование доступа к данным в домене»:

    Делегирование доступа к данным в домене
    image

    Нажимаем «Добавить», в поле «Идентификатор клиента» копируем соответствующую строку из карточки сервисного аккаунта, а поле «Области действия 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 - для доступа к метаданным


    Сведения о сервисном аккаунте
    image
    image

  4. Возвращаемся к карточке сервисного аккаунта и включаем ещё одну разрешающую галку «Включить делегирование доступа к данным в домене G Suite»:

    Статус сервисного аккаунта
    image

    А также заполняем название Вашего продукта в поле ниже.
  5. Теперь нужно создать ключ сервисного аккаунта: это файл, который должен быть доступен в Вашем приложении. Он, собственно, и будет использоваться для авторизации.

    Для этого со страницы «Учётные данные» Вашего проекта переходим по ссылке «Управление сервисными аккаунтами»:

    Учётные данные
    image

    и выбираем «Действия — Создать ключ», тип: JSON:

    Управление сервисными аккаунтами
    image

    После этого будет сформирован и скачан на Ваш компьютер файл ключа, который нужно поместить в свой проект и дать к нему доступ при вызове 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, не забывая указать файл ключа сервисного аккаунта:

Класс для работы с Gmail
<?php
// Класс для работы с Gmail
class 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.

Вот и всё, что я хотел поведать в этой статье. Спасибо за прочтение! Если у Вас защипало в глазах от моего кода, просто сверните спойлер или напишите свои замечания — буду рад конструктивной критике.

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое