LoginSignup
2
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-02-02

今回は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

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

必要な情報とその取得

Gmail API referenceのusers.messagesに、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内のどのメールに返信するかまで指定することが必要です。

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

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を調べるコードを書きます。

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()

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

{'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の記事にある通り、threadIdraw属性に入れず、独立して入れなければならないようです。API Referenceのusers.messagesにあるFieldsにもraw(Headerも含める)とthreadIdが別Fieldになっていることが分かります。これを踏まえて、以下のようなコードを書きます。

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にあるJSONをdumpしたpython dictionaryなので、Referenceを見ながらアクセスしたい情報を取っていきます。

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

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では出来ないっぽい?です。

参考

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2