PythonでGmail APIに触れてみる: メールを取得してみよう

PythonでGmail APIに触れてみる: メールを取得してみよう

本業の機械系設計事務所ではご依頼いただいた案件についてメールにて作業に必要なデータや書類を頂くことが多いです。そのため、メールの収集を自動化することで作業の効率化を図っています。

会社ではGoogle WorkspaceのGmailを使っていますが、今回はGmail APIとPythonのクライアントライブラリを使ってメールの収集する方法をお伝えします。

しばらくシリーズものになりそうです。今回はメール本体を収集してメール本文を見てみましょう。

Gmail API シリーズはこちら

Gmail APIを使うには

Gmail APIはGoogle(Google Workspace)のAPIの1つです。利用するためにはGoogleアカウントやGmailの利用が前提になっています。

またGoogle Cloud Platformの一部でもあるので、Google Cloud Consoleも扱います。

その他ガイドやリファレンスは公式ドキュメントを参照してください。

Gmail API の概要  |  Google for Developers

PythonのクライアントライブラリはGoogleが提供しているので、pipでインストールできます。

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

はじめてGmail APIをPythonから触るときは、Pythonのクイックスタートページを見て操作をしてみましょう。操作を進めると(この記事公開時点では)認証を許可したGmailアカウントのラベル一覧が表示されるサンプルコードが実行できるようです。

Python のクイックスタート  |  Gmail  |  Google for Developers

最初の解説は公式ドキュメントを見て進めるほうが早いこともあるので、この記事では割愛します。

Gmail APIの有効化、OAuth同意画面の作成(今回の例は内部タイプにしてます)、OAuth 2.0クライアントIDの作成とJsonファイルのダウンロード、を含めてクイックスタートまでの設定ができている前提で記事を進めていきます。(この部分が割と大変かもしれませんが…

Gmail APIでメールメッセージを収集する

Gmailでメールを収集した後、たとえば受信トレイのメールの一覧を収集してみます。APIとしてはuser.listを利用します。受信トレイのメールラベルIDはINBOXです。

一覧自体は、user.listで取得します。その時はメールのメッセージIDが手に入りますが、メール本体の情報はメッセージIDを元にuser.getで取得することで取得できます。

from pathlib import 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

SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]

# トークンとクレデンシャルの保存先
token_save_path = Path("google_api_access_token.json")
cred_json = Path("credentials.json")


# クレデンシャルの取得用の関数
def get_cledential(scopes: list[str]) -> Credentials:
    """
    Google APIの認証情報をOAuth2の認証フロー(クライアントシークレット)で取得します。
    既に認証情報があればそれを返します。
    リフレッシュトークンに対応しています。
    なければ認証情報を取得し、google_api_access_token.jsonに保存します。
    args:
        scopes: 認証情報を取得する際に必要なスコープ
    return:
        認証情報
    """
    creds = None
    if token_save_path.exists():
        creds = Credentials.from_authorized_user_file(token_save_path, 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(cred_json, scopes)
            creds = flow.run_local_server(port=8081)
        with token_save_path.open("w") as token:
            token.write(creds.to_json())
    return creds


def main():
    creds = get_cledential(SCOPES)
    try:
        service = build("gmail", "v1", credentials=creds)

        # 受信トレイのメール一覧を収集
        result = (
            service.users().messages().list(userId="me", labelIds="INBOX").execute()
        )
        # message IDの一覧を取得
        inbox_messges = result.get("messages", [])

        if not inbox_messges:
            print("No messages in inbox.")
        else:
            print("Inbox Messages:")
            # メールが多い場合を想定して最大10件を表示
            for message in inbox_messges[0:10]:
                print(message["id"])
                # メッセージIDを元にメッセージを取得
                msg = (
                    service.users()
                    .messages()
                    .get(userId="me", id=message["id"])
                    .execute()
                )
                # メッセージの冒頭(スニペット)を表示
                print(msg["snippet"])

    # エラーハンドリング
    except HttpError as error:
        print(f"An error occurred: {error}")


if __name__ == "__main__":
    main()

メールリソースの構造を理解する

ここまでで、メール本体の情報(メールメッセージ)までたどりつきました。この後に、メールメッセージからメールのヘッダーや本文、添付ファイルを取得します。

その時に必ず当たってしまう壁として、メールの構造になります。私もここで苦労していたので、調べつつまとめてみます。

Messageリソースの構造を把握する

gmailのメール本体は、メッセージリソース(Message Resource)として表現されます。APIでのやり取りはjson文字列を使います。

構造としては公式リファレンスを参考に、ですがpayloadの中身はメールプロトコルやフォーマットの知識が必要になります。

REST Resource: Message

{
  "id": string,
  "threadId": string,
  "labelIds": [
    string
  ],
  "snippet": string,
  "historyId": string,
  "internalDate": string,
  "payload": {
    object (MessagePart)
  },
  "sizeEstimate": integer,
  "raw": string
}

メールフォーマットの種類や構造を見る

payloadはGmail APIとしてはMessagePartというオブジェクトとして表現されます。

REST Resource: MessagePart

{
  "partId": string,
  "mimeType": string,
  "filename": string,
  "headers": [
    {
      object (Header)
    }
  ],
  "body": {
    object (MessagePartBody)
  },
  "parts": [
    {
      object (MessagePart)
    }
  ]
}

その中にあるheadersはメールヘッダーの意味です。構造は見やすいのですが、ヘッダー名とその値はそれぞれname,valueという名前がついてjson値になっているので取り出すときがやや面倒かもです。(事前変換して辞書型にしてもよさそうですね)

REST Resource: Header

{
  "name": string,
  "value": string
}

メール本文ですが、partsの部分はメールのMIMETypeを考慮して判断する必要があります。たとえばテキストだとtext/plain、HTMLだとtext/htmlになります。

シンプルなテキストメールの例はこういった構造

  "payload": {
    "partId": "",
    "mimeType": "text/plain",
    "filename": "",
    "headers": [
      {
        "name": "MIME-Version",
        "value": "1.0"
      },...
    ],
    "body": {
      "size": [BYTES],
      "data": "[BASE64_ENCODED_BYTES]"
    }

添付ファイルや画像が入る場合はmultipart/mixedmultipart/alternative, multipart/relatedが使われます。これらはpartsに入っているので、再帰的に探索する必要があります。これが非常に厄介。

mixedの一例はこんな感じです

  "payload": {
    "partId": "",
    "mimeType": "multipart/mixed",
    "filename": "",
    "headers": [
      {
        "name": "MIME-Version",
        "value": "1.0"
      },...
    ],
    "parts": [
      {
        "partId": "",
        "mimeType": "multipart/alternative",
        "filename": "",
        "headers": [
          {
            "name": "MIME-Version",
            "value": "1.0"
          },...
        ],
        "parts": [
          {
            "partId": "",
            "mimeType": "text/plain",
            "filename": "",
            "headers": [
                ...
            ],
            "body": {...}
          },
          {
            "partId": "",
            "mimeType": "text/html",
            "filename": "",
            "headers": [...
            ],
            "body": {...}
          }
        ]
      },
      ...{さらに添付ファイルもpartsに入っている}
    ]
  }

他にもaltanative単体やmixed>related>altanativeの入れ子があったり、ほかにも把握できていないパターンもありそうで、パターンを上げていくとしんどいです。

再帰的探索をして取得をしてみた

そこで、今回は再帰的に探索する関数を作ってみました。今回はメール本文の取得したいので。text/planetext/htmlがどちらともある場合はtext/plainを取得します。

サンプルコードは以下のような感じです。

import base64
from pathlib import 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

SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]

# トークンとクレデンシャルの保存先
token_save_path = Path("google_api_access_token.json")
cred_json = Path("credentials.json")


# クレデンシャルの取得用の関数
def get_cledential(scopes: list[str]) -> Credentials:
    creds = None
    if token_save_path.exists():
        creds = Credentials.from_authorized_user_file(token_save_path, 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(cred_json, scopes)
            creds = flow.run_local_server(port=8081)
        with token_save_path.open("w") as token:
            token.write(creds.to_json())
    return creds


def find_message_parts_text(message, message_parts=None):
    """
    メッセージから text/plain と text/html の部分を再帰的に探索する関数
    """
    if message_parts is None:
        message_parts = {"text/plain": None, "text/html": None}

    mimetype = message.get("mimeType")
    data = message.get("body", {}).get("data")

    if mimetype == "text/plain" and data:
        message_parts["text/plain"] = base64.urlsafe_b64decode(data).decode("utf-8")
    elif mimetype == "text/html" and data:
        message_parts["text/html"] = base64.urlsafe_b64decode(data).decode("utf-8")

    for part in message.get("parts", []):
        find_message_parts_text(part, message_parts)

    return message_parts

def main():
    creds = get_cledential(SCOPES)
    try:
        service = build("gmail", "v1", credentials=creds)

        # 受信トレイのメールメッセージを収集
        result = (
            service.users().messages().list(userId="me", labelIds="INBOX").execute()
        )
        inbox_messges = result.get("messages", [])
        if not inbox_messges:
            print("No messages in inbox.")
        else:
            print("Inbox Messages:")
            # メールが多い場合を想定して最大3件まで表示
            for message_info in inbox_messges[0:3]:
                print(f"gmail message id:{message_info['id']}")
                # メール本文を取得
                message = (
                    service.users()
                    .messages()
                    .get(userId="me", id=message_info["id"], format="full")
                    .execute()
                )

                # messageのpaylodからmimetypeを元に、本文を取得
                msg_payload = message["payload"]

                # 探索的にtext/planeを探して表示する。text/htmlのみのメールもあるので注意
                message_parts = find_message_parts_text(msg_payload)
                message_text = message_parts["text/plain"] or message_parts["text/html"]
                if message_text:
                    # 20文字まで出している
                    print(f"{message_text[0:20]}\n")

    # エラーハンドリング
    except HttpError as error:
        print(f"An error occurred: {error}")


if __name__ == "__main__":
    main()

実は、このコードはChatGPTを相談しながら作っていましたが、社内で利用しているコードだと、愚直にmultipart/**を探したりしているのであんまり筋が良くないコードでした。思えば目的のMIMETypeを探せばいいだけでしたね。

また、メール本文は通常はBASE64のurlセーフでエンコードされているので、本文から何らかの情報を得たい場合はデコードは必須です。

まとめ

社内でメールを収集するコードを書いているときに、MIMETypeの構造を理解するのが一番大変でした。いくつかある構造の中でどこまで対応するべきかで悩みどころで、目的を狙い撃ちするほうがよさそうです。

適当にメールを収集してMIMETypeの入れ子構造を調べてみました。上位5種類を上げています。メールクライアントでいろいろと違いがあるんだろうなあと感じるところですね

(調査の深追いすると怖いのでやめました。multipart/signedはこの調査ではじめて知った。)

multipart/alternative
  text/plain
  text/html
 : 62

text/plain
 : 22

text/html
 : 5

multipart/mixed
  multipart/alternative
    text/plain
    text/html
 : 4

multipart/signed
  multipart/alternative
    text/plain
    text/html
  application/x-pkcs7-signature
 : 2

利用したコード

Gmail APIというか、メールの奥底は深いものがある~魔境ともいえる~ものですが、それだけ歴史があるということでもあります。先人にもメールクライアントにも感謝しかないです。

今後もGmail APIの解説していこうと思います。次回はメールの検索について扱います。

参考

宣伝

所属会社の宣伝です! 自動車プレス金型の機械設計を行う設計事務所で、製品や試作品の3Dモデリングのお仕事も受け付けています。

また製造業の業務、バックオフィスの自動化やその先に繋がるデジタル化、DXに取り組みたい方もご相談承っております。 今回の記事で扱ったGmail等のメールの扱いや、データを抽出して作業の自動化を行いたい方は、ぜひお気軽にお問い合わせください!

About Me

買ったり作ったり考えたり試したの日々の記録です