💭

Gmail API + NodeJS | Gmail を Node で取得する

2024/02/20に公開

目的

Gmail API に Node.js でアクセスし、自分の Gmail を取得する.

手順

  1. Gmail API の有効化
  2. [APIとサービス] 認証情報と OAuth 同意画面の設定
  3. コード(gmail-api.js)作成

1, 2 の参考

第1項, 第2項 は以下のサイトを参考に.
https://sqripts.com/2022/08/25/20386/
https://developers.google.com/gmail/api/quickstart/js?hl=ja

1. Gmail API の有効化

Google Cloud から "Gmail API" を検索して有効化する.

2. [APIとサービス] 認証情報と OAuth 同意画面の設定

Gmail API に自分の PC の NodeJS からアクセスするための認証設定
Google Cloud から "APIとサービス" を検索.

  1. OAuth 同意画面作成
  2. 認証情報作成
    • 認証情報を作成 → "OAuthクライアントID"
    • アプリケーションの種類は "デスクトップアプリ"
    • 作成 → "JSONをダウンロード"
    • ダウンロードされた JSON ファイルのファイル名を "credentials.json" に変更

3. コード作成

コード本体

Gmail API へのアクセスには、公式モジュール googleapis@105 および @google-cloud/local-auth@2.1.0 を使用する.
また、HTML メールの解析のために、html-to-text を使用する.

npm i googleapis@105 @google-cloud/local-auth@2.1.0 --save
npm i html-to-text
長いのでコードはアコーディオンになってます
gmail-api.js
const fs = require('fs').promises;
const path = require('path');
const process = require('process');
const {authenticate} = require('@google-cloud/local-auth');
const {google} = require('googleapis');

// If modifying these scopes, delete token.json.
const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'];

// The file token.json stores the user's access and refresh tokens, and is
// created automatically when the authorization flow completes for the first
// time.
const TOKEN_PATH = path.join(process.cwd(), 'token.json');
const CREDENTIALS_PATH = path.join(process.cwd(),
    'credentials.json'); // 作成した認証情報の JSON ファイル
    // ここでは、gmail-api.js と同じフォルダに credentials.json がある

/**
 * Reads previously authorized credentials from the save file.
 *
 * @return {Promise<OAuth2Client|null>}
 */
async function loadSavedCredentialsIfExist() {
  try {
    const content = await fs.readFile(TOKEN_PATH);
    const credentials = JSON.parse(content);
    return google.auth.fromJSON(credentials);
  } catch (err) {
    return null;
  }
}

/**
 * Serializes credentials to a file compatible with GoogleAUth.fromJSON.
 *
 * @param {OAuth2Client} client
 * @return {Promise<void>}
 */
async function saveCredentials(client) {
  const content = await fs.readFile(CREDENTIALS_PATH);
  const keys = JSON.parse(content);
  const key = keys.installed || keys.web;
  const payload = JSON.stringify({
    type: 'authorized_user',
    client_id: key.client_id,
    client_secret: key.client_secret,
    refresh_token: client.credentials.refresh_token,
  });
  await fs.writeFile(TOKEN_PATH, payload);
}

/**
 * Load or request or authorization to call APIs.
 *
 */
async function authorize() {
  let client = await loadSavedCredentialsIfExist();
  if (client) {
    return client;
  }
  client = await authenticate({
    scopes: SCOPES,
    keyfilePath: CREDENTIALS_PATH,
  });
  if (client.credentials) {
    await saveCredentials(client);
  }
  return client;
}

/**
 * 新着メールを取得
 * @param {Object<OAuth2Client>} auth 
 * @return {Object} 取得したメールの ID 等が格納された配列
 */
async function getRecentEmails(auth) {
    const gmail = google.gmail({ version: 'v1', auth });
    const res = await gmail.users.messages.list({
      userId: 'me',
      labelIds: ['INBOX'],
      maxResults: 10 // 取得するメールの最大数
    });
    return res.data.messages;
}

/**
 * Message ID より、 Message の詳細データを取得
 * @param {Object<OAuth2Client>} auth 
 * @param {String} messageId getRecentEmails で取得した message の ID
 * @returns {Object} Message ID で取得したメッセージの詳細データ
 */
async function getEmailDataByMessageId(auth, messageId) {
    const gmail = google.gmail({ version: 'v1', auth });
    const res = await gmail.users.messages.get({
      userId: 'me',
      id: messageId
    });
    return res.data;
}

// input の html/text 文字列からタグ情報などを取り除く
const htmlConvert = (input) => {
    // import
    const { convert } = require('html-to-text');
    // config
    const options = {
        ignoreHref: true,
        ignoreImage: true,
        noAnchorUrl: true,
        singleNewLineParagraphs: true,
    };

    return convert(input, options);
};

// payload 等の mimeType によって、適切な方法で本文を取り出す
const parseBody = (container) => new Promise((resolve, reject) => {
    // 空判定
    if (!(container.body) || container.body.size === 0) reject();
    try {
        // base64 デコード
        const body = Buffer.from(container.body.data, 'base64').toString();
        if (container.mimeType === 'text/plain') {
            resolve(body);
        }
        else if (container.mimeType === 'text/html') {
            resolve(htmlConvert(body));
        }
        else {
            reject();
        }
    } catch (error) {
        reject(error);
    }
});

