Работа с Gmail используя PHP

в 8:56, , рубрики: gmail api, php, Zend Framework, Веб-разработка, Песочница, метки: , , ,

Доброго времени суток, коллеги. В этой статье я расскажу об опыте использовании Gmail API. Как оказалось, данная тема не очень освещена в интернете, да и документация далека от идеала.

Недавно у меня появилась задача: написать PHP приложение для поиска сообщений на Gmail ящике пользователя. Притом не просто поиск, а поиск по параметрам, благо Gmail имеет неплохую строку поиска, позволяющую написать что то вида “is:sent after:2012/08/10”. Да и в API есть расширения IMAP протокола X-GM-*

Итак, нам требуется реализовать интерфейс для авторизации пользователей и поиска сообщений. Для данных целей я использовал Zend Framework, так как проект написан на Zend Framework, да и Google рекомендует его использовать для работы с API.

Обрисуем интерфейс:

class Model_OAuth_Gmail {
    
    // авторизуемся используя OAuth
    public function Connect( $callback );

    // получаем соединение используя Access Token ( выдан нам при подключении )
    public function getConnection($accessToken);
    
// типы ответа для метода поиска
    const MODE_NONE = 0;
    const MODE_MESSAGES = 1;
    const MODE_THREAD = 2;
// поиск сообщений: используя соединение( от getConnection ), параметры и тип ответа
    public function searchMessages($imapConnection, $params, $mode = 0);
}

Что делает каждый метод я написал в комментариях.
Примечание: да я знаю что такое синглтон и что этот класс стоит так реализовать, но суть не в этом!

Итак, начнем:

Connect

    public function Connect( $callback ) {
        $this -> urls['callbackUrl'] = $callback;
        $session = new Zend_Session_Namespace('OAuth');

        $OAuth_Consumer = new Zend_Oauth_Consumer(array_merge($this->config, $this->urls));

        try {
            if (!isset($session -> accessToken)) {
                if (!isset($session -> requestToken)) {
                    $session -> requestToken = $OAuth_Consumer -> getRequestToken(array('scope' => $this -> scopes), "GET");
                    $OAuth_Consumer -> redirect();
                } else {
                    $session -> accessToken = $OAuth_Consumer -> getAccessToken($_GET, $session -> requestToken);
                }
            }
            $accessToken = $session -> accessToken;

            $session -> unsetAll();
            unset($session);
            return $accessToken;
        } catch( exception $e) {
            $session -> unsetAll();
            throw new Zend_Exception("Error occurred. try to reload this page", 5);
        }
    }

Все довольно просто: Запускаем сессию, перекидываем на Google для нажатия кнопки Grant access и получаем Access Token, с помощью переданного нам Request Token’а

Главное не забыть сделать блок try-catch, т.к. если, к примеру, пользователь нажмёт назад, то больше, пока сессия не будет очищена, он авторизоваться не сможет (Request Token сохраняется на первом шаге)!

Ну и чуть не забыл конфиги:

    protected $config = array(
    'requestScheme' => Zend_Oauth::REQUEST_SCHEME_HEADER,
    'version' => '1.0',
    'consumerKey' => 'anonymous',
    'signatureMethod' => 'HMAC-SHA1',
    'consumerSecret' => 'anonymous',
    );

    protected $urls = array('callbackUrl' => "",
    'requestTokenUrl' => 'https://www.google.com/accounts/OAuthGetRequestToken',
    'userAuthorizationUrl' => 'https://www.google.com/accounts/OAuthAuthorizeToken',
    'accessTokenUrl' => 'https://www.google.com/accounts/OAuthGetAccessToken'
    );

    protected $scopes = 'https://mail.google.com/ https://www.googleapis.com/auth/userinfo#email';

getConnection

    public function getConnection($accessToken) {

        $config = new Zend_Oauth_Config();
        $config -> setOptions($this::config);
        $config -> setToken(unserialize($user::accessToken));
        $config -> setRequestMethod('GET');
        $url = 'https://mail.google.com/mail/b/' . $user -> email . '/imap/';
        $urlWithXoauth = $url . '?xoauth_requestor_id=' . urlencode($user -> email);

        $httpUtility = new Zend_Oauth_Http_Utility();

        /**
         * Get an unsorted array of oauth params,
         * including the signature based off those params.
         */
        $params = $httpUtility -> assembleParams($url, $config, array('xoauth_requestor_id' => $user -> email));

        /**
         * Sort parameters based on their names, as required
         * by OAuth.
         */
        ksort($params);

        /**
         * Construct a comma-deliminated,ordered,quoted list of
         * OAuth params as required by XOAUTH.
         *
         * Example: oauth_param1="foo",oauth_param2="bar"
         */
        $first = true;
        $oauthParams = '';
        foreach ($params as $key => $value) {
            // only include standard oauth params
            if (strpos($key, 'oauth_') === 0) {
                if (!$first) {
                    $oauthParams .= ',';
                }
                $oauthParams .= $key . '="' . urlencode($value) . '"';
                $first = false;
            }
        }

        /**
         * Generate SASL client request, using base64 encoded
         * OAuth params
         */
        $initClientRequest = 'GET ' . $urlWithXoauth . ' ' . $oauthParams;
        $initClientRequestEncoded = base64_encode($initClientRequest);

        /**
         * Make the IMAP connection and send the auth request
         */
        $imap = new Zend_Mail_Protocol_Imap('imap.gmail.com', '993', true);
        $authenticateParams = array('XOAUTH', $initClientRequestEncoded);
        $imap -> requestAndResponse('AUTHENTICATE', $authenticateParams);

        return $imap;
    }

