Search code examples
google-apps-scriptgoogle-drive-apigmail-api

How to upload xlsx attachments from Gmail to Gdrive using Gmail Api and Gdrive Api V3


I know how to get attachments in google spreadsheet format using GmailApp, but this method is very slow and not suitable when there are a lot of files.

I am trying to get attachment files from mail using GmailApi V1 and GoogleDriveApi V3, but I am facing some problems:

configObject

// Объект конфигурации для управления параметрами поиска файлов Excel
const config = {
  folderId: '1cZeymn1OUhew0xc3uO_MeS4aDTH3gxXp', // ID папки на Google Drive для поиска файлов Excel
  query: 'is:unread has:attachment', // запрос для поиска непрочитанных сообщений с вложениями
  mimeTypes: {
    excel: [
      MimeType.MICROSOFT_EXCEL, // стандартный MIME-тип для Excel файлов
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' // MIME-тип для современных Excel файлов (.xlsx)
    ],
    googleSheets: MimeType.GOOGLE_SHEETS // MIME-тип для Google Таблиц
  }
};

1. Sample1

    const saveGmailAttachmentsToDrive = () => {

    // Включите Gmail API и Drive API в разделе Google Cloud Platform API Dashboard для вашего проекта
    const response = Gmail.Users.Messages.list('me', { q: 'is:unread has:attachment ' });

    // Проверяем наличие сообщений
     if (response && response.messages && response.messages.length > 0) {
      response.messages.forEach((messageWrapper) => {
      const messageId = messageWrapper.id;
      const message = Gmail.Users.Messages.get('me', messageId, { format: 'full' });

    // Проверяем наличие вложений
    if (message.payload && message.payload.parts) {
    message.payload.parts.forEach((part) => {
      // Ищем части с 'body.attachmentId' (что указывает на вложение)

      if (part.body && part.body.attachmentId) {
        const attachment = Gmail.Users.Messages.Attachments.get('me', messageId, part.body.attachmentId)
        const content = attachment.data.map(code => String.fromCharCode(code)).join('');
        console.log(content);

        // Создаем Blob из данных вложения
        const blob = Utilities.newBlob(content, part.mimeType, part.filename);

        const fileMetdata = {
          name: part.filename,
          parents: [config.folderId]
        };

        const response = Drive.Files.create(fileMetdata, blob);
        console.log(response);
      }
    });
    }
    });
    }

The current script retrieves attachments using the forEach method. The attachment data retrieved from the JSON response is as follows:

 [80,75,3,4,20,0,8,8,8,0,115,80,-107,88,0,0,0,0,0,0,0,0,0,0,0,0,24,0,0,0,120,108,47,100,114,97,119,105,110,103,115,47,100,114,97,119,105,110,103,49,46,120,109,108,-99,-48,93,110,-62,48,12,7,-16,19,-20,14,85,-34,105,90,24,19,67,20,94,-48,78,48,14,-32,37,110,27,-111,-113,-54,14,-93,-36,126,-47,74,54,105,123,1,30,109,-53,63,-7,-17,-51,110,116,-74,-8,68,98,19,124,35,-22,-78,18,]

Although when I get attachment data using API Explorer I get data in base64 format

Then I only managed to save the file in xlsx format, but when I open it I get an error, I try to convert it to google spreadsheet and I also get an error this file format cannot be converted to google spreadsheet.

2. Sample2

This code works fine, but it will take a lot of time if there are a lot of attachments and emails.

/**
 * Обработка вложений из Gmail и конвертация Excel файлов в Google Sheets.
 * @return {object[]} Массив объектов с ID сконвертированных таблиц.
 */
const convertToGoogleSpreadsheet = () => {
  // Получаем список всех непрочитанных сообщений с вложениями в Gmail
  const threads = GmailApp.search(config.query);
  const messages = threads.flatMap(thread => GmailApp.getMessagesForThreads([thread]));
  const convertedSheetIds = []; // Инициализируем массив для хранения ID конвертированных таблиц

  // Перебираем каждое сообщение для обработки вложений
  messages.flat().forEach(message => {
    const attachments = message.getAttachments();
    // Перебираем вложения в каждом сообщении
    attachments.forEach(attachment => {
      // Проверяем MIME-тип вложения, чтобы убедиться, что он соответствует формату Excel
      if (config.mimeTypes.excel.includes(attachment.getContentType())) {
        try {
          // Создание объекта для дальнейшей вставки файла на Google Drive
          const resource = {
            name: attachment.getName(),
            mimeType: config.mimeTypes.googleSheets,
            parents: [config.folderId],
          };

          // Конвертируем вложение в Google Sheets с помощью API Google Drive
          const blob = attachment.getAs(config.mimeTypes.excel[0]);
          const file = Drive.Files.create(resource, blob, { convert: true });

          // Логируем информацию о сохраненном файле
          console.log(`Файл ${attachment.getName()} сохранен с ID: ${file.id}`);
          // Добавляем ID сконвертированной таблицы в массив
          convertedSheetIds.push({ id: file.id });
        } catch (error) {
          // Логирование ошибок при конвертации и сохранении файла
          logError('convertToGoogleSpreadsheet', error);
        }
      }
    });
  });
}

Solution

  • I found the following solution for myself:

    1. I used asynchronous execution of functions.
    2. I used UrlFetchApp.fetchAll method and multipart download.

    Here is my current code which works very fast and reliable:

    /**
     * Извлекает адрес электронной почты отправителя из сообщения.
     * @param {string} messageId - Идентификатор сообщения Gmail.
     * @return {string|null} Адрес электронной почты отправителя или null, если он не найден.
     */
    const getSenderEmail = (message) => {
      // Извлечение заголовка 'From' (отправитель письма)
      const headers = message.payload.headers;
      const fromHeader = headers.find(header => header.name.toLowerCase() === 'from');
    
      // Если заголовок 'From' найден, возвращаем его значение, иначе - null
      return fromHeader ? fromHeader.value.replace(/["<>]/g, '') : null;
    }
    
    /**
     * Выполняет операцию обработки всех тредов с непрочитанными сообщениями, содержащими вложения
     * соответствующего MIME типа, и возвращает информацию для пакетной загрузки.
     *
     * @return {Object} Объект с информацией о вложениях для пакетной загрузки.
     */
    const processUnreadMessagesWithAttachments = async (scriptProperties) => {
      let attachmentsInfo = [];
      try {
        const threadsResponse = await Gmail.Users.Threads.list('me', { q: config.query });
        const threads = threadsResponse.threads || [];
    
        if (threads) {
          const threadIds = threads.map(thread => thread.id);
    
          // Сохраняем идентификаторы тредов в свойствах скрипта
          scriptProperties.setProperties({ 'unreadThreadIds': JSON.stringify(threadIds) });
    
    
          for (const thread of threads) {
            const threadAttachments = await processThreadAttachments(thread.id);
            attachmentsInfo = attachmentsInfo.concat(threadAttachments);
          }
        }
        return attachmentsInfo;
      } catch (error) {
        logError('processUnreadMessagesWithAttachments', error);
      }
    };
    
    /**
     * Обрабатывает вложения в заданном треде электронной почты и возвращает массив объектов с идентификаторами вложений и метаданными.
     *
     * @param {string} threadId - ID треда, вложения которого необходимо обработать.
     * @return {Array<Object>} Массив объектов для каждого вложения с его ID и метаданными.
     */
    const processThreadAttachments = async (threadId) => {
      try {
        const threadData = Gmail.Users.Threads.get('me', threadId);
        return threadData.messages.flatMap(message => {
          return (message.payload.parts || []).flatMap(part => {
            if (part.body.attachmentId && config.mimeTypes.excel.includes(part.mimeType)) {
              return {
                attachmentId: part.body.attachmentId,
                filename: part.filename,
                mimeType: part.mimeType,
                messageId: message.id,
                threadId: threadId,
                sender: getSenderEmail(message) // получаем отправителей 
              }
            }
            return [];
          }).filter(Boolean) // Убираем undefined значения
        });
      } catch (error) {
        logError('processThreadAttachments', error);
      }
    };
    
    /**
     * Инициирует процесс пакетной загрузки файлов.
     * Эта функция является входной точкой для начала загрузки файлов на Google Drive на основе вложений 
     * в непрочитанных сообщениях Gmail, соответствующих определенным MIME-типам.
     */
    const startBatchUpload = async () => {
      // Получаем объект для работы со свойствами скрипта, где будем хранить данные между выполнениями.
      const scriptProperties = PropertiesService.getScriptProperties();
    
      // Получаем OAuth 2.0 токен для текущего пользователя, что позволит делать авторизованные запросы к Google API.
      const accessToken = ScriptApp.getOAuthToken();
    
      // Получаем список вложений, которые нужно загрузить. Эта функция предполагается быть реализованной 
      // и должна обрабатывать все непрочитанные сообщения в Gmail, извлекая нужные вложения.
      const attachmentsInfo = await processUnreadMessagesWithAttachments(scriptProperties);
    
      if (attachmentsInfo.length == 0) {
        console.log(`Нет новых закзаов`);
        retutn;
      }
      // Сохраняем необходимую информацию для процесса загрузки в свойства скрипта.
      // Это позволяет удерживать состояние между выполнениями функции, особенно полезно при использовании триггеров.
      scriptProperties.setProperties({
        'attachmentsInfo': JSON.stringify(attachmentsInfo), // информация о вложениях, представленная в виде строки JSON
        'accessToken': accessToken, // сохраняемый токен доступа
        'currentIndex': '0' // начальный индекс текущего вложения, с которого начнется загрузка
      });
    
      // Запускаем процесс пакетной загрузки файлов, вызывая функцию, которая обрабатывает загрузку.
      triggerBatchUpload();
    };
    
    /**
     * Триггер вызывает пакетную загрузку файлов на Google Drive.
     * Запускает обработку следующего пакета файлов или завершает процесс, если все файлы обработаны.
     */
    const triggerBatchUpload = async () => {
      // Получаем доступ к сохраненным свойствам скрипта
      const scriptProperties = PropertiesService.getScriptProperties();
      const attachmentsInfo = JSON.parse(scriptProperties.getProperty('attachmentsInfo'));
      const accessToken = scriptProperties.getProperty('accessToken');
      let currentIndex = parseInt(scriptProperties.getProperty('currentIndex'), 10);
    
      // Проверяем, не была ли обработка уже завершена
      if (currentIndex >= attachmentsInfo.length) {
        deleteAllPropertiesExceptUnreadThreadIds(scriptProperties); // Удаляем все сохраненные свойства
        deleteAllTriggers(); // Удаляем все триггеры, так как загрузка завершена
        addTrigger('doOrders', 1 * 20 * 1000) // запускаем процесс оработки заказов
        console.log('Все файлы были обработаны.');
        return;
      }
    
      // формируем массив отправителей
      const senderData = attachmentsInfo.slice(currentIndex, currentIndex + 3).map(info => ({ sender: info.sender }));
      // Формируем массив запросов на получение данных вложений из Gmail
      const batchInfo = attachmentsInfo.slice(currentIndex, currentIndex + 3);
      const requests = batchInfo.map(info => ({
        url: `https://www.googleapis.com/gmail/v1/users/me/messages/${info.messageId}/attachments/${info.attachmentId}`,
        method: 'GET',
        headers: {
          Authorization: `Bearer ${accessToken}`,
          Accept: 'application/json'
        },
        filename: info.filename,
        mimeType: info.mimeType,
        muteHttpExceptions: true
      }));
    
      // Подготовим массивы для хранения blob данных и метаданных файлов
      let blobs = [];
      let attachmentsData = [];
    
      // Выполнение запросов и обработка полученных данных вложений
      if (requests.length > 0) {
        const responses = await UrlFetchApp.fetchAll(requests);
        responses.forEach((response, index) => {
          if (response.getResponseCode() === 200) {
            // Если ответ успешный, обрабатываем данные вложения
            const request = requests[index];
            const attachmentData = JSON.parse(response.getContentText());
            const blob = Utilities.base64DecodeWebSafe(attachmentData.data);
            blobs.push(blob);
            attachmentsData.push({
              name: request.filename,
              mimeType: 'application/vnd.google-apps.spreadsheet',
              parents: [config.folderId]
            });
          } else {
            console.error(`Ошибка получения данных вложения: ${response.getResponseCode()}`);
          }
        });
      }
    
      // Запустим асинхронную функцию загрузки файлов на Google Drive
      batchUploadFiles({ attachmentsData, blobs, senderData });
    
      // Обновляем индекс текущего выполнения и сохраняем его в свойствах скрипта
      currentIndex += batchInfo.length;
      scriptProperties.setProperty('currentIndex', currentIndex.toString());
    
      // Если не все файлы обработаны, установим новый триггер для продолжения загрузки
      if (currentIndex < attachmentsInfo.length) {
        addTrigger('triggerBatchUpload', 1 * 60 * 1000); // Запустить следующий шаг через 30 секунд
      } else {
        deleteAllPropertiesExceptUnreadThreadIds(scriptProperties); // Процесс загрузки завершён, удаляем свойства
        deleteAllTriggers(); // Удаляем все триггеры
        addTrigger('doOrders', 1 * 20 * 1000) // запускаем процесс оработки заказов
        console.log('Все файлы были обработаны.');
      }
    };
    
    /**
     * Функция, которая будет вызвана триггером и отметит ранее сохраненные треды, как прочитанные.
     */
    const markThreadsAsRead = () => {
      try {
        const scriptProperties = PropertiesService.getScriptProperties();
        const threadIdsString = scriptProperties.getProperty('unreadThreadIds');
        if (!threadIdsString) {
          return;
        }
        const threadIds = JSON.parse(threadIdsString);
    
        threadIds.forEach(threadId => {
          // Отмечаем каждое сообщение из треда, как прочитанное
          Gmail.Users.Threads.modify({ removeLabelIds: ['UNREAD'] }, 'me', threadId);
        });
    
        // Удаляем идентификаторы тредов из свойств скрипта после их обработки
        scriptProperties.deleteProperty('unreadThreadIds');
    
        console.log('Unread threads are marked as read.');
      } catch (error) {
        logError('markThreadsAsRead', error);
      }
    };
    
    /**
     * Удаляет все свойства скрипта, кроме 'unreadThreadIds'.
     */
    const deleteAllPropertiesExceptUnreadThreadIds = (scriptProperties) => {
      const properties = scriptProperties.getProperties();
    
      // Получаем массив ключей свойств и удаляем каждое свойство, не являющееся 'unreadThreadIds'
      Object.keys(properties).forEach(key => {
        if (key !== 'unreadThreadIds') {
          scriptProperties.deleteProperty(key);
        }
      });
    
      // Логирование успешного выполнения операции
      console.log('Все свойства, кроме "unreadThreadIds", были успешно удалены.');
    };
    
    /**
     * Выполняет пакетную загрузку файлов на Google Drive.
     */
    async function batchUploadFiles(data) {
      try {
        // Получение доступного OAuth 2.0 токена для текущей сессии пользователя
        const accessToken = ScriptApp.getOAuthToken();
        // Получаем информацию о вложениях и созданные blob'ы
        const { attachmentsData, blobs, senderData } = data;
        const boundary = "xxxxxxxxxx"; // Уникальная граница нужна для разделения частей в multipart запросе
    
        // Создаем массив запросов для каждого вложения
        const requests = attachmentsData.map((metadata, index) => {
          // Использование шаблонных литералов для формирования multipart тела запроса
          let data = `--${boundary}\r\n`;
          data += `Content-Disposition: form-data; name="metadata"\r\n`;
          data += `Content-Type: application/json; charset=UTF-8\r\n\r\n`;
          data += `${JSON.stringify(metadata)}\r\n`; // Добавление метаданных файла
    
          data += `--${boundary}\r\n`;
          data += `Content-Disposition: form-data; name="file"; filename="${metadata.name}"\r\n`;
          data += `Content-Type: ${config.mimeTypes.excel[0]}\r\n\r\n`;
    
          // TODO: Этот блок кода предполагает наличие массива байт в blobs, который нужно будет преобразовать в blob
          let payload = [...Utilities.newBlob(data).getBytes(), ...blobs[index]];
          payload = [...payload, ...Utilities.newBlob(`\r\n--${boundary}--`).getBytes()]; // Закрытие multipart тела
    
          // Формируем объект запроса для fetchAll
          return {
            url: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
            method: 'POST',
            headers: {
              'Authorization': `Bearer ${accessToken}`,
              'Content-Type': `multipart/related; boundary=${boundary}`
            },
            payload: payload,
            muteHttpExceptions: true
          };
        });
    
        // Выполняем пакетные запросы для загрузки файлов
        const responses = await UrlFetchApp.fetchAll(requests);
    
        // Обработка ответов и логирование результатов
        const log = responses.map((response, index) => {
          if (response.getResponseCode() === 200) {
            return {
              'Файл': JSON.parse(response.getContentText()).name,
              'Отправитель': senderData[index].sender,
              'Статус': 'успешно загружен'
            }
          } else {
            return {
              'Ошибка загрузки файла': response.getContentText(),
              'Отправитель': senderData[index].sender,
              'Статус': 'неудачно'
            }
          }
        });
        console.log(JSON.stringify(log, null, 2));
        // отправляем сообщения в телеграм
        sendMessage(JSON.stringify(log, null, 2));
      } catch (error) {
        // Логирование и передача информации об ошибке
        logError('batchUploadFiles', error)
      }
    }