Gmail API を使ってメールを取得すると結構大変だった話(前編)

こんにちは、バックエンドエンジニアの まる です。

以前、Gmailアドレスに送られたメールの内容をAPIを使ってSlackのチャンネルに送るようなSlackbotの開発をしていた際に、「Gmailの受信BOXを監視するのって意外と複雑だな」という気づきがあったので、そこで得られた知見をみなさんに共有したいと思います。

全2回構成として、前編にあたるこの記事では、特定のGmailアドレスのメールBOXにあるメールをGmail APIを使ってCloud Functionsから取得する方法についてご説明します。

後編はこちら

はじめに:Gmail APIの認証をクリアしたい

今回やりたいことは、簡単に言えばCloud FunctionsからGmail APIを呼ぶだけです。しかし、実際にはGmail APIの認証周りが複雑で、GCP上でいくつか事前準備を行わなければなりません。
この記事のメインコンテンツは、このGmail APIの認証をクリアする方法の紹介になっています。

Gmail APIの認証について

Gmail APIは認証と認可にOAuth2.0を採用しています。公式ドキュメント
公式ドキュメントの内容を要約すると、以下の手順でGmail APIを呼ぶことができます。

  1. Google Cloud Platformから認証情報を取得する
  2. Google承認サーバーに認証情報を送り、アクセストークンを取得する
    2.1 アクセストークンの有効期限が切れている場合は更新する
  3. リクエストにアクセストークンを付与してを呼ぶ

一見シンプルですが、注意点がいくつかあります。

一つ目は、1.の認証情報についてです。
おそらくサービスアカウントのプライベートキーを使用するのが最も簡単だと思いますが、今回は使用できません。なぜなら、Gmail APIを呼ぶには、対象のGoogleアカウント本人の認証情報が必要だからです。
この記事では、自前のOAuthクライアントIDを立ててそのクライアントシークレットを使用します。

二つ目は、アクセストークンの更新についてです。
2.で取得できるアクセストークンの有効期限は1時間しかありません。(公式ドキュメントには有効期限について具体的に書かれていませんが、この記事の方法で取得したアクセストークンのexpiryはどれも1時間後でした)
したがって、Cloud Functionsにデプロイしたアプリケーションをメンテナンスなしで動かすためには、アクセストークンを更新する処理が必須になります。
幸い取得したアクセストークンのJSONにはリフレッシュトークンが含まれているので、これを使って更新します。

Google Cloud Platform設定

APIを有効化(Gmail APIとあと2つ)

まずは使用するAPIをGoogle Cloud Platformから有効化する必要があります。
今回使用するAPIは以下の3つです。

  • Gmail API
  • Cloud Functions API
  • Cloud Build API

Cloud FunctionsからGmail APIを呼ぶので、上の2つは当然必要です。
Cloud Build APIはCloud Functionsのデプロイに使用しますが、必要ない場合は有効化しなくてもよいです。
代表してGmail APIを有効化する手順をご説明します。
画面左のナビゲーションメニューから「APIとサービス」にカーソルをかざし、「有効なAPIとサービス」を選択します。

APIとサービスの画面に移るので、画面上部の「APIとサービスの有効化」を選択します。

検索窓に「Gmail」と入力すると、Gmail APIが検索結果に表示されます。

「有効にする」をクリックします。

これでGmail APIが有効化されました。
同様の手順でCloud Functions API とCloud Build APIの有効化を行いましょう。

OAuth同意画面を作成

次に、OAuth同意画面を作成します。
画面左のナビゲーションメニューから「APIとサービス」にカーソルをかざし、「OAuth同意画面」を選択します。
UserTypeは「外部」を選択してください。これは、メールBOXを取得したいGoogleアカウントがGCPプロジェクトに入っていないことを想定しているためです。GCPプロジェクト内のGoogleアカウントのメールを取得したい場合は「内部」を選択しましょう。

ステップ①ではアプリ情報、アプリのドメイン、デベロッパーの連絡先情報などの入力を求められますが、自分しか使わない同意画面なので必須項目のみ入力すればよいと思います。

ステップ②ではスコープを選択します。「スコープを追加または削除」をクリックして必要なスコープを選択しましょう。
今回はGmail APIを使ってメールを取得するだけですので、https://mail.google.com/auth/gmail.modify のみにチェックを入れます。メールの送信や削除も行いたい場合などは必要に応じてスコープを選択してください。

ステップ③のテストユーザーには何も入れずに次に進みましょう。

最後に「本番環境にpushしますか?」といった確認画面が出ます。Googleが検証できるように公式リンクや書面の提出を促すような文言が書いてありますが、このOAuth同意画面はオレオレなので特に検証してもらう必要はありません。無視して「確認」を押します。

