✉️

Google Colab上でGmailを送信してみる(サービスアカウント認証・パスワード認証・OAuth 2.0 クライアントID)

2024/01/04に公開

はじめに

本記事では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権限のあるアカウントで下記にアクセスし、作成したサービスアカウントに「ドメイン全体権限の委任」を行います。

https://admin.google.com/ac/owl/domainwidedelegation

クライアント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に置いています。

gmail_with_service_account
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としました。

https://developers.google.com/gmail/api/auth/scopes?hl=ja

with_subject

create_gmail_service()内のwith_subjectが「ドメイン全体権限の委任(Domain Wide Delegation)」のために指定が必要になる部分で、どのアドレスとして振る舞うか指定します。
https://stackoverflow.com/a/59864889

SENDER_ADDRESSRECEIVER_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はあれど、解決策はわかりませんでした。

https://stackoverflow.com/questions/37954442/gmail-api-access-single-user-without-domain-wide-delegation

https://stackoverflow.com/questions/70774580/accessing-gmail-api-using-service-account-but-limited-to-single-mailbox

ちなみにGoogle Groups APIsに関して、Domain Wide Delegationが不要になった記事が2020年にありました。

https://workspaceupdates.googleblog.com/2020/08/use-service-accounts-google-groups-without-domain-wide-delegation.html

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を送付することもできました。が、結論から言うと、セキュリティ上の理由や将来の仕様変更リスクが大きく、使わないべきです。

  • 下記コードの設定変数を埋めて実行
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月でサポート終了のアナウンスが出ていました。
https://support.google.com/accounts/answer/6010255?hl=ja

https://workspaceupdates-ja.googleblog.com/2023/10/2024-9-30-google-google-sync.html

もう一つ、アプリパスワードというものを設定する方法もあるようです。

しかしながら、

アプリ パスワードは推奨されておらず、ほとんどの場合は不要です。

とドキュメントに明記されているのでこれも避けるべきでしょう。
https://support.google.com/accounts/answer/185833?hl=ja

3. OAuth 2.0 クライアントID(デスクトップアプリ)

Gmail APIを使う際はまずこの方法を採りたいところですが、Colaboratoryで送ろうとすると問題があります。

まず、クライアントタイプのうち、「ウェブアプリケーション」 はユーザー同意の後のcallbackをColab上で受け取れず、頓挫しました。

ちょっと前まではOOB(out-of-band) と言って、callbackフローの代わりにauthorization codeを手動で貼って認証する方法があったようですが、2022年に廃止されていました。

https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html#disallowed-oob

次にデスクトップアプリを選択する方法を試しました。結論から言うとローカルで認証を行い、認証キーをColabから参照する方法が筆者の妥協点となりました。

Colab上で認証する(失敗)

  • GCPプロジェクトでOAuth 2.0 クライアントIDを作成(デスクトップアプリ)
  • 認証情報をダウンロードし、credentials.jsonとしてColabの直下(/content/)に設置
  • Colab上でコードを実行

コード例です。公式サンプルからそのまま持ってきています。

https://developers.google.com/gmail/api/quickstart/python?hl=ja

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から参照

仕方ないので妥協します。

ローカルで認証し、tokenrefresh_tokenを含むtoken.jsonを生成、Colabで取得できる場所に置いてみます。

Pythonがインストールされたローカル環境[1]で、以下のコードを実行します。

@local
$pip install google-auth-oauthlib
generate_token.py@local
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モジュールでもできないか模索しましたが、権限不足で上手くいきませんでした。

https://github.com/googlecolab/colabtools/blob/4ec43c1032dd9ba1ba55e8bf1aaea10fce86e965/google/colab/auth.py#L241

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'}]">

もし最適解をご存じの方がいらっしゃれば、ご教示ください...

脚注
  1. 厳密には認証すれば良いので、必ずしもPythonでなくても構いませんが。 ↩︎

Discussion