Python を使い、Gmail API 経由で Gmail の送受信を行う

はじめに
======================
Gmail にはご存知の通りGmail API があり、SMTP や POP3 を使うことなく、検索機能など Gmail 独自の機能を活用することができます。
この記事では、Python を使って Gmail API の有効化から、スクリプトの作成、実行までの手順をステップバイステップで紹介します。

Gmail API を利用するまでに、プロジェクトを作成したり、APIを有効化したり、スコープを設定したり、認証情報を作成したりと、準備が割と大変なので、備忘録として書きました。

* 2020年4月時点の Gmail API の内容です。
* Python 3.7.x を想定しています。


利用前の準備
===============

Goole API を利用するプロジェクトを作成する
----------------------------------------

利用する Gmail アカウントの Gmail を開いたブラウザで、 Google Cloud Platform コンソール （ https://console.cloud.google.com/ ）を開きます。

下記の画面が出るので規約等に同意し、「同意して実行」をクリックします。

<img width="585" alt="A00.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/83336e53-5340-9f03-6b7c-3051c686b827.jpeg">

「プロジェクトの選択」から「新しいプロジェクト」を選択
<img width="586" alt="A01.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/d0e03441-2e87-ff41-f815-efe8d1136ba5.jpeg">

プロジェクト名を設定して「作成」をクリック
<img width="586" alt="A02.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/3150bee1-6c01-94d6-df7b-48a138ee01e2.jpeg">

作成できると、下記のような画面が表示されます
<img width="586" alt="A03.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/2ee0d331-f228-269a-829d-94aeb894fdd5.jpeg">

Gmail APIを有効化する
-------------------------------

「ライブラリ」をクリック
<img width="587" alt="A04.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/f6799ac0-ea46-1681-411a-d97f5014f5f8.jpeg">


「APIとサービスを検索」から「Gmail API」を検索する
<img width="583" alt="A05.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/98deb6aa-b605-d122-5506-e7003537718d.jpeg">

表示された「Gmail API」をクリックする
<img width="586" alt="A06.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/e76cb60b-1831-bfce-3c51-59f7995fcf06.jpeg">

「有効にする」をクリック
<img width="585" alt="A07.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/dd95eda9-6776-3345-dc35-2df6fd27efe7.jpeg">

OAuth同意画面を作成する
-----------------------------------

「APIとサービス」から「OAuth同意画面」をクリック
<img width="586" alt="A08.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/d5789e4c-ab35-90b5-ee69-70b20da4da09.jpeg">


「外部」を選択し、「作成」をクリック
<img width="586" alt="A09.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/a50bdcde-17be-a89f-9871-1d1b5fd5c672.jpeg">


「アプリケーション名」に適切な名前を設定し、「スコープを追加」をクリック
<img width="586" alt="A10.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/06a1cde5-2ca0-7c86-83da-52246083b4ad.jpeg">


下図のようにスコープを選択し、「追加」をクリック
<img width="392" alt="A11.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/560e77ab-0c3f-e59f-bb81-273bfd4ef95d.jpeg">


「保存」をクリック
<img width="585" alt="A12.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/c95b5bcf-d4ed-a059-2506-abb444fb38fc.jpeg">

認証情報を作成する
------------------------

### 2020年6月14日現在、下記の手順でウィザードを実行すると、エラーが発生するようです。下記手順で回避できました。

「認証情報」を選択し「認証情報を作成」をクリックして表示されるメニューから「OAuthクライアントID」をクリック
![OauthClientID.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/e2796b1d-cfed-3c7c-9e0f-0dcc0babc6d3.jpeg)

「アプリケーションの種類」で「デスクトップアプリ」を選択
![DesktopApp.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/924aea69-e18c-9aca-0c71-a8e10d5560dc.jpeg)

作成完了のダイアログが表示されるので「OK」をクリック
![ID_and_Secret.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/4678a4eb-1d17-5fb1-3faa-f1fe8cdd24a1.jpeg)