async function main(auth) {
    // get recent emails
    const messages = await getRecentEmails(auth);
    // get message data
    const msgDatas = await Promise.all(
        messages.map(async (m) => await getEmailDataByMessageId(auth, m.id))
    );
    const textMessages = await Promise.all(
    msgDatas.map(async (md) => {
        const payload = md.payload;
        if (!payload || !('mimeType' in payload)) return;
        if (payload.mimeType === 'multipart/alternative') {
            // 内部の最初に解決された Promise が返される
            return await Promise.any(
                payload.parts.map((part) => parseBody(part))
            );
        } else {
          return parseBody(payload);
        }
    })); // End of map

    console.log(textMessages);
    // Output: [ '.......', '.....', ...]
}

authorize().then(main).catch(console.error);
// Output: [ '.......', '.....', ...]

  • コードを実行すると、1回目は認証のためにブラウザが起動する.
  • 成功すると gmail-api.js と同じフォルダに token.json が作成される.
  • メールが HTML だったりすると、CSS 等いらない情報もついてくるため、メールが HTML の場合に、必要な情報だけ取り出す処理を書いた.

メールの種類

本文が HTML かどうかは、メッセージヘッダー の Content-Type を確認すればよい.
メールで使われる Content-Type には次のようなものがある
- text/plain (テキスト)
- text/html (HTML)
- multipart/alternative (HTML & テキスト)

https://sendgrid.kke.co.jp/blog/?p=8262

モジュールで取得できるメールオブジェクトの構造 (結論)

const gmail = google.gmail({ version: 'v1', auth });
const res = await gmail.users.messages.get({
  userId: 'me',
  id: messageId
});
console.log(res.data);

これで取得できるデータは単純にすると以下のようになる.

res.data:
    payload:
        mimeType: '<MIME Type String>' # 例: 'multipart/alternative'
        headers: [<Header Object>, ...]
        body:
            size: (Int)<Body Data Length>,
            data: '<Base64 Encoded Message String>' # 例: 'PGh0bWw-DQo5bGU-DQoJ...'
        parts: [<Part Object>, ...]

Header Object:
    name: '<Header Name String>' # 例: 'Content-Type'
    value: '<MIME Type String>; <Option Args>' # Option Args 例: 'boundary="xxx";'

Part Object:
    mimeType: '<MIME Type String>'
    headers: [<Header Object>, ...]
    body:
        size: (Int)<Body Data Length>
        data: '<Encoded Message String>'

つまり、res.data.payload に MIME Type, header, body, parts が含まれており、MIME Type によっては、parts に HTML や Plain のメッセージがあり、それぞれが、MIME Type, header, body を持っている.

検証過程

本項は、前項の検証過程である.

以下は headers を表示した例
gmail-api.js
const headers = msgDatas.map((md) => md.payload.headers);
console.log(`Headers.Content-Type:`, headers[0]);

以下が結果のいち部

[
    ...
    name: 'Subject', value: 'Welcome to Google AI Studio' },
  {
    name: 'From',
    value: 'Google AI Studio <googleaistudio-noreply@google.com>'
  },
  { name: 'To', value: 'hogehoge@gmail.com' },
  {
    name: 'Content-Type',
    value: 'multipart/alternative; boundary="00000000000010f42c0610mecied"'
  }
]

上記の仕様を前提に payload.headers.nameContent-Type で場合分けして、内容を取得しようとした.
しかし、メッセージの内容が undefined になったり、multipart なのに text しかなかったりして、よくわからない状況に.

Body 取得でつまずいたので、オブジェクトの中身をきちんと確認
  • gmail.users.messages.get で取得できるメッセージの詳細データ
gmail-api.js
/**
 * Message ID より、 Message の詳細データを取得
 * @param {Object<OAuth2Client>} auth 
 * @param {String} messageId getRecentEmails で取得した message の ID
 * @returns {Object} Message ID で取得したメッセージの詳細データ
 */
async function getEmailDataByMessageId(auth, messageId) {
    const gmail = google.gmail({ version: 'v1', auth });
    const res = await gmail.users.messages.get({
      userId: 'me',
      id: messageId
    });
    return res.data;
}

取得結果

// res.data
{
  id: '42a9365f9142d129', threadId: '21x3456e9456f192',
  labelIds: [ 'UNREAD', 'INBOX' ], // Label ID
  snippet: '', // メッセージ概要 ない場合もある
  payload: {
    partId: '',
    mimeType: 'multipart/alternative',
    filename: '',
    headers: [
      [Object], [Object], [Object], ...
    ],
    body: {
        size: 2577,
        data: 'PGh0bWw-DQo5bGU-DQoJ...' // base 64 文字列 省略
    },
    parts: [ [Object], [Object] ]
  },
  sizeEstimate: 27549,
  historyId: '892346',
  internalDate: '1234123463000'
}
  • さらに parts の中身
// res.data.payload.parts
[
    {
        partId: '0',
        mimeType: 'text/plain',
        filename: '',
        headers: [ [Object], [Object] ],
        body: {
          size: 4095,
          data: 'Q44OX44Op4g5L44...' // base 64 文字列 省略
        }
    },
    ... // 省略
]

つまり、res.data 自体と res.data.payload.parts がそれぞれ header 情報を持っている.
こちらでいろいろしなくても、分割してくれている.

Discussion