Google Colab上でGmailを送信してみる(サービスアカウント認証・パスワード認証・OAuth 2.0 クライアントID)
はじめに
本記事ではGoogle Colaboratory上でGmailを送る方法を3種類扱います。
最終的に至った結論はサービスアカウント認証かOAuth 2.0 クライアントIDが有力そうではあるがいずれも問題点あり、です。3種類の方法ともメール送信はできましたが、運用・保守まで考えると「これがベスト」という結論には達せずでした。
「試行錯誤したけどダメだった系記事」であることを、最初におことわりしておきます。
要約
- 本記事はGoogle WorkspaceでのGmail送付を扱います。
- 下記の3つでGoogle ColaboratoryからGmailを送ります。
- サービスアカウント認証
- パスワード認証
- OAuth 2.0 クライアントID認証
- いずれの方法も課題があり、本記事では最適解に至っていません。
- サービスアカウント認証
- 特定のユーザーではなく、ドメイン全体を委譲するので、サービスアカウントに強力な権限を渡す
- パスワード認証
- 非推奨。将来的に使えなくなる
- OAuth 2.0 クライアントID(デスクトップアプリ)
- Google Colaboratory内で認証フローが完結しない
- サービスアカウント認証
環境・前提
- 2023年1月初旬時点
- Google Workspaceアカウント上のGoogle Colaboratory
- ローカル環境:MacBook Air(2020, M1)
- pyenv(Python 3.10.10)
- Gmail APIが有効化されたGoogle Cloud Platformプロジェクト
1. サービスアカウント認証
サービスアカウント作成
GCPの「認証情報」からサービスアカウントでの認証を試します。
作成の際のオプションは不要です。
作成後、「新しい鍵を作成」からキーをダウンロードします。
Gmail APIの場合、もう一つ設定が必要です。
ドメイン全体権限の委任(Domain Wide Delegation)
Workspaceのadmin権限のあるアカウントで下記にアクセスし、作成したサービスアカウントに「ドメイン全体権限の委任」を行います。
クライアントIDには先ほどダウンロードしたjsonデータに記載のものをセットします。
これを行わないと権限不足でGmail APIを叩けませんでした。
An error occurred: ('unauthorized_client: Client is unauthorized to retrieve access tokens using this method, or client not authorized for any of the scopes requested.', {'error': 'unauthorized_client', 'error_description': 'Client is unauthorized to retrieve access tokens using this method, or client not authorized for any of the scopes requested.'})
Colabのコード作成
ダウンロードしたキーをColabでアクセスできる場所に置き、KEY_PATH
にパスを指定します。ここでは、一例としてservice_account_secrets.json
という名前でGoogle Driveに置いています。
from google.oauth2 import service_account
from googleapiclient.discovery import build
from email.mime.text import MIMEText
import base64
KEY_PATH = "/content/drive/MyDrive/gcp/service_account_secrets.json"
SENDER_ADDRESS = "Your Sender Email"
RECEIVER_ADDRESS = "Your Receiver Email"
def create_gmail_service():
creds = service_account.Credentials.from_service_account_file(KEY_PATH)
scoped_credentials = creds.with_scopes(
["https://www.googleapis.com/auth/gmail.compose"]
).with_subject(SENDER_ADDRESS)
return build("gmail", "v1", credentials=scoped_credentials)
def create_message(sender, to, subject, message_text):
"""Create a message for an email."""
message = MIMEText(message_text)
message["to"] = to
message["from"] = sender
message["subject"] = subject
return {"raw": base64.urlsafe_b64encode(message.as_bytes()).decode()}
def send_message(service, user_id, message):
"""Send an email message."""
try:
message = (
service.users().messages().send(userId=user_id, body=message).execute()
)
print(f'Message Id: {message["id"]}')
return message
except Exception as error:
print(f"An error occurred: {error}")
return None
def main():
service = create_gmail_service()
subject = "test from colab"
message_text = "hello from google colaboratory"
message = create_message(SENDER_ADDRESS, RECEIVER_ADDRESS, subject, message_text)
send_message(service, "me", message)
if __name__ == "__main__":
main()
以下、ポイントです。
Gmail関連のscope
Gmail関連のscopeは下記にまとめられています。送信したいので、scopeはhttps://www.googleapis.com/auth/gmail.compose
としました。
with_subject
create_gmail_service()
内のwith_subject
が「ドメイン全体権限の委任(Domain Wide Delegation)」のために指定が必要になる部分で、どのアドレスとして振る舞うか指定します。
SENDER_ADDRESSとRECEIVER_ADDRESSを埋めて実行するとメールが送信されると思います。
問題点
Domain Wide
(ドメイン全体)の名の通り、ドメイン内の任意のアドレスから送信できてしまいます。試しにSENDER_ADDRESS
を同一ドメインの別アドレスにすると、送信元もその通り変わります。
「ドメイン全体の委譲」は、
Domain-wide delegation is a powerful feature that allows apps to access users' data across your organization's Google Workspace environment.
https://support.google.com/a/answer/162106?hl=en
とある通り、だいぶ強い権限です。
単一ユーザーだけに絞れないか、調べてみましたが、同じ悩みを抱えたstackoverflowはあれど、解決策はわかりませんでした。
ちなみにGoogle Groups APIsに関して、Domain Wide Delegationが不要になった記事が2020年にありました。
Previously, you had to use domain-wide delegation and admin impersonation to provide service accounts with sufficient data access. This was a cumbersome process, which could result in overly broad privileges for the service account and audit logs that were hard to interpret.
ここにもoverly broad privileges for the service account
と書かれている通り、ColabからGmailを送るためにしては権限が強すぎる感は否めません。
2. ユーザー/パスワード認証
パスワード認証でGmailを送付することもできました。が、結論から言うと、セキュリティ上の理由や将来の仕様変更リスクが大きく、使わないべきです。
- アカウント設定から「安全性の低いアプリのアクセス」をOn
https://myaccount.google.com/security
- 下記コードの設定変数を埋めて実行
import smtplib, ssl
port = 465
smtp_server = "smtp.gmail.com"
sender_email = "<入力してください>"
receiver_email = "<入力してください>"
password = "<入力してください>"
message = "<入力してください>"
context = ssl.create_default_context()
with smtplib.SMTP_SSL(smtp_server, port, context=context) as server:
server.login(sender_email, password)
server.sendmail(sender_email, receiver_email, message)
なお、Google Workspace以外でのパスワード認証は2022年5月30日にサポート終了となっており、Google Workspaceに関しても2024年9月でサポート終了のアナウンスが出ていました。
もう一つ、アプリパスワードというものを設定する方法もあるようです。
しかしながら、
アプリ パスワードは推奨されておらず、ほとんどの場合は不要です。
とドキュメントに明記されているのでこれも避けるべきでしょう。
3. OAuth 2.0 クライアントID(デスクトップアプリ)
Gmail APIを使う際はまずこの方法を採りたいところですが、Colaboratoryで送ろうとすると問題があります。
まず、クライアントタイプのうち、「ウェブアプリケーション」 はユーザー同意の後のcallbackをColab上で受け取れず、頓挫しました。
ちょっと前まではOOB(out-of-band) と言って、callbackフローの代わりにauthorization codeを手動で貼って認証する方法があったようですが、2022年に廃止されていました。
次にデスクトップアプリを選択する方法を試しました。結論から言うとローカルで認証を行い、認証キーをColabから参照する方法が筆者の妥協点となりました。
Colab上で認証する(失敗)
- GCPプロジェクトでOAuth 2.0 クライアントIDを作成(デスクトップアプリ)
- 認証情報をダウンロードし、
credentials.json
としてColabの直下(/content/
)に設置 - Colab上でコードを実行
コード例です。公式サンプルからそのまま持ってきています。
import os.path
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
# If modifying these scopes, delete the file token.json.
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
def main():
"""Shows basic usage of the Gmail API.
Lists the user's Gmail labels.
"""
creds = None
# The file token.json stores the user's access and refresh tokens, and is
# created automatically when the authorization flow completes for the first
# time.
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
# If there are no (valid) credentials available, let the user log in.
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(port=0)
# Save the credentials for the next run
with open("token.json", "w") as token:
token.write(creds.to_json())
try:
# Call the Gmail API
service = build("gmail", "v1", credentials=creds)
results = service.users().labels().list(userId="me").execute()
labels = results.get("labels", [])
if not labels:
print("No labels found.")
return
print("Labels:")
for label in labels:
print(label["name"])
except HttpError as error:
# TODO(developer) - Handle errors from gmail API.
print(f"An error occurred: {error}")
if __name__ == "__main__":
main()
しかし、OAuth2.0フローでブラウザ上で認証しようとするところで、案の定Colab環境にbrowserがなくエラーになります。
Error: could not locate runnable browser
ローカルで認証を作成 & Colabから参照
仕方ないので妥協します。
ローカルで認証し、token
とrefresh_token
を含むtoken.json
を生成、Colabで取得できる場所に置いてみます。
Pythonがインストールされたローカル環境[1]で、以下のコードを実行します。
$pip install google-auth-oauthlib
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = ["https://www.googleapis.com/auth/gmail.compose"]
def main():
flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)
creds = flow.run_local_server(port=0)
with open("token.json", "w") as token:
token.write(creds.to_json())
if __name__ == "__main__":
main()
ブラウザが開くので、メール送信元にするアカウントを選択/ログインすると、token.json
が作成されます。
続いて作成したtoken.json
をColabからアクセスできる場所(たとえばGoogle Driveの/content/drive/MyDrive/gcp/token.json
)に置き、Colabで下記を実行します。
(TO_ADDRESS = "Your Receiver Email"
を置き換えてください。)
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from email.mime.text import MIMEText
import base64
SCOPES = ["https://www.googleapis.com/auth/gmail.compose"]
TOKEN_PATH = "/content/drive/MyDrive/gcp/token.json"
TO_ADDRESS = "Your Receiver Email"
def create_gmail_service():
creds = Credentials.from_authorized_user_file(TOKEN_PATH, SCOPES)
return build("gmail", "v1", credentials=creds)
def create_message(to, subject, message_text):
"""Create a message for an email."""
message = MIMEText(message_text)
message["to"] = to
message["subject"] = subject
return {"raw": base64.urlsafe_b64encode(message.as_bytes()).decode()}
def send_message(service, user_id, message):
"""Send an email message."""
try:
message = (
service.users().messages().send(userId=user_id, body=message).execute()
)
print(f'Message Id: {message["id"]}')
return message
except Exception as error:
print(f"An error occurred: {error}")
return None
def main():
try:
service = create_gmail_service()
except Exception as e:
print(e)
return
receiver_email = TO_ADDRESS
subject = "test from colab"
message_text = "hello from google colaboratory"
message = create_message(receiver_email, subject, message_text)
send_message(service, "me", message)
if __name__ == "__main__":
main()
これでメール送信ができ、かつ送信元をOAuth2.0で代理させたアカウントのみに縛れます。
ただ、ローカルのPython環境が必要になるのは、環境構築不要なColabの良さを消してしまっていて、残念な感じがします。
おわりに
Google Colab上でGmail APIを用いるための認証として3つの方法を試しました。
まとめると、
認証方法 | 😄 | 🤔 |
---|---|---|
サービスアカウント | Google Colaboratoryで完結して動作する | 特定のユーザーではなく、ドメイン全体を委譲するので、サービスアカウントに強力な権限が渡る |
パスワード | 実装が容易 | 非推奨。将来的に使えなくなる |
OAuth 2.0 クライアントID(デスクトップアプリ) | 特定のユーザーのみの権限を与えることができる | Google Colaboratoryで完結しない |
とどれも一長一短という具合でした。
理想としては、「Google Colaboratoryで完結して動作し、特定のユーザーのみの権限を与えることができる」認証方法です。これができるとすればOAuth 2.0 クライアントIDによるものだと思われます。
もうひとつ、colabtoolsのauthモジュールでもできないか模索しましたが、権限不足で上手くいきませんでした。
def create_gmail_service():
auth.authenticate_user() # auth.authenticate_user(project_id='your_project')
creds, _ = default()
scoped_creds = creds.with_scopes(SCOPES)
print(scoped_creds.scopes)
return build("gmail", "v1", credentials=scoped_creds)
['https://www.googleapis.com/auth/gmail.compose']
WARNING:googleapiclient.http:Encountered 403 Forbidden with reason "insufficientPermissions"
An error occurred: <HttpError 403 when requesting https://gmail.googleapis.com/gmail/v1/users/me/messages/send?alt=json returned "Request had insufficient authentication scopes.". Details: "[{'message': 'Insufficient Permission', 'domain': 'global', 'reason': 'insufficientPermissions'}]">
もし最適解をご存じの方がいらっしゃれば、ご教示ください...
-
厳密には認証すれば良いので、必ずしもPythonでなくても構いませんが。 ↩︎
Discussion