あとは、一覧画面から認証情報をダウンロード（client_id.json）します。

### ウィザードを使う

「認証情報」を選択し「認証情報を作成」をクリックして表示されるメニューから「ウィザードで選択」をクリック
<img width="586" alt="A13.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/e1ff0ea0-daa3-d329-70a6-1d8cd6ffc1bb.jpeg">


「プロジェクトへの認証情報の追加」画面で、下図のように設定し、「必要な認証情報」をクリック
<img width="585" alt="A14.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/9381f358-293a-b3a3-29c6-915465471b10.jpeg">


「OAuthクライアントIDを作成」をクリック
<img width="585" alt="A15.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/9fa09b9f-3bbf-99d3-525c-8d00302021ec.jpeg">


認証情報をダウンロード（client_id.json）し、「完了」をクリック
<img width="585" alt="A16.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/c7b1149a-4213-d2c7-b906-82933f41df1c.jpeg">


完了ボタンを押すと下記のような画面に遷移します
<img width="584" alt="A17.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/a5cd68f6-25e8-6988-7fa3-9a99df83e846.jpeg">


ダウンロードした認証情報ファイル client_id.json は後ほど Python スクリプトから利用します。


補足：認証情報について
------------------------

認証情報ではAPIキー、OAuth2.0クライアントID、サービスアカウントキーのいずれかを作成できます。ユーザーデータを扱う場合は、OAuth2.0クライアントID を使うことが一般的のようです。
[Google Cloud ドキュメント 認証の概要](https://cloud.google.com/docs/authentication?hl=ja)ページに、それぞれの認証方法の用途について解説されています。

クレデンシャルの Refresh Token の有効期限については、下記に記載があります。一度認証して Refresh Token が払い出されると、6ヶ月放置するか、50以上のトークンが払い出されない限り、期限切れになることはないようです。RPA などの自動化の仕組みでも許容できそうです。
https://developers.google.com/identity/protocols/OAuth2#expiration

```
You must write your code to anticipate the possibility that a granted refresh token might no longer work. A refresh token might stop working for one of these reasons:

* The user has revoked your app's access.
* The refresh token has not been used for six months.
* The user changed passwords and the refresh token contains Gmail scopes.
* The user account has exceeded a maximum number of granted (live) refresh tokens.
* There is currently a limit of 50 refresh tokens per user account per client. If the limit is reached, creating a new refresh token automatically invalidates the oldest refresh token without warning. This limit does not apply to service accounts.
```

Python スクリプトの準備
=======================================
ようやく準備が整いましたので、これから実行する Python スクリプトを用意します。

Python 環境の準備
--------------------
（この部分はすでにすんでいる方も多いと思います）

### Python のインストール
[ここ](https://www.python.jp/install/windows/install_py3.html) にしたがってインストールを行います。3.7.x をインストールしてください。

### pipenv のインストール
[ここ](http://pipenv-ja.readthedocs.io/ja/translate-ja/install.html#installing-pipenv) の手順に従ってインストールします。

Python スクリプトの作成
---------------------
適当なディレクトリを用意し、python スクリプトを準備します。先の手順で保存した client_id.json も同じディレクトリに保存します。Gmail API のサンプルをつなぎ合わせました :sweat_drops:

Gmail API のリファレンスに、サンプルコードが載ってます。ここまでくると利用しやすいですね！

* [Users.messages: list](https://developers.google.com/gmail/api/v1/reference/users/messages/list)
* [Users.labels: list](https://developers.google.com/gmail/api/v1/reference/users/labels/list)


```python:gmail_credential.py

import os
import pickle
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow


# Gmail APIのスコープを設定
SCOPES = [
    "https://www.googleapis.com/auth/gmail.compose",
    "https://www.googleapis.com/auth/gmail.readonly",
    "https://www.googleapis.com/auth/gmail.labels",
    "https://www.googleapis.com/auth/gmail.modify",
]


def get_credential():
    """
    アクセストークンの取得

    カレントディレクトリに pickle 形式でトークンを保存し、再利用できるようにする。（雑ですみません。。）
    """
    creds = None
    if os.path.exists("token.pickle"):
        with open("token.pickle", "rb") as token:
            creds = pickle.load(token)
    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("client_id.json", SCOPES)
            # creds = flow.run_local_server()
            creds = flow.run_console()
        with open("token.pickle", "wb") as token:
            pickle.dump(creds, token)
    return creds
```


```python:listmail.py

"""
list GMail Inbox.

Usage:
  listmail.py <query> <tag> <count>
  listmail.py -h | --help
  listmail.py --version

Options:
  -h --help     Show this screen.
  --version     Show version.
"""
import pickle
import base64
import json
import io
import csv
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import base64
from email.mime.text import MIMEText
from apiclient import errors
import logging
from docopt import docopt
from gmail_credential import get_credential

logger = logging.getLogger(__name__)


def list_labels(service, user_id):
    """
    label のリストを取得する
    """
    labels = []
    response = service.users().labels().list(userId=user_id).execute()
    return response["labels"]


def decode_base64url_data(data):
    """
    base64url のデコード
    """
    decoded_bytes = base64.urlsafe_b64decode(data)
    decoded_message = decoded_bytes.decode("UTF-8")
    return decoded_message


def list_message(service, user_id, query, label_ids=[], count=3):
    """
    メールのリストを取得する

    Parameters
    ----------
    service : googleapiclient.discovery.Resource
        Gmail と通信するためのリソース
    user_id : str
        利用者のID
    query : str
        メールのクエリ文字列。 is:unread など
    label_ids : list
        検索対象のラベルを示すIDのリスト
    count : str
        リターンするメール情報件数の上限

    Returns
    ----------
    messages : list
        id, body, subject, from などのキーを持った辞書データのリスト
    """
    messages = []
    try:
        message_ids = (
            service.users()
            .messages()
            .list(userId=user_id, maxResults=count, q=query, labelIds=label_ids)
            .execute()
        )

        if message_ids["resultSizeEstimate"] == 0:
            logger.warning("no result data!")
            return []

        # message id を元に、message の内容を確認
        for message_id in message_ids["messages"]:
            message_detail = (
                service.users()
                .messages()
                .get(userId="me", id=message_id["id"])
                .execute()
            )
            message = {}
            message["id"] = message_id["id"]
            # 単純なテキストメールの場合
            if 'data' in message_detail['payload']['body']:
                message["body"] = decode_base64url_data(
                    message_detail["payload"]["body"]["data"]
                )
            # html メールの場合、plain/text のパートを使う
            else:
                parts = message_detail['payload']['parts']
                parts = [part for part in parts if part['mimeType'] == 'text/plain']
                message["body"] = decode_base64url_data(
                    parts[0]['body']['data']
                    )
            # payload.headers[name: "Subject"]
            message["subject"] = [
                header["value"]
                for header in message_detail["payload"]["headers"]
                if header["name"] == "Subject"
            ][0]
            # payload.headers[name: "From"]
            message["from"] = [
                header["value"]
                for header in message_detail["payload"]["headers"]
                if header["name"] == "From"
            ][0]
            logger.info(message_detail["snippet"])
            messages.append(message)
        return messages

    except errors.HttpError as error:
        print("An error occurred: %s" % error)


def remove_labels(service, user_id, messages, remove_labels):
    """
    ラベルを削除する。既読にするために利用(is:unread ラベルを削除すると既読になる）
    """
    message_ids = [message["id"] for message in messages]
    labels_mod = {
        "ids": message_ids,
        "removeLabelIds": remove_labels,
        "addLabelIds": [],
    }
    # import pdb;pdb.set_trace()
    try:
        message_ids = (
            service.users()
            .messages()
            .batchModify(userId=user_id, body=labels_mod)
            .execute()
        )
    except errors.HttpError as error:
        print("An error occurred: %s" % error)


# メイン処理
def main(query="is:unread", tag="daily_report", count=3):
    creds = get_credential()
    service = build("gmail", "v1", credentials=creds, cache_discovery=False)
    # ラベル一覧
    labels = list_labels(service, "me")
    target_label_ids = [label["id"] for label in labels if label["name"] == tag]
    # メール一覧 [{'body': 'xxx', 'subject': 'xxx', 'from': 'xxx'},]
    messages = list_message(service, "me", query, target_label_ids, count=count)
    # unread label
    unread_label_ids = [label["id"] for label in labels if label["name"] == "UNREAD"]
    # remove labels form messages
    remove_labels(service, "me", messages, remove_labels=unread_label_ids)
    logger.info(json.dumps(messages, ensure_ascii=False))
    if messages:
        return json.dumps(messages, ensure_ascii=False)
    else:
        return None


# プログラム実行部分
if __name__ == "__main__":
    arguments = docopt(__doc__, version="0.1")
    query = arguments["<query>"]
    tag = arguments["<tag>"]
    count = arguments["<count>"]
    logging.basicConfig(level=logging.DEBUG)

    messages_ = main(query=query, tag=tag, count=count)
    print(messages_)
```

送信スクリプトはこんな感じです。
※ [Users.messages.send](https://developers.google.com/gmail/api/v1/reference/users/messages/send) の Python サンプルコードでは、 ```return {'raw': base64.urlsafe_b64encode(message.as_string())}``` となっていましたが、それでは動かなかったので修正しています。

```python:sendmail.py
"""
Send E-Mail with GMail.

Usage:
  sendmail.py <sender> <to> <subject> <message_text_file_path>  [--attach_file_path=<file_path>] [--cc=<cc>]
  sendmail.py -h | --help
  sendmail.py --version

Options:
  -h --help     Show this screen.
  --version     Show version. 
  --attach_file_path=<file_path>     Path of file attached to message.
  --cc=<cc>     cc email address list(separated by ','). Default None.
"""
import pickle
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import base64
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from email.mime.audio import MIMEAudio
from pathlib import Path

from email.mime.multipart import MIMEMultipart
import mimetypes
from apiclient import errors
from gmail_credential import get_credential
from docopt import docopt
import logging

logger = logging.getLogger(__name__)


def create_message(sender, to, subject, message_text, cc=None):
    """
    MIMEText を base64 エンコードする
    """
    enc = "utf-8"
    message = MIMEText(message_text.encode(enc), _charset=enc)
    message["to"] = to
    message["from"] = sender
    message["subject"] = subject
    if cc:
        message["Cc"] = cc
    encode_message = base64.urlsafe_b64encode(message.as_bytes())
    return {"raw": encode_message.decode()}


def create_message_with_attachment(
    sender, to, subject, message_text, file_path, cc=None
):
    """
    添付ファイルつきのMIMEText を base64 エンコードする
    """
    message = MIMEMultipart()
    message["to"] = to
    message["from"] = sender
    message["subject"] = subject
    if cc:
        message["Cc"] = cc
    # attach message text
    enc = "utf-8"
    msg = MIMEText(message_text.encode(enc), _charset=enc)
    message.attach(msg)

    content_type, encoding = mimetypes.guess_type(file_path)

    if content_type is None or encoding is not None:
        content_type = "application/octet-stream"
    main_type, sub_type = content_type.split("/", 1)
    if main_type == "text":
        with open(file_path, "rb") as fp:
            msg = MIMEText(fp.read(), _subtype=sub_type)
    elif main_type == "image":
        with open(file_path, "rb") as fp:
            msg = MIMEImage(fp.read(), _subtype=sub_type)
    elif main_type == "audio":
        with open(file_path, "rb") as fp:
            msg = MIMEAudio(fp.read(), _subtype=sub_type)
    else:
        with open(file_path, "rb") as fp:
            msg = MIMEBase(main_type, sub_type)
            msg.set_payload(fp.read())
    p = Path(file_path)
    msg.add_header("Content-Disposition", "attachment", filename=p.name)
    message.attach(msg)

    encode_message = base64.urlsafe_b64encode(message.as_bytes())
    return {"raw": encode_message.decode()}


def send_message(service, user_id, message):
    """
    メールを送信する

    Parameters
    ----------
    service : googleapiclient.discovery.Resource
        Gmail と通信するためのリソース
    user_id : str
        利用者のID
    message : dict
        "raw" を key, base64 エンコーディングされた MIME Object を value とした dict

    Returns
    ----------
    なし
    """
    try:
        sent_message = (
            service.users().messages().send(userId=user_id, body=message).execute()
        )
        logger.info("Message Id: %s" % sent_message["id"])
        return None
    except errors.HttpError as error:
        logger.info("An error occurred: %s" % error)
        raise error


#  メイン処理
def main(sender, to, subject, message_text, attach_file_path, cc=None):
    # アクセストークンの取得とサービスの構築
    creds = get_credential()
    service = build("gmail", "v1", credentials=creds, cache_discovery=False)
    if attach_file_path:
        # メール本文の作成
        message = create_message_with_attachment(
            sender, to, subject, message_text, attach_file_path, cc=cc
        )
    else:
        message = create_message(
            sender, to, subject, message_text, cc=cc
        )
    # メール送信
    send_message(service, "me", message)


# プログラム実行部分
if __name__ == "__main__":
    arguments = docopt(__doc__, version="0.1")
    sender = arguments["<sender>"]
    to = arguments["<to>"]
    cc = arguments["--cc"]
    subject = arguments["<subject>"]
    message_text_file_path = arguments["<message_text_file_path>"]
    attach_file_path = arguments["--attach_file_path"]

    logging.basicConfig(level=logging.DEBUG)

    with open(message_text_file_path, "r", encoding="utf-8") as fp:
        message_text = fp.read()

    main(
        sender=sender,
        to=to,
        subject=subject,
        message_text=message_text,
        attach_file_path=attach_file_path,
        cc=cc,
    )

```


### python スクリプトの実行準備

python スクリプトを作成したディレクトリに移動し、必要なモジュールをインストールします。

```
% pipenv install google-api-python-client oauth2client google-auth-httplib2 google-auth-oauthlib docopt
```

Python スクリプトの実行
------------------------

```
% pipenv run python listmail.py is:unread 見積もり依頼 10
```
などと実行すると、初回実行時にには cmd.exe の画面に OAuth 認証画面URL が表示されるので、ブラウザから開き、承認します。2回目以降は OAuth 認証画面URLは表示されません。

```
% pipenv run python listmail.py is:unread 見積もり依頼 10
Please visit this URL to authorize this application: https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=AAAAAAAAAAAAAA
Enter the authorization code:
```

上記のコンソールに表示されるURLを開くと、下記のような画面が表示されます。
<img width="387" alt="A18.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/451b61aa-6704-f45b-cd80-7c803b49c8e5.jpeg">


「詳細」をクリックして表示されるリンク「〇〇（安全ではないページ）に移動」をクリックします。
![A19.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/71d0d1b6-669a-ccbb-7707-225c967d9bac.jpeg)


権限付与のダイアログが何度か表示されるので、それぞれ許可します。
![A20.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/268eab6a-c05d-abf7-f672-8b3da7ed253c.jpeg)


最後にダメおしで確認画面が表示されるので、許可します。
![A21.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/32057ba2-c442-1922-dde6-6a3d746765f0.jpeg)


下記画面からコードをコピーし、```Enter the authorization code: ``` に続けてペーストすると、スクリプトが実行されます。
![A22.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564576/8fe11796-eb54-3048-44be-cca6120a83ac.jpeg)

さいごに
====================
Gmail の強力なフィルタ、ラベル機能やクエリをそのまま使えるので、Gmail APIは面白いですね。