🐈

FileMakerで本格的なメールクライアントを作ったらGmail APIの知見を得たのでPythonで解説する

に公開

はじめに

弊社では、以前から業務でFileMakerを使っています。FileMakerの弱点のひとつに、「標準機能だけではメール受信ができない」というものがあります。専用のプラグインをインストールすれば受信できるのですが、有料のプラグインはメンテナンスが面倒なので、あまり使いたくありません。そこで、Gmail APIを使ってメール受信を実装することにしました。どうせなら、完全なメールクライアント機能を実現しようと考え、受信だけでなく送信やスレッド制御も実装しました。

開発する過程でGmail APIに関するいくつかの知見を得られたので、本記事で公開することにしました。FileMakerスクリプトは可読性が低いため、本記事のサンプルコードはPythonで書いています。もしFileMakerの処理に興味がある場合は、アプリをダウンロードしてスクリプトをご覧ください。

今回作ったアプリケーションは、こちらで公開しています。MITライセンスですので、自由にお使いいただけます。

https://crm-mailmanager.netlify.app/

本記事の内容

本記事では、以下の内容について解説します。

  • Gmail APIを用いたメール受信時のレスポンス解析について
  • スレッドを考慮したメール送信方法について

これらの情報は、検索してもなかなか見つからなかったので、Gmail APIを使った開発を行う方にとって有用な情報になると思われます。

メール受信時のレスポンス解析について

Gmail APIを用いて添付ファイル付きのメールを受信する場合の手順はなかなか複雑です。順番に記載すると、以下のようになります。

  1. アクセストークンを取得する。
  2. メッセージ一覧を取得する。Method: users.messages.list
  3. 個別のメッセージを取得する。Method: users.messages.get
  4. ラベルを更新して既読にする。Method: users.messages.modify
  5. 個別メッセージのレスポンスを解析して、ヘッダーや本文、添付ファイル等の情報を取り出す。
  6. 添付ファイル本体を取得する。Method: users.messages.attachments.get

たった1通のメールを受信するのに、実に4回ものAPIリクエストが必要です。ただ、API呼び出しはリファレンスどおりに記述すればよいので、さほど難しくはありません。やっかいなのは5番目のレスポンス解析です。それでは詳しく説明していきましょう。

レスポンスの構造

受信した個別メッセージのレスポンスは、次のような構造をしています。ドキュメントはこちらです。

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

payloadの中にあるMessagePartは、次のような構造をしています。

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

HeaderMessagePartBodyについては後述するので、ここではpartsの中にMessagePartの配列が存在することに注目してください。これは、MessagePartが1対多の入れ子構造になっていることを示しています。また、各項目には次のような特徴があります。

  • mimeTypeは、様々な値を取り得ます。レスポンスの解析において特に重要なのは、text/plaintext/htmlです。
  • filenameは、添付ファイルがある場合のみ存在します。
  • headersは、トップレベルのMessagePartのみに存在します。
  • bodyには、メール本文が格納されています。空の場合もあります。
  • partsは、mimeTypeがコンテナMIMEメッセージ(multipart/*)の場合にのみ存在します。

つづいて、HeaderMessagePartBodyについても説明します。

Headerは、次のような構造をしています。

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

MessagePartBodyは、次のような構造をしています。

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を送信するわけです。

Message
{
  "raw": string
}

単純ですが、単純すぎてかえって難しいですね。これだけでは何を設定したらよいかわかりません。それでも公式ドキュメントにサンプルコードが載っているので、単純なメール送信で困ることはないでしょう。では、次のような場合はどうでしょうか。

  • 既存のスレッドに返信する場合
  • マルチバイトのヘッダー項目(サブジェクトや宛先など)を含むメールを送信する場合

メールのスレッド制御方法は、RFC 2822に記載されています。受信したメッセージに返信する場合は、メールヘッダーのReferencesIn-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)

サンプルコード内のダミーデータは、複数回やり取りしたメールに対して、さらに返信するシチュエーションを想定して作成したものです。ReferencesIn-Reply-Toに着目すると、スレッドの制御方法が理解できるはずです。ポイントは以下の通りです。

  • Referencesには、元のメッセージのMessage-IDと、スレッドに登場するメッセージのMessage-IDをスペース区切りで設定します。
  • In-Reply-Toには、直前のメッセージのMessage-IDを設定します。

このサンプルコードは、一部のシチュエーションを切り取って再現したものなので、実際の処理はもっと複雑です。なお、マルチバイト文字の考慮はライブラリ側で行われるため、Pythonで実装する場合には特に気にする必要はありません。

FileMaker実装時の苦労話

余談ですが、FileMakerではマルチバイト文字の考慮も自分で実装する必要があるため、とても大変でした。Pythonではほとんど意識する必要のないContent-TypeMIME-Versionboundaryもすべて自分で書かなくてはいけません。また、メッセージをエンコードするのも大変で、Pythonでは1行で書けるのに、FileMakerでは次のようなトリッキーな手順が必要になりました。

  1. 作成したメッセージデータをTextEncode関数を用いてUTF-8にエンコードする。
  2. エンコードしたデータをオブジェクトフィールドにいったん格納する。
  3. オブジェクトフィールドをBase64エンコードする。
  4. Substitute関数を2回使って、/_に、+-に置換する。

当初はメッセージのテキストをそのままエンコードしてエラーになっていました。デバッグと調査の結果、rawパラメータがバイトコードしか受け付けないと知ったときには、絶望しそうになりました。そこで諦めずに、オブジェクトフィールドごとBase64エンコードするというアイデアを思いついた自分を褒めてあげたいです。

まとめ

本記事では、Gmail APIの実装で得られた2つの知見を公開しました。メール受信時のレスポンス解析については、再帰処理を用いて、メール本文や添付ファイルを抽出する方法を解説しています。また、スレッドを考慮したメール送信方法について、スレッド制御用のヘッダーを設定する方法を解説しました。これらの知見をGmail APIを使った開発に活かしていただければ幸いです。

Discussion