Этот метод есть в примере использования у Google, он документирован и работает «как есть». К тому же он довольно простой.

Ну и переходим к самому интересному:

searchMessages

Вначале алгоритм действий:

  1. Выстраиваем на основе параметров строку поиска
  2. Находим ID сообщений удовлетворяющих условиям
  3. Преобразуем их в зависимости от $mode
  4. PROFIT! :)
Пункт 1:

        $searchString = 'X-GM-RAW "';

        foreach ($params as $key => $value)
            switch ($key) {
                // this is dates
                case "before" :
                case "after" :
                    $searchString .= $key . ":" . date("Y/m/d", $value) . " ";
                    break;
                
                // this is simple strings
                default :
                    $searchString .= $key . ":" . $value . " ";
                    break;
            }

        $searchString = trim($searchString) . '"';

Просто проходим по массиву с параметрами и преобразуем их в строку. Исключения составляют лишь даты, которые мы будем преобразовывать сами.

Пункт 2:

        $messages = $imapConnection -> search(array($searchString));

Просто, правда? Но как оказалось это решение не работает вообще. Сервер выдаст ошибку, т.к. мы не выполнили команду EXAMINE “INBOX”. Ну ладно:

    if (isset($params['in'])){
        $imapConnection->examine(strtoupper(($params['in'])));
    } else {
        $imapConnection->examine("INBOX");
    }
    $messages = $imapConnection -> search(array($searchString));

Это решение уже работает, и почти правильно работает. Но, как только придется искать в исходящих(in:sent), мы получим неверный ответ. Я потратил много времени копаясь с этой проблемой, и ответ был найден.

Оказалось что у Gmail папки называются не SENT, INBOX, ..., а имеют названия зависящие от локали (оО). Пришлось сделать простой метод преобразования названий папок:

    protected function getFolder($imap, $folder) {
        $response = $imap -> requestAndResponse('XLIST "" "*"');
        $folders = array();
        foreach ($response AS $item) {
            if ($item[0] != "XLIST") {
                continue;
            }
            $folders[strtoupper(str_replace('\', '', end($item[1])))] = $item[3];
        }
        return $folders[$folder];
    }

Просто узнаем список папок и найдем нужную. Но на этом, как оказалось, не все. EXAMINE от проблемы все равно не спасает, а вызывать нужно метод select для выбора папки перед поиском.

        if (isset($params['in']))
            $imapConnection -> select($this -> getFolder($imapConnection, strtoupper($params['in'])));
        
        $messages = $imapConnection -> search(array($searchString));

Теперь у нас есть ID найденых сообщений, дело за малым – преобразовать к виду сообщений.

        switch ( $mode ) {
            case $this::MODE_NONE :
                return $messages;

            case $this::MODE_MESSAGES :
                // fetching (get content of messages)
                $messages = $imapConnection -> requestAndResponse("FETCH " . implode(',', $messages) . " (X-GM-THRID)");
                return $messages;
            case $this::MODE_THREAD :
                $messages = $imapConnection -> requestAndResponse("FETCH " . implode(',', $messages) . " (X-GM-THRID)");
                $storage = new Zend_Mail_Storage_Imap($imapConnection);
                $storage -> selectFolder( $this -> getFolder($imapConnection, strtoupper($params['in'])) );
                $threads = array();
                if ($messages)
                    foreach ($messages AS $message) {
                        if (isset($message[2][1])) {
                            $thread_id = $message[2][1];
                            if (!isset($threads[$thread_id])) {
                                $threads[$thread_id] = array('all' => $imapConnection -> requestAndResponse("SEARCH X-GM-THRID $thread_id"), 'my' => array());
                                unset($threads[$thread_id]['all'][0][0]);
                            }

                            $threads[$thread_id]['my'][] = $message[0];
                        }
                    }

                $result = array();
                foreach ($threads as $thread)
                    if (!array_slice($thread['all'], array_search(max($thread['my']), $thread['all']) + 1))
                        $result[$storage -> getUniqueId(max($thread['my']))] = $storage -> getMessage(max($thread['my']));

                return array_reverse($result);
            // for right order
        }

В 1ом случае так и вернем массив идентификаторов, во втором получим сами сообщения, но самый интересный 3ий случай.

Здесь мы используем Zend_Mail_Storage_Imap для получения сообщений в виде Zend_Mail_Message.

Не стоит забывать что Zend_Mail_Storage_Imap ничего не знает о выбранной нам папке(у нас стала другая нумерация сообщений), по этому не забудем вызвать метод selectFolder.

Процесс преобразования простой: получим тред сообщения, преобразуем к виду: [все сообщения, мои сообщения]. Дальше выбираем последнее сообщение треда и формируем результат.

Также не забудем что результат нужно перевернуть, т.к. нумерация на сервере идет от старых к новым, ну а мы привыкли наоборот.

Вот и все! Спасибо всем за внимание. Надеюсь, что статья окажется вам полезной.

Автор: mixkorshun

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js