これでOAuth同意画面が作成できました。

OAuthクライアントIDを作成

次に、OAuthクライアントIDを作成します。
画面左のナビゲーションメニューから「APIとサービス」にカーソルをかざし、「認証情報」を選択します。
認証情報画面で「認証情報を作成」をクリックし、「OAuthクライアントID」を選択します。

アプリケーションの種類は「デスクトップアプリ」を選択してください。名前は適当で大丈夫です。
入力後に「作成」をクリックします。

作成完了画面に移るので、ここでJSONファイルをダウンロードしておきましょう。

ここでダウンロードし忘れても「認証情報」画面から再度ダウンロードすることができます。
クライアントIDとクライアントシークレットの情報はJSONファイルに入っているので、特に記録しておく必要はありません。

ダウンロードしたJSONファイルは後ほどプログラムから指定するので、ファイル名を「credentials.json」に変えておきましょう。
これでクライアントIDの作成が完了しました。

Gmail APIのサンプルコードを使ってアクセストークンを取得

次にアクセストークンを取得しましょう。
Gmail APIのPython Quickstartページにアクセストークンを取得する手順が載っているので、これに沿った形でご説明します。

ライブラリをインストール

コマンドラインから必要なライブラリをpipコマンドを使ってインストールします。

pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib

トークン取得用のpythonプログラムを設定

トークン取得用のpythonプログラム(quickstart.py)を用意します。クイックスタートページに載っているサンプルコードをそのまま使えばよいです。

from __future__ import print_function

import os.path

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

## スコープを変更した場合はtoken.jsonを削除してください
SCOPES = ['<https://www.googleapis.com/auth/gmail.readonly>']

def main():
    """Shows basic usage of the Gmail API.
    Lists the user's Gmail labels.
    """
    creds = None
    # 認証フローが初めて完了したときに自動的にtoken.jsonが作成されます
    if os.path.exists('token.json'):
        creds = Credentials.from_authorized_user_file('token.json', SCOPES)
    # 使用可能な認証情報がない場合は、ユーザーにログインを要求します
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                'credentials.json', SCOPES)
            creds = flow.run_local_server(port=0)
        # 新しい認証情報をtoken.jsonに保存します。
        with open('token.json', 'w') as token:
            token.write(creds.to_json())

    try:
        # Gmail API呼び出し
        service = build('gmail', 'v1', credentials=creds)
        results = service.users().labels().list(userId='me').execute()
        labels = results.get('labels', [])

        if not labels:
            print('No labels found.')
            return
        print('Labels:')
        for label in labels:
            print(label['name'])

    except HttpError as error:
        # TODO(developer) - 適切にエラーを処理してください
        print(f'An error occurred: {error}')

if __name__ == '__main__':
    main()

OAuthクライアントIDを作成の章でダウンロードした認証情報(credentials.json)をquickstart.pyと同じディレクトリに置いておきましょう。もしcredentials.jsonを別のディレクトリに置く場合はコード中のパスを書きかえてください。

プログラムを実行

最後にローカルでプログラムを実行しましょう。

python quickstart.py

実行するとブラウザが立ち上がり、OAuth同意画面が開かれます。
場合によっては以下のような警告画面が表示されることもあります。これはGoogleによって検証されていないという警告ですが、自分で作った同意画面なので問題ありません。無視して進みましょう。

「詳細を表示」をクリックし、「[アプリ名](安全ではないページ)に移動」をクリックします。

その後、メールを取得するGoogleアカウントを選択し、アクセス権の付与の同意画面で「続行」をクリックします。

ブラウザ上に「The authentication flow has completed. You may close this window.」という表示が出たら成功です。quickstart.pyと同じディレクトリにアクセストークンファイル(token.json)が作成されているはずです。

アクセストークンをCloud Storageに保存

Cloud FunctionsからGmail APIを実行するために、token.jsonをCloud Functionsから参照できる場所に置きます。今回はCloud Storageにバケットを立ててそこに置くことにしました。
バケットの構成については詳しく解説しませんが、頻繁にアクセスされることが予想されるので、ストレージクラスはStandardにしたほうがよいと思います。他は予算とパフォーマンスを考慮して設定してください。
バケットが作成されたらtoken.jsonをアップロードしてください。バケット名は後ほど使うのでメモっておきましょう。

実装

メールBOXのメールの内容を全件取得するために必要なGoogle APIは2つあります。

まずusers.messages.listを呼んでmessageのIDの一覧を取得し、その後messageのIDを使ってusers.messages.get を呼んでメールの内容を取得する、という流れです。
実際のpythonプログラム(mai.py)は以下のようになります。

from bs4 import BeautifulSoup
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from google.cloud import storage
import base64
import json

# OAuth 同意画面で設定したSCOPEに合わせて追加・変更してください
SCOPES = [
    "https://www.googleapis.com/auth/gmail.readonly",
]
# Cloud Storage のバケット名に置き換えてください
BUCKET_NAME = "bucket_name"
TOKEN_FILE_NAME = "token.json"
# メールを取得するGoogleアカウントに置き換えてください
EMAIL_ADDRESS = "example@gmail.com"

def main(arg):

    storage_client = storage.Client()
    bucket = storage_client.bucket(BUCKET_NAME)
    token_blob = storage.Blob(TOKEN_FILE_NAME, bucket)

    creds = None

    # Cloud Storage 上の token.json を使って認証
    if token_blob.exists():
        token_str = token_blob.download_as_string()
        token_json = json.loads(token_str)
        creds = Credentials.from_authorized_user_info(token_json, SCOPES)
    if not creds or not creds.valid:
        # token.json の有効期限が切れていたらリフレッシュ
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        # token.json が invalid の場合の処理は省略
        # 新しい token.json 情報を Cloud Storage にアップロード
        with open("/tmp/token.json", 'w') as token:
            token.write(creds.to_json())
        token_blob.upload_from_filename(filename="/tmp/token.json")

    try:
        # users.messages.list を呼んでメールの一覧を取得
        service = build('gmail', 'v1', credentials=creds)
        result = service.users().messages().list(userId='me').execute()
        messages = result.get('messages')

        for msg in messages:
            # message_id ごとに users.messages.get を呼んでメールの内容を取得
            txt = service.users().messages().get(
                userId='me', id=msg['id']).execute()

            payload = txt['payload']
            headers = payload['headers']

            # header から送信者と件名の情報を取得
            for d in headers:
                if d['name'] == 'Subject':
                    subject = d['value']
                if d['name'] == 'From':
                    sender = d['value']

            # 本文を base64 でデコード
            parts = payload.get('parts')[0]
            data = parts['body']['data']
            data = data.replace("-", "+").replace("_", "/")
            decoded_data = base64.b64decode(data)

            print("dacoded_data", decoded_data)

            # BeautifulSoup ライブラリで lxml をパース
            soup = BeautifulSoup(decoded_data, "lxml")
            body = soup.body()

            # 件名、送信者、本文をログに出力
            print("Subject: ", subject)
            print("From: ", sender)
            print("Message: ", body)
            print('\n')

        return "end"

    except HttpError as error:
        print(f'An error occurred: {error}')

デプロイと実行

プログラムをCloud Functionsにデプロイ

gcloud コマンドを使ってデプロイします。
コマンドを実行する前に、まずは依存関係をまとめたrequirements.txtファイルを作成しましょう。

bs4
google-api-python-client
google-auth-httplib2
google-auth-oauthlib
google-cloud-storage
lxml

requirements.txtはmain.pyと同じディレクトリに置いてください。別のディレクトリにあるとgcloudコマンドが失敗します。

main.pyとrequirements.txtを準備できたら、以下のコマンドでデプロイを行います。各オプションの説明は公式ドキュメントを参照してください。

gcloud functions deploy main \
  --source=./src \
  --region=asia-northeast1 \
  --entry-point=main \
  --trigger-http \
  --allow-unauthenticated \
  --runtime=python39 \

デプロイに成功したらGCPのCloud Functionsを見に行きましょう。
きちんとデプロイできていることが確認できると思います。

Cloud Functionsを実行してメールを取得

デプロイしたCloud Functionsを実行してメールを取得してみましょう。
関数の詳細画面の「テスト中」から関数を実行できます。
httpリクエストの中身は何でもよいので、空のまま「関数をテストする」ボタンをクリックして下さい。

ログを見るとメールの内容を取得できていることが確認できると思います。

お疲れ様でした。

おわりに

この記事では、Cloud FunctionsからGmail APIを使ってメールを全件取得する方法についてご説明しました。
特にGmail APIの認証周りの設定が複雑で初見では分からないことも多かったので、この記事が誰かの役に立てば幸いです。
後日公開される後編では、「新規メールが届いたときにCloud Functionsが動いて、そのメールの内容だけ取得したい」というよくある要件を満たす構成についてご紹介する予定です。

後編はこちら

SHARE

  • facebook
  • twitter

SQRIPTER

AGEST Engineers

AGEST

記事一覧

AGESTのエンジニアが情報発信してます!
AGESTのサービスやソリューションのお問い合わせページはこちらです。

株式会社AGEST

Sqriptsはシステム開発における品質(Quality)を中心に、エンジニアが”理解しやすい”Scriptに変換して情報発信するメディアです

  • 新規登録/ログイン
  • 株式会社AGEST