FileMakerで本格的なメールクライアントを作ったらGmail APIの知見を得たのでPythonで解説する
はじめに
弊社では、以前から業務でFileMakerを使っています。FileMakerの弱点のひとつに、「標準機能だけではメール受信ができない」というものがあります。専用のプラグインをインストールすれば受信できるのですが、有料のプラグインはメンテナンスが面倒なので、あまり使いたくありません。そこで、Gmail APIを使ってメール受信を実装することにしました。どうせなら、完全なメールクライアント機能を実現しようと考え、受信だけでなく送信やスレッド制御も実装しました。
開発する過程でGmail APIに関するいくつかの知見を得られたので、本記事で公開することにしました。FileMakerスクリプトは可読性が低いため、本記事のサンプルコードはPythonで書いています。もしFileMakerの処理に興味がある場合は、アプリをダウンロードしてスクリプトをご覧ください。
今回作ったアプリケーションは、こちらで公開しています。MITライセンスですので、自由にお使いいただけます。
本記事の内容
本記事では、以下の内容について解説します。
- Gmail APIを用いたメール受信時のレスポンス解析について
- スレッドを考慮したメール送信方法について
これらの情報は、検索してもなかなか見つからなかったので、Gmail APIを使った開発を行う方にとって有用な情報になると思われます。
メール受信時のレスポンス解析について
Gmail APIを用いて添付ファイル付きのメールを受信する場合の手順はなかなか複雑です。順番に記載すると、以下のようになります。
- アクセストークンを取得する。
- メッセージ一覧を取得する。Method: users.messages.list
- 個別のメッセージを取得する。Method: users.messages.get
- ラベルを更新して既読にする。Method: users.messages.modify
- 個別メッセージのレスポンスを解析して、ヘッダーや本文、添付ファイル等の情報を取り出す。
- 添付ファイル本体を取得する。Method: users.messages.attachments.get
たった1通のメールを受信するのに、実に4回ものAPIリクエストが必要です。ただ、API呼び出しはリファレンスどおりに記述すればよいので、さほど難しくはありません。やっかいなのは5番目のレスポンス解析です。それでは詳しく説明していきましょう。
レスポンスの構造
受信した個別メッセージのレスポンスは、次のような構造をしています。ドキュメントはこちらです。
{
"id": string,
"threadId": string,
"labelIds": [
string
],
"snippet": string,
"historyId": string,
"internalDate": string,
"payload": {
object (MessagePart)
},
"sizeEstimate": integer,
"raw": string
}
payload
の中にあるMessagePart
は、次のような構造をしています。
{
"partId": string,
"mimeType": string,
"filename": string,
"headers": [
{
object (Header)
}
],
"body": {
object (MessagePartBody)
},
"parts": [
{
object (MessagePart)
}
]
}
Header
とMessagePartBody
については後述するので、ここではparts
の中にMessagePart
の配列が存在することに注目してください。これは、MessagePart
が1対多の入れ子構造になっていることを示しています。また、各項目には次のような特徴があります。
-
mimeType
は、様々な値を取り得ます。レスポンスの解析において特に重要なのは、text/plain
とtext/html
です。 -
filename
は、添付ファイルがある場合のみ存在します。 -
headers
は、トップレベルのMessagePart
のみに存在します。 -
body
には、メール本文が格納されています。空の場合もあります。 -
parts
は、mimeType
がコンテナMIMEメッセージ(multipart/*
)の場合にのみ存在します。
つづいて、Header
とMessagePartBody
についても説明します。
Header
は、次のような構造をしています。
{
"name": string,
"value": string
}
MessagePartBody
は、次のような構造をしています。
{
"attachmentId": string,
"size": integer,
"data": string
}
MessagePardBody
の各項目には、次のような特徴があります。
-
attachmentId
は、添付ファイルがある場合にのみ存在します。 -
data
は、Base64エンコードされた文字列です。これをデコードすることで、メールの本文を取得できます。
以上が、個別メッセージのレスポンスの構造です。parts
が入れ子になっているため、理論上無限のパターンを取り得るのがやっかいなところです。本文がどこに格納されるのかさえ決まっていません。実際に弊社に送られてきたメールでも、本文の格納箇所はバラバラで、少し調べただけでも以下のパターンが見つかりました。
payload.body.data
payload.parts[0].body.data
payload.parts[0].parts[0].body.data
payload.parts[1].body.data
しかもこれらが全てではありません。したがって、メール本文や添付ファイルを取りこぼしなく解析するためには、再帰処理が必須となります。これを踏まえて、解析処理を実装してみましょう。
レスポンス解析処理の実装
次に示すサンプルコードは、説明用に簡略化したものです。実際のコードはもっと複雑です。
import base64
def decode_base64_data(encoded_data):
if encoded_data:
return base64.urlsafe_b64decode(encoded_data).decode('utf-8')
return ''
def extract_message_parts(part, result):
# MIMEタイプに基づいて、メール本文を抽出
if part['mimeType'] == 'text/plain' or part['mimeType'] == 'text/html':
data = part['body'].get('data', '')
if not result[part['mimeType']]:
result[part['mimeType']] = decode_base64_data(data)
# 添付ファイルがある場合、そのIDを抽出
if 'filename' in part and part['filename']:
attachment_id = part['body'].get('attachmentId', '')
if attachment_id:
result['attachments'].append({
'filename': part['filename'],
'attachmentId': attachment_id
})
# `parts`が存在する場合、再帰的に処理
if 'parts' in part:
for subpart in part['parts']:
extract_message_parts(subpart, result)
def extract_email_data(response):
result = {
'text/plain': '',
'text/html': '',
'attachments': [],
'headers': {}
}
# ヘッダー情報の抽出
if 'headers' in response['payload']:
for header in response['payload']['headers']:
result['headers'][header['name']] = header['value']
# payloadからMessagePartsを抽出
extract_message_parts(response['payload'], result)
return result
# Gmail APIのレスポンス(ここではダミーのレスポンスを使用)
response = {
# レスポンスの内容をここに入れる
}
# メールデータの抽出
email_data = extract_email_data(response)
# 結果の出力
print(email_data)
このコードでは、Gmail APIのレスポンスから、主要なメールヘッダーとメール本文、および添付ファイルのIDを抽出しています。extract_message_parts
で再帰処理を行っているのがポイントですね。これで理論上はどんなパターンのレスポンスが来ても対応できます。
スレッドを考慮したメール送信方法
次に、スレッドを考慮したメール送信方法について説明します。Gmail APIでは、送信にも受信と同じMessage
オブジェクトを使います。ただし、実際に設定するのはraw
のみです。つまり、次に示すようなJSONを送信するわけです。
{
"raw": string
}
単純ですが、単純すぎてかえって難しいですね。これだけでは何を設定したらよいかわかりません。それでも公式ドキュメントにサンプルコードが載っているので、単純なメール送信で困ることはないでしょう。では、次のような場合はどうでしょうか。
- 既存のスレッドに返信する場合
- マルチバイトのヘッダー項目(サブジェクトや宛先など)を含むメールを送信する場合
メールのスレッド制御方法は、RFC 2822に記載されています。受信したメッセージに返信する場合は、メールヘッダーのReferences
とIn-Reply-To
に適切なMessage-ID
を設定することで、スレッドを制御できます。
マルチバイトのヘッダー項目は、マルチバイト部分をBase64エンコードしたうえで、=?UTF-8?B?
と?=
で囲みます。UTF-8
は文字コード、B
はBase64エンコードを意味します。これにより、日本語のヘッダー項目を設定できます。
これを踏まえて、公式ドキュメントのサンプルコードを、スレッドの途中から返信する体で書き換えてみましょう。
import base64
import mimetypes
from email.message import EmailMessage
import google.auth
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
def gmail_send_reply_with_attachment(original_message_info):
creds, _ = google.auth.default()
try:
service = build("gmail", "v1", credentials=creds)
message = EmailMessage()
# 宛先を元のメッセージの送信者から設定
sender_email = original_message_info['headers'].get('From', 'recipient@example.com')
message["To"] = sender_email
# 送信者を設定
message["From"] = "あなたの名前 <your_email@example.com>"
# 元の件名に「Re: 」を付ける
message["Subject"] = "Re: " + original_message_info['headers'].get('Subject', '')
# スレッド制御用のヘッダーを設定
message["In-Reply-To"] = original_message_info['headers'].get('Message-ID')
original_references = original_message_info['headers'].get('References', '')
if original_references:
message["References"] = original_references + ' ' + original_message_info['headers'].get('Message-ID')
else:
message["References"] = original_message_info['headers'].get('Message-ID')
# 本文を設定
message.set_content("これは返信メールのサンプルです。添付ファイルを含みます。")
# 添付ファイルを設定
attachment_filename = "photo.jpg"
type_subtype, _ = mimetypes.guess_type(attachment_filename)
maintype, subtype = type_subtype.split('/')
with open(attachment_filename, "rb") as fp:
attachment_data = fp.read()
message.add_attachment(attachment_data, maintype, subtype, filename=attachment_filename)
# メッセージをエンコード
encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
create_message = {"raw": encoded_message}
sent_message = service.users().messages().send(userId="me", body=create_message).execute()
print(f'Message Id: {sent_message["id"]}')
except HttpError as error:
print(f"An error occurred: {error}")
sent_message = None
# 元のメールのダミーデータ
original_message_info = {
'text/plain': 'これは元のメッセージの本文です。',
'text/html': '<p>これは元のメッセージの本文です。</p>',
'headers': {
'From': '送信者名 <sender@example.com>',
'Message-ID': '<original_message_id@example.com>',
'Subject': 'Re: Re: 元のメッセージの件名',
'References': '<previous_message_id_1@example.com> <previous_message_id_2@example.com>',
'In-Reply-To': '<previous_message_id_2@example.com>'
}
}
if __name__ == "__main__":
gmail_send_reply_with_attachment(original_message_info)
サンプルコード内のダミーデータは、複数回やり取りしたメールに対して、さらに返信するシチュエーションを想定して作成したものです。References
とIn-Reply-To
に着目すると、スレッドの制御方法が理解できるはずです。ポイントは以下の通りです。
-
References
には、元のメッセージのMessage-ID
と、スレッドに登場するメッセージのMessage-ID
をスペース区切りで設定します。 -
In-Reply-To
には、直前のメッセージのMessage-ID
を設定します。
このサンプルコードは、一部のシチュエーションを切り取って再現したものなので、実際の処理はもっと複雑です。なお、マルチバイト文字の考慮はライブラリ側で行われるため、Pythonで実装する場合には特に気にする必要はありません。
FileMaker実装時の苦労話
余談ですが、FileMakerではマルチバイト文字の考慮も自分で実装する必要があるため、とても大変でした。Pythonではほとんど意識する必要のないContent-Type
やMIME-Version
、boundary
もすべて自分で書かなくてはいけません。また、メッセージをエンコードするのも大変で、Pythonでは1行で書けるのに、FileMakerでは次のようなトリッキーな手順が必要になりました。
- 作成したメッセージデータを
TextEncode
関数を用いてUTF-8にエンコードする。 - エンコードしたデータをオブジェクトフィールドにいったん格納する。
- オブジェクトフィールドをBase64エンコードする。
- Substitute関数を2回使って、
/
を_
に、+
を-
に置換する。
当初はメッセージのテキストをそのままエンコードしてエラーになっていました。デバッグと調査の結果、raw
パラメータがバイトコードしか受け付けないと知ったときには、絶望しそうになりました。そこで諦めずに、オブジェクトフィールドごとBase64エンコードするというアイデアを思いついた自分を褒めてあげたいです。
まとめ
本記事では、Gmail APIの実装で得られた2つの知見を公開しました。メール受信時のレスポンス解析については、再帰処理を用いて、メール本文や添付ファイルを抽出する方法を解説しています。また、スレッドを考慮したメール送信方法について、スレッド制御用のヘッダーを設定する方法を解説しました。これらの知見をGmail APIを使った開発に活かしていただければ幸いです。
Discussion