[Разработка веб-сайтов, PHP, Программирование, Проектирование и рефакторинг, Google API] Настройка Gmail API для замены расширения PHP IMAP и работы по протоколу OAuth2

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
25-Авг-2020 14:34

Оказавшись одним из счастливчиков, совершенно не готовым к тому, что с 15 февраля 2021 года авторизация в Gmail и других продуктах будет работать только через OAuth, я прочитал статью "Google хоронит расширение PHP IMAP" и загрустил начал предпринимать действия по замене расширения PHP IMAP в своём проекте на API Google. Вопросов было больше, чем ответов, поэтому заодно нацарапал мануал.
У меня PHP IMAP используется для следующих задач:
  • Удаление старых писем из почтовых ящиков. К сожалению, в панели управления корпоративным аккаунтом G Suite можно настроить только срок удаления писем из всех почтовых ящиков организации через N дней после получения. Мне же требуется удалять письма только в заданных почтовых ящиках и через заданное разное количество дней после получения.
  • Фильтрация, разбор и маркировка писем. С нашего сайта в автоматическом режиме отправляется множество писем, некоторые из которых не доходят до адресатов, о чём, соответственно, приходят отчёты. Нужно эти отчёты отлавливать, разбирать, находить клиента по email и формировать человекочитаемое письмо для менеджера, чтобы тот связался с клиентом и уточнил актуальность адреса электронной почты.

Эти две задачи мы и будем решать при помощи API Gmail в данной статье (а заодно и отключим в настройках почтовых ящиков доступ для небезопасных приложений, который был включён для работы PHP IMAP, и, собственно, перестанет работать в страшный день в феврале 2021). Использовать будем так называемый сервисный аккаунт приложения Gmail, который при соответствующей настройке даёт возможность подключения ко всем почтовым ящикам организации и выполнения в них любых действий.
1. Создаём проект в консоли разработчика Google API
При помощи этого проекта мы и будем осуществлять API-взаимодействие с Gmail, и в нём же создадим тот самый сервисный аккаунт.
Для создания проекта:
  • Переходим в консоль разработчика Google API и логинимся по администратором G Suite (ну или кто у Вас там пользователь со всеми правами)
  • Ищем кнопку «Создать проект».

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

    SPL


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

    SPL


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

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

    SPL


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

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

      SPL


      Выбираем Gmail API

    2. Создаём и настраиваем сервисный аккаунт
    Для этого можно воспользоваться официальным мануалом или продолжить чтение:
    • Переходим в наш добавленный Gmail API, нажимаем кнопку «Создать учётные данные» и выбираем «Сервисный аккаунт»

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

      SPL


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

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

      SPL


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

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

      SPL


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

      SPL


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

        Управление API

        SPL



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

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

          SPL


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

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

          SPL



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

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

            SPL


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

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

              SPL


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

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

              SPL


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

            SPL
            <?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'ом:

            Удаление старых писем

            SPL
            <?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);
            }


            Скрипт подключается к каждому заданному почтовому ящику, выбирает старые письма и удаляет их.
            Задача формирования отчётов для менеджера о недоставленных письмах на основании автоматических отчётов решается следующим скриптом:

            Фильтрация и маркировка писем

            SPL
            <?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.
            Вот и всё, что я хотел поведать в этой статье. Спасибо за прочтение! Если у Вас защипало в глазах от моего кода, просто сверните спойлер или напишите свои замечания — буду рад конструктивной критике.
            ===========
            Источник:
            habr.com
            ===========

            Похожие новости: Теги для поиска: #_razrabotka_vebsajtov (Разработка веб-сайтов), #_php, #_programmirovanie (Программирование), #_proektirovanie_i_refaktoring (Проектирование и рефакторинг), #_google_api, #_imap, #_php, #_oauth2, #_gmail_api, #_razrabotka_vebsajtov (
            Разработка веб-сайтов
            )
            , #_php, #_programmirovanie (
            Программирование
            )
            , #_proektirovanie_i_refaktoring (
            Проектирование и рефакторинг
            )
            , #_google_api
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 20:18
Часовой пояс: UTC + 5