本業の機械系設計事務所ではご依頼いただいた案件についてメールにて作業に必要なデータや書類を頂くことが多いです。そのため、メールの収集を自動化することで作業の効率化を図っています。
会社ではGoogle WorkspaceのGmailを使っていますが、今回はGmail APIとPythonのクライアントライブラリを使ってメールの収集する方法をお伝えします。
しばらくシリーズものになりそうです。今回はメール本体を収集してメール本文を見てみましょう。
Gmail API シリーズはこちら
- PythonでGmail APIに触れてみる: メールを取得してみよう
- Pythonで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の中身はメールプロトコルやフォーマットの知識が必要になります。
{
"id": string,
"threadId": string,
"labelIds": [
string
],
"snippet": string,
"historyId": string,
"internalDate": string,
"payload": {
object (MessagePart)
},
"sizeEstimate": integer,
"raw": string
}
メールフォーマットの種類や構造を見る
payloadはGmail APIとしてはMessagePartというオブジェクトとして表現されます。
{
"partId": string,
"mimeType": string,
"filename": string,
"headers": [
{
object (Header)
}
],
"body": {
object (MessagePartBody)
},
"parts": [
{
object (MessagePart)
}
]
}
その中にあるheadersはメールヘッダーの意味です。構造は見やすいのですが、ヘッダー名とその値はそれぞれname,valueという名前がついてjson値になっているので取り出すときがやや面倒かもです。(事前変換して辞書型にしてもよさそうですね)
{
"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/mixed
や multipart/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/plane
とtext/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の解説していこうと思います。次回はメールの検索について扱います。
参考
- Gmail API: Gmail API の概要 | Google for Developers
- メールのMIMEType解説:MIME(Multipurpose Internet Mail Extensions)~後編:インターネット・プロトコル詳説(4) - @IT
- サンプルコード
宣伝
所属会社の宣伝です! 自動車プレス金型の機械設計を行う設計事務所で、製品や試作品の3Dモデリングのお仕事も受け付けています。
また製造業の業務、バックオフィスの自動化やその先に繋がるデジタル化、DXに取り組みたい方もご相談承っております。 今回の記事で扱ったGmail等のメールの扱いや、データを抽出して作業の自動化を行いたい方は、ぜひお気軽にお問い合わせください!
- お問い合わせは設計事務所のWEBサイトにて!