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

от автора

Оказавшись одним из счастливчиков, совершенно не готовым к тому, что с 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.

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

ссылка на оригинал статьи https://habr.com/ru/post/516408/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *