🐈

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

2024/04/11に公開

はじめに

弊社では、メールのやり取りを複数のメンバーで共有するために、とあるWebアプリケーションを利用しています。しかし、このアプリケーションは利用料が高い割に使い勝手が悪く、ながいこと不満を募らせてきました。とうとう不満も限界に近づいてきたので、FileMakerで同等のアプリケーションを開発することにしました。情シス作業の合間に、暇を見つけてはちまちまと開発すること数ヶ月、最近ようやく完成しました。

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

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

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

本記事で解説すること

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

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

これら以外は、公式ドキュメントを読むか、検索すれば大抵すぐ見つかります。

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

私が今回開発したアプリにおいて、1通のメールを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を設定することで、スレッドを制御できます。

マルチバイトのヘッダー項目は、マルチバイト部分を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に関する知見を共有しました。メール受信時のレスポンス解析については、再帰処理を用いて、メール本文や添付ファイルを抽出する方法を解説しました。また、スレッドを考慮したメール送信方法についても、スレッド制御用のヘッダーを設定する方法を解説しました。これらの知見を活かして、Gmail APIを使った開発を進めていただければ幸いです。

Discussion