Python版Gmail API ClientでThreadにメール(返信)を送信する

今回はPythonを用いてGMailを扱えるAPI Clientを使って、Threadにメールを送信する方法で詰まりました。解決出来ましたが日本語のドキュメントがなかったので、ここでまとめます。

API keyの作り方やcredential等の説明は他の方の記事が大変丁寧なので、こちらでは取り上げません(参考のところにリンクあります)。Permissionは

- `https://mail.google.com`
- `https://www.googleapis.com/auth/gmail.send`
- `https://www.googleapis.com/auth/gmail.readonly`

を付与しておきます。

# Threadについて

Threadというのは、このように関連するメール(主に返信)がひとまとまりになっているもののことを言います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/728f2903-782a-2136-7c3d-cdcc6747c115.png)

これは今回作ったテストコードでメールを送った際の受信側のGmailのThread画面です。

# 必要な情報とその取得

[Gmail API referenceのusers.messages](https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message)に、Threadにメールをつなげる条件は

> The ID of the thread the message belongs to. To add a message or draft to a thread, the following criteria must be met:

>1. The requested threadId must be specified on the Message or Draft.Message you supply with your request.
2. The References and In-Reply-To headers must be set in compliance with the RFC 2822 standard.
3. The Subject headers must match.

と書いてあります。ここで必要な情報は

- `threadId`
- RFC 2822形式のメッセージID

の2つです。Thread単位だけでなく、Thread内のどのメールに返信するかまで指定することが必要です。

とりあえず次のようなコードを実行してメールを送信します。

```python:sendmail.py
import pickle
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import base64
from email.mime.text import MIMEText
from apiclient import errors
import json

SCOPES = ['https://mail.google.com/',
          'https://www.googleapis.com/auth/gmail.send',
          'https://www.googleapis.com/auth/gmail.readonly',
]


def create_message(sender, to, subject, message_text):
    message = MIMEText(message_text)
    message['to'] = to
    message['from'] = sender
    message['subject'] = subject
    raw_msg = base64.urlsafe_b64encode(message.as_bytes())
    raw_msg = raw_msg.decode()
    raw_msg = {'raw': raw_msg}
    return raw_msg


def send_message(service, user_id, message):
    try:
        message = service.users().messages().send(userId=user_id, body=message).execute()
        print('Message Id: %s' %message['id'])
        return message
    except errors.HttpError as error:
        print('An error ocurred: %s' % error)


def main():
    creds = None
    if os.path.exists('token.pickle'):
        with open('token.pickle','rb') as token:
            creds = pickle.load(token)

    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(
                'credentials.json', SCOPES)
            creds = flow.run_local_server()

        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)

    service = build('gmail','v1',credentials=creds)

    sender = '******@******'
    to = '######@#######'
    subject = 'Gmail API test'
    message_text = 'This mail is sent via the Gmail API'

    message = create_message(sender, to, subject, message_text)

    print(message)
    send_message(service, 'me', message)    
    
if __name__ == '__main__':
    main()
```

その次に、このメールに返信をしていきたいのですが、threadIdとメッセージIDを調べるコードを書きます。

```python:receive_threadlist.py
import pickle
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import base64
from email.mime.text import MIMEText
from apiclient import errors
import json

SCOPES = ['https://mail.google.com/',
          'https://www.googleapis.com/auth/gmail.send',
          'https://www.googleapis.com/auth/gmail.readonly',
          'https://www.googleapis.com/auth/gmail.modify',
]

def get_thread_list(service,user_id,query,max_length):
    try:
        thread_list = service.users().threads().list(userId=user_id,q=query).execute().get('threads')
        return thread_list
    except errors.HttpError as error:
        print('An error ocurred: %s' %error)

def get_thread(service, user_id, thread):
    thread = service.users().threads().get(userId=user_id, id=thread['id']).execute()
    return thread
    
def main():
    creds = None
    if os.path.exists('token.pickle'):
        with open('token.pickle','rb') as token:
            creds = pickle.load(token)

    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(
                'credentials.json', SCOPES)
            creds = flow.run_local_server()

        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)

    service = build('gmail','v1',credentials=creds)

    thread_list = get_thread_list(service, 'me', 'in:sent',100) # 送ったメールの中で調べる
    print(thread_list[0]) # 最初のthread情報を出力
    thread = get_thread(service, 'me', thread_list[0])
    print(json.dumps(thread, indent=2))

if __name__ == '__main__':
    main()
        
```

これを実行した結果の例がこちらです。

```json
{'id': '1111111111111111', 'snippet': 'This mail is sent via the Gmail API', 'historyId': '2222222'}
{
  "id": "1111111111111111",
  "historyId": "2222222",
  "messages": [
    {
      "id": "1111111111111111",
      "threadId": "1111111111111111",
      "labelIds": [
        "SENT"
      ],
      "snippet": "This mail is sent via the Gmail API",
      "payload": {
        "partId": "",
        "mimeType": "text/plain",
        "filename": "",
        "headers": [
          {
            "name": "Received",
            "value": "from 33333333333 named unknown by gmailapi.google.com with HTTPREST; Mon, 1 Feb 2021 22:02:54 -0500"
          },
          {
            "name": "Content-Type",
            "value": "text/plain; charset=\"us-ascii\""
          },
          {
            "name": "MIME-Version",
            "value": "1.0"
          },
          {
            "name": "Content-Transfer-Encoding",
            "value": "7bit"
          },
          {
            "name": "to",
            "value": "######@######"
          },
          {
            "name": "from",
            "value": "******@******"
          },
          {
            "name": "subject",
            "value": "Gmail API test"
          },
          {
            "name": "Date",
            "value": "Mon, 1 Feb 2021 22:02:54 -0500"
          },
          {
            "name": "Message-Id",
            "value": "<xxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.gmail.com>"
          }
        ],
  ]
}
```

適当にぼかしたりしていますが、この結果で得られた `threadId`(ここでは`1111111111111111`)と`Message-Id`(`<xxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.gmail.com>` と、`<>`も含めて)をコピーしておきます。


# 送信の実装

[Stack Overflowの記事](https://stackoverflow.com/questions/58336859/how-to-send-an-existing-email-to-a-new-thread-using-gmail-api)にある通り、`threadId`を`raw`属性に入れず、独立して入れなければならないようです。[API Referenceのusers.messages](https://developers.google.com/gmail/api/reference/rest/v1/users.messages#Message)にあるFieldsにも`raw`(Headerも含める)と`threadId`が別Fieldになっていることが分かります。これを踏まえて、以下のようなコードを書きます。

```python:sendmail.py
import pickle
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import base64
from email.mime.text import MIMEText
from apiclient import errors
import json

SCOPES = ['https://mail.google.com/',
          'https://www.googleapis.com/auth/gmail.send',
          'https://www.googleapis.com/auth/gmail.readonly',
]


def create_message(sender, to, subject, message_text, thread_id=None, reply_to=None):
    message = MIMEText(message_text)
    message['to'] = to
    message['from'] = sender
    message['subject'] = subject
    if reply_to:
        message['Reference'] = reply_to   
        message['In-Reply-To'] = reply_to  # ReferenceとIn-Reply-Toはこの場合同じで良い
    raw_msg = base64.urlsafe_b64encode(message.as_bytes())
    raw_msg = raw_msg.decode()
    raw_msg = {'raw': raw_msg}
    if thread_id:
        raw_msg['threadId'] = thread_id # threadIdはrawとは別にしないとAPIが叩けない
    return raw_msg


def send_message(service, user_id, message):
    try:
        message = service.users().messages().send(userId=user_id, body=message).execute()
        print('Message Id: %s' %message['id'])
        return message
    except errors.HttpError as error:
        print('An error ocurred: %s' % error)


def main():
    creds = None
    if os.path.exists('token.pickle'):
        with open('token.pickle','rb') as token:
            creds = pickle.load(token)

    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(
                'credentials.json', SCOPES)
            creds = flow.run_local_server()

        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)

    service = build('gmail','v1',credentials=creds)

    sender = '******@******'
    to = '######@######'
    subject = 'Gmail API test'
#    message_text = 'This mail is sent via the Gmail API' # 1回目(reply_toとthread_idは定義しない)
    message_text = 'This mail is test for sending to the same thread' # 2回目
#    message_text = 'This mail is test for sending to the same thread (2nd)' #3回目

    test_reply_to = '<xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@mail.gmail.com>'
    test_thread_id = '1111111111111111'
    
    message = create_message(sender, to, subject, message_text, test_thread_id, test_reply_to)

    print(message)
    send_message(service, 'me', message)    
    
if __name__ == '__main__':
    main()
```

これを実行すると、上のスクリーンショットのようにThread内でメールが受信出来るはずです。

# 過去のメッセージを引用に入れる

このコードで返信を行うと、過去のメッセージが引用されず、Thread内ではあるけど新規に書いたメッセージしか残りません。
そこで、過去のメッセージも残すような機能を入れます。といっても、API上でどうこう出来るわけではなさそうなので、ここは力技で行きます。

方針としては

1. 返信先のメッセージを取得する
2. メッセージをデコードする
3. クオート`>`を各行につけて書いたメッセージに追加する

とします。

上で紹介した`threadId`が分かっていれば、`users.threads.get`メソッドでメッセージの本文などを取得する事ができます。このメソッドで返ってくるのは[Reference](https://developers.google.com/gmail/api/reference/rest/v1/users.threads#Thread)にあるJSONをdumpしたpython dictionaryなので、Referenceを見ながらアクセスしたい情報を取っていきます。

一行ずつクオートをつけなければならないので、`splitlines()`関数を使ってちゃちゃっと実装します。

```python:send_mail.py
import pickle
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import base64
from email.mime.text import MIMEText
from apiclient import errors
import json

SCOPES = ['https://mail.google.com/',
          'https://www.googleapis.com/auth/gmail.send',
          'https://www.googleapis.com/auth/gmail.readonly',
]

def create_message(sender, to, subject, message_text,
                   thread_id=None, reply_to=None, past_message=None):
    if past_message:
        message_text = message_text + '\r\n\r\n'
        for strline in past_message.splitlines(): # 過去のメッセージを一行ずつ分割
            message_text = message_text + '\r\n>' + strline # 手で改行 + '>'を追加
    message = MIMEText(message_text)
    message['to'] = to
    message['from'] = sender
    message['subject'] = subject
    if reply_to:
        message['Reference'] = reply_to
        message['In-Reply-To'] = reply_to
    raw_msg = base64.urlsafe_b64encode(message.as_bytes())
    raw_msg = raw_msg.decode()
    raw_msg = {'raw': raw_msg}
    if thread_id:
        raw_msg['threadId'] = thread_id
    return raw_msg


def send_message(service, user_id, message):
    try:
        message = service.users().messages().send(userId=user_id, body=message).execute()
        print('Message Id: %s' %message['id'])
        return message
    except errors.HttpError as error:
        print('An error ocurred: %s' % error)

### Thread IDを取得する
def get_thread_list(service, user_id, query):
    return service.users().threads().list(userId=user_id, q=query).execute()

### メッセージの中身を取得する
def get_thread(service, user_id, thread_id):
    return service.users().threads().get(userId=user_id, id=thread_id).execute()

def main():
    creds = None
    if os.path.exists('token.pickle'):
        with open('token.pickle','rb') as token:
            creds = pickle.load(token)

    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(
                'credentials.json', SCOPES)
            creds = flow.run_local_server()

        with open('token.pickle', 'wb') as token:
            pickle.dump(creds, token)

    service = build('gmail','v1',credentials=creds)

##### 一発目(threadが立っていない)のときにコメントアウト #####
    query = 'in:sent' # 自分が送信したThreadを検索
    thread_list = get_thread_list(service,'me',query) # jsonをdictionaryにdumpしたものが帰ってくる
    thread_id = thread_list['threads'][0]['id']
    print(thread_id) # 検索の最初のThreadのthreadIdが出力される
    thread = get_thread(service,'me',thread_id)
    latest_message = thread['messages'][-1]['payload'] # threadの一番最後のメッセージオブジェクトを取得
    latest_msg_body = base64.urlsafe_b64decode(latest_message['body']['data']).decode() # base64形式なのでstringにdecodeする

    ###ついでに、ここからReplyするメッセージIDをHeaderから取得しておく
    for att in latest_message['headers']:
        if att['name'] == 'Message-Id':
            message_id = att['value']
            break
##### コメントアウトおわり ######
    
    sender = '******@******'
    to = '######@######'
    subject = 'Gmail API test'
#    message_text = 'This mail is sent via the Gmail API' # 1回目(reply_toとthread_idは定義しない)
    message_text = 'This mail is test for sending to the same thread' # 2回目
#    message_text = 'This mail is test for sending to the same thread (2nd)' #3回目

#    message = create_message(sender, to, subject, message_text) # 一発目のみ
    message = create_message(sender, to, subject, message_text, thread_id, message_id, latest_msg_body)
    send_message(service, 'me', message)

if __name__ == '__main__':
    main()
```

最初の一発目のメッセージを送る際には、メッセージ検索の箇所など、コメントアウトして使ってください。

これで、無事に引用つき返信を実装できました。ただ、Web UIからだと出来る`blockquote`(縦線で引用領域が分かりやすいやつ)はリッチテキストなので、base64 encodingでは出来ないっぽい?です。


# 参考

- [Gmail APIとPythonを使ってメール送信を自動化する方法](https://valmore.work/automate-gmail-sending/#:~:text=Gmail%20API%E3%81%A8%E3%81%AF%E3%80%81Gmail,%E3%82%88%E3%81%86%E3%81%AA%E4%BD%BF%E3%81%84%E9%81%93%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82&text=%E5%85%AC%E5%BC%8F%E3%83%AA%E3%83%95%E3%82%A1%E3%83%AC%E3%83%B3%E3%82%B9%E3%81%AF%E8%8B%B1%E8%AA%9E%E3%81%A7%E3%81%99,%E3%81%8C%E8%A8%98%E3%81%95%E3%82%8C%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%82)
- [Gmail API Python メッセージとスレッドの取得方法](https://typememo.jp/tech/gmail-api-get-messages-and-threads-with-python/)
- [How to send an existing email to a new thread using gmail API](https://stackoverflow.com/questions/58336859/how-to-send-an-existing-email-to-a-new-thread-using-gmail-api)
- [GMail API Reference](https://developers.google.com/gmail/api/reference/rest)