今回は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というのは、このように関連するメール(主に返信)がひとまとまりになっているもののことを言います。
これは今回作ったテストコードでメールを送った際の受信側の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:
- The requested threadId must be specified on the Message or Draft.Message you supply with your request.
- The References and In-Reply-To headers must be set in compliance with the RFC 2822 standard.
- The Subject headers must match.
と書いてあります。ここで必要な情報は
threadId
- RFC 2822形式のメッセージID
の2つです。Thread単位だけでなく、Thread内のどのメールに返信するかまで指定することが必要です。
とりあえず次のようなコードを実行してメールを送信します。
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を調べるコードを書きます。
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の記事にある通り、threadId
をraw
属性に入れず、独立して入れなければならないようです。API Referenceのusers.messagesにあるFieldsにもraw
(Headerも含める)とthreadId
が別Fieldになっていることが分かります。これを踏まえて、以下のようなコードを書きます。
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上でどうこう出来るわけではなさそうなので、ここは力技で行きます。
方針としては
- 返信先のメッセージを取得する
- メッセージをデコードする
- クオート
>
を各行につけて書いたメッセージに追加する
とします。
上で紹介したthreadId
が分かっていれば、users.threads.get
メソッドでメッセージの本文などを取得する事ができます。このメソッドで返ってくるのはReferenceにあるJSONをdumpしたpython dictionaryなので、Referenceを見ながらアクセスしたい情報を取っていきます。
一行ずつクオートをつけなければならないので、splitlines()
関数を使ってちゃちゃっと実装します。
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では出来ないっぽい?です。