はじめに
この記事は Goodpatch Advent Calendar 2019 の22日目です.
私が現在担当しているWebサービスの開発において、Puppeteerを用いたe2eテストを用いてQAの効率化を図っています。
この記事では Node.js と Gmail API を使い、アカウント作成時のメール認証を自動化する方法について共有したいと思います。
注:この記事ではPuppeteerには触れません!
環境準備
メールをNode.jsで取得するためには、Gmail APIの設定と、各種ファイルの取得・生成が必要です。
基本的にはNode.js Quickstartに従って作業します。
フォルダの準備
あらかじめ、各種ファイルを保存するフォルダの準備しておきます。
例として、以下のような構造にします。
Gmail Credentialの取得と配置
あらかじめ、利用するGmailのアカウントでログインしておきます。

ログイン後、以下のページを開きます
Node.js Quickstart | Gmail API
モーダルが表示されるので、ボタンを押してcredensial.jsonをダウンロードし、

トークンの取得
Node.js Quickstart | Gmail API の Step2 に従って、以下をインストールします。
$ npm install googleapis@39 --save
Node.js Quickstart | Gmail API
中のStep 3 のコードをコピーし、 e2e/scripts/get-token.js と名付けて保存します。
フォルダ構造を合わせるため、credentials.jsのファイルパスと、トークンの出力先パス TOKEN_PATHを以下のように修正します。
const TOKEN_PATH = __dirname + '/../env/token.json';
// Load client secrets from a local file.
fs.readFile(__dirname + '/../env/credentials.json', (err, content) => {
保存後、以下のコマンドを実行します。
$ node e2e/scripts/get-token
すると、以下ようにURLがあらわれるので、言われた通りこのページを開きます。

ログインするアカウントを選択すると、以下の画面がでて来るので、詳細を表示し Quickstartに移動します

ターミナルに戻って Enter the code from that page here:  のあとに貼り付け、Enterします。

すると、Gmailで利用しているラベルの一覧が表示され、e2e/env フォルダに token.jsファイルが生成されます。
これで、Node.jsからGmailを利用する準備は完了です。
Gmailから目的のメールを取得する
準備が長かった気がしますが、ここからが本番です。
数多くのメールの中から、認証リンクを含んだメールを探し、本文からリンク取得します。
取得すべきメール
取得すべきメールは以下のようなものです。
APIのフィルタ機能と、本文への正規表現を用いた検索を使ってこのメールを探します。
- 未読状態である。
- Webサービスのメール送信用アドレスから送信されている。
 (ここではhoge@piyo.jpとします)
- 登録したメールアドレスに送信されている。
 (登録に利用したメールアドレスを引数として渡す)
- 本文に認証リンクを含む。
 (ここではhttps://hoge.piyo.jp/mail/XXXXXXのフォーマットであるとします)
環境準備
追加で以下のpackageをインストールします。
$ npm i google-auth-library -s
最終的なコード
先に最終的なコードを貼っておきます。
以下で説明していきます。
const fs = require('fs')
const { promisify } = require('util')
const { google } = require('googleapis')
const { OAuth2Client } = require('google-auth-library')
const gmail = google.gmail('v1')
const TOKEN_PATH = __dirname + '/../env/token.json'
const SECRET_PATH = __dirname + '/../env/credentials.json'
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))
const MAX_RETRY = 10
//Promise 化
const readFileAsync = promisify(fs.readFile)
const getMessageList = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.list(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}
const getMessage = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.get(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}
const getRegisterToken = async ({ email }) => {
  //クレデンシャル情報の取得
  const content = await readFileAsync(SECRET_PATH) //クライアントシークレットのファイルを指定
  const credentials = JSON.parse(content) //クレデンシャル
  //認証
  const clientSecret = credentials.installed.client_secret
  const clientId = credentials.installed.client_id
  const redirectUrl = credentials.installed.redirect_uris[0]
  const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUrl)
  const token = await readFileAsync(TOKEN_PATH)
  oauth2Client.credentials = JSON.parse(token)
  //API経由でシートにアクセス
  try {
    const getToken = async () => {
      // メッセージリスト取得
      const data = await getMessageList({
        auth: oauth2Client,
        userId: 'me',
        q: `is:unread from:piyo@hoge.jp to:${email}`,
      })
      if (!data.messages || data.messages.length === 0) {
        console.log('no message')
        return
      }
      const message = await getMessage({
        auth: oauth2Client,
        userId: 'me',
        id: data.messages[0].id,
      })
      const text = Buffer.from(
        message.payload.parts[1].body.data,
        'base64'
      ).toString('utf8')
      const regex = new RegExp(
        /https:\/\/hoge\.piyo\.jp\/mail\/([A-Za-z0-9]+)/
      )
      const result = text.match(regex)
      if (!result) {
        console.log('not matched')
        return
      }
      console.log('matched', result[1])
      return result[1]
    }
    let retry = MAX_RETRY
    let token = null
    while (retry > 0) {
      token = await getToken()
      if (token) {
        break
      }
      retry--
      await sleep(10000)
    }
    console.log('token', token)
    return token
  } catch (err) {
    return ''
  }
}
module.exports = { getRegisterToken }
Promise化
async/awaitで書きたいので、各種関数をPromiseでラップします。
promisifyが使えるものについては、promisifyを使い、そうでないものはベタに書いていきます。
//promisifyでプロミス化
const readFileAsync = promisify(fs.readFile)
const getMessageList = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.list(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}
const getMessage = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.get(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}
認証データの準備
認証データをファイルから読み出し、OAuth2の認証クライアントを生成します。
  //クレデンシャル情報の取得
  const content = await readFileAsync(SECRET_PATH) //クライアントシークレットのファイルを指定
  const credentials = JSON.parse(content) //クレデンシャル
  //認証
  const clientSecret = credentials.installed.client_secret
  const clientId = credentials.installed.client_id
  const redirectUrl = credentials.installed.redirect_uris[0]
  const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUrl)
  const token = await readFileAsync(TOKEN_PATH)
  oauth2Client.credentials = JSON.parse(token)
メッセージリストの取得
メッセージリストを検索クエリを付与してフィルタリングし、取得します。
| 指定 | 意味 | 
|---|---|
| is:unread | 未読 | 
| from:hoge@piyo.jp | hoge@piyo.jp から送信されている | 
| to:${email} | ${email} へ送信されている | 
      // メッセージリスト取得
      const data = await getMessageList({
        auth: oauth2Client,
        userId: 'me',
        q: `is:unread from:piyo@hoge.jp to:${email}`,
      })
メール本文の取得
メール本文を取得します。
ここでは、メッセージリストで複数候補があっても1つ目のメールのみを取得しています。
      const message = await getMessage({
        auth: oauth2Client,
        userId: 'me',
        id: data.messages[0].id, //1つ目のメールを指定
      })
デコード
Gmailでは本文は、UTF-8の文字列バイトデータがBase64エンコードされたものになっています。
そのため、Base64からバイト配列に変換し、UTF-8に変換、といったデコードが必要です。
      const text = Buffer.from(
        message.payload.parts[1].body.data,
        'base64'
      ).toString('utf8')
文字列の抽出
ここまできたら正規表現で文字列を検索・抽出してあげるだけです!
      const regex = new RegExp(
        /https:\/\/hoge\.piyo\.jp\/mail\/([A-Za-z0-9]+)/
      )
      const result = text.match(regex)
リトライ
メールが直に送信されるとは限らないので、リトライの仕組みを入れています。
ここでは、10秒毎に最大10回リトライするようにしています。
    let retry = MAX_RETRY
    let token = null
    while (retry > 0) {
      token = await getToken()
      if (token) {
        break
      }
      retry--
      await sleep(10000)
    }
最後に
まだ追記するべき点がありますので、後ほど更新します!
時間切れにてここまで。何かのお役に立ちますように〜〜〜!






