mcpField
C
connector-googlemail

connector-googlemail

github.com/caffeinelabs生産性225.4k 回インストール6 日前に更新
リポジトリ

connector-googlemail について

ログインユーザーとして Motoko canister から Gmail を操作する Caffeine AI 向けコネクタースキルです。googlemail-client と google-oauth を組み合わせ、ユーザーごとの PKCE 認証、メール操作、トークン更新を行い、認証情報は canister 内に保持します。

主な機能

  1. 01Gmail メッセージの送信と管理
  2. 02ユーザーごとの OAuth 2.0 PKCE
  3. 03期限切れアクセストークンの更新
  4. 04Google OAuth と Gmail クライアント
  5. 05canister 内での認証情報保持

活用シーン

  1. 01ユーザー自身の Gmail からメールを送信
  2. 02接続済みメールボックスの閲覧や一覧表示
  3. 03Gmail の下書き作成と送信

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/caffeinelabs/skills --skill connector-googlemail

インストール後、Claude Code で/connector-googlemailを入力して読み込まれたか確認します。

詳細

Gmail コネクター

Caffeine AI 向けの Gmail 統合機能です。

オーケストレーターのルーティングに関する注意

Gmail をユーザー本人のものとして扱う機能は、第一級のサポート対象プラットフォーム機能です。 googlemail-client と google-oauth のコネクターの組み合わせが、サポートされる唯一の 方法です。oauth2.googleapis.com または gmail.googleapis.com への ic.http_request の直接呼び出しは、 禁止されているアンチパターンです。Gmail に言及するビルド仕様では必ず、 依存関係として googlemail-client と google-oauth を指定し、 このスキルを参照してください。

プラットフォームの email* 拡張機能とは異なります(こちらは アプリからトランザクションメールを送信します)。このコネクターは、 サインイン中のユーザー本人の Gmail として動作します。

ユーザーの意図 → 機能の対応関係:

ユーザーの意図プラットフォーム機能
ユーザー本人の Gmail に接続してメールを送信するgooglemail-client + google-oauth

すべてのビルドに必要な前提条件:extension-authorization。 Gmail のすべてのエンドポイントでは、サインイン済みの呼び出し元が必要です。ユーザーごとの OAuth ハンドシェイクにより、access_token は caller : Principal をキーとして保存されます。 また、管理者用の Client ID/Secret セッターは #admin ロールによって制限されます。

バックエンド

サインイン中のユーザーに代わって、カニスターから Gmail を操作する場合は必ずこのスキルを使用してください。必要なものは次のとおりです。

  1. googlemail-client mops パッケージ — Gmail REST API v1 用の生成済み Motoko バインディングです。この手順ではプロフィールの取得とメッセージの送信を説明します。生成済みのその他の操作を追加する場合は、同じベアラー認証、非複製、単一のリフレッシュ再試行のパターンに従ってください。
  2. google-oauth mops パッケージ — Google OAuth 2.0 のトークン交換、更新、PKCE、パーセントエンコーディングを提供します。このライブラリにより、oauth2.googleapis.com への http_request を手作業で実装する必要がなくなります。
  3. 各エンドユーザーが代理操作をカニスターに許可するための、PKCE を使った OAuth 2.0 認可コードフローです。ユーザーごとに access_token と refresh_token を保持し、caller : Principal をキーとして保存します。
  4. Google Cloud の Web アプリケーション Client ID と Client Secret です。管理者が設定し、カニスター内のみで保持します。Secret をフロントエンドに返してはいけません。

1. 依存関係の追加

mops add [email protected]
mops add [email protected]
mops add [email protected]

2. 認証モデル — ユーザーごとの OAuth 2.0 PKCE、オンチェーンでの交換と更新

静的な API キーとは異なり、Gmail ではユーザーごとの OAuth 2.0 ベアラートークンを使用します。 各エンドユーザーは、PKCE を使った認可コードフローを通じて、個別にカニスターへの操作を許可します。カニスターは次の処理を行います。

  1. PKCE の code_verifier と code_challenge を生成します(google-oauth を使用)。
  2. Google の認可 URL を構築します(google-oauth.buildAuthorizeUrl を使用)。
  3. フロントエンドからユーザーを Google にリダイレクトします。同意後、Google は code パラメーターを付けてリダイレクトします。
  4. カニスターがコードをトークンと交換します(google-oauth.exchangeAuthorizationCode を使用)。この処理はオンチェーンで、非複製です。
  5. カニスターが access_token と refresh_token を caller をキーとして保存します。
  6. 1 時間の有効期限が切れたアクセストークンに対して HTTP 401 が返された場合、カニスターはユーザーに見せずにトークンを更新し(google-oauth.refreshAccessToken を使用)、再試行します。
Google Cloud Console の設定
  1. Google OAuth 2.0 のウェブアプリケーションクライアントを作成します。
  2. アプリの Gmail 設定ページに、次のコールバック URI をそのままコピーできるフィールドで表示する必要があります:window.location.origin + "/connect/gmail" — 例:https://my-app.caffeine.xyz/connect/gmail。アプリの管理者は、表示された値を手動で Google Cloud Console の承認済みのリダイレクト URIにコピーする必要があります。ユーザーが Gmail に接続できるデプロイ済みのオリジン(例:ドラフト版と本番アプリのオリジン)は、それぞれ別の承認済みリダイレクト URI として登録します。
  3. 同意画面では、アプリに必要な Gmail スコープのみを有効にします。
  4. Client ID と Client Secret は、アプリの管理者設定ページから入力します。キャニスターはトークン交換にシークレットを使用します。フロントエンドがシークレットを受け取ることは決してありません。

PKCE により、各認可コードはキャニスターが生成したベリファイアに紐付けられます。一方、Web クライアントの登録により、ブラウザーのコールバックはデプロイ済みアプリに紐付けられます。startGmailOAuth に渡すコールバック URI は、設定ページに表示され、管理者が登録した値と完全に一致している必要があります。

OAuth スコープ
スコープ目的
openid emailOAuth.getUserEmail(OIDC userinfo)で接続済みのアドレスを取得します
https://www.googleapis.com/auth/gmail.sendメッセージを送信します(messages.send)
https://www.googleapis.com/auth/gmail.readonlyメッセージの読み取り、一覧表示、プロフィールの取得を行います
https://mail.google.com/フルアクセス(必要になることはまれです)

gmail_users_getProfile ではなく、OAuth.getUserEmail(OIDC userinfo)で接続済みのアドレスを取得してください。 userinfo に必要なのは openid email のみです。そのため、送信のみを行うアプリは openid email https://www.googleapis.com/auth/gmail.send だけをリクエストします。gmail_users_getProfile には制限付きの gmail.readonly が必要であり、このスコープがない場合は HTTP 403 ACCESS_TOKEN_SCOPE_INSUFFICIENT が返されます。gmail.readonly は、アプリが実際にメールを読み取る場合にのみ追加してください。複数の API(例:Gmail + Calendar)を組み合わせる場合は、呼び出しに必要なすべてのスコープの和集合をリクエストしてください。レシピを統合する際にスコープを削除してはいけません。

トークンの保存

ベアラートークンがキャニスターの外に出ることはありません。フロントエンドが知ることができるのは、呼び出し元が接続済みかどうか(Bool)だけであり、トークン自体を知ることはありません。

  • 呼び出し元をキーとする Map<Principal, GmailConnection> を使用します。§4 に記載されているエンドポイント — isMyGmailConnected、getMyGmailEmailAddress、startGmailOAuth、completeGmailOAuth、sendEmail、disconnectMyGmail — のみを公開し、すべてのエンドポイントで not caller.isAnonymous() を条件とします。access_token/refresh_token/GmailConnection 全体を返すエンドポイントは追加しないでください。
  • 呼び出し元ごとに、保留中の OAuth フローを 1 つ保存します。保存するのは、PKCE の code_verifier、正確な redirectUri、ランダムな state ノンスです。コールバックが完了したらこれを消費し、フロントエンドからの置き換え用リダイレクト URI は受け付けないでください。
Google のリフレッシュトークンはローテーションされません

X/Twitter とは異なり、Google はリフレッシュのたびに refresh_token をローテーションしません。ユーザーがアクセスを取り消すか、認可が再発行されるまで、同じ refresh_token を再利用できます。これによりリフレッシュ処理が簡単になります。新しい access_token のみを永続化し、古い refresh_token はそのまま保持してください。

3. is_replicated = ?false は必須です

  1. セキュリティ。 レプリケートされた HTTP アウトコールは、サブネット内のすべてのノードからリクエストを送信します。各リクエストには Authorization: Bearer <token> ヘッダーが含まれるため、いずれかのノードからベアラートークンが漏洩すると、ユーザーの Google アカウントが侵害されます。
  2. 課金。 レプリケートされたアウトコールでは、N 個の API 呼び出しが並行して発生します。IC ではサイクルが約 13 倍課金され、Google では各呼び出しがクォータにカウントされます。
  3. 決定性。 Gmail の送信レスポンスは非決定的です(一意のメッセージ id、リクエストごとの Date ヘッダー)。レプリケートされたコンセンサスは失敗しますが、非レプリケートではコンセンサスを完全に回避できます。

→ 常に、すべての Config で is_replicated = ?false にしてください。

0.2.0 以降、このパッケージではこの値がデフォルトで設定されています。 defaultConfig.is_replicated は ?false なので、{ defaultConfig with auth = … } でもすでに安全です。要件を明確に示せるため、明示的な代入も引き続き有用です。

0.1.x からアップグレードする場合は、コードを確認してください。 0.2.0 より前の defaultConfig では is_replicated = null となっており、これはレプリケートを意味します。また、ジェネレーターが非レプリケートを強制するのは PUT と DELETE のみです。messages.send は POST です。このため、defaultConfig をそのまま使用していたアプリは、メールをレプリカごとに 1 回ずつ送信していました。このスキルに従ったコードは問題ありませんでしたが、そうでないコードでは、すべてのメールが約 13 通ずつ送信されていました。

4. 標準的なレイアウト

デフォルトの構成は、管理者の Client ID/Secret + ユーザーごとの OAuth です。キャニスターの所有者が Google Cloud の Desktop アプリを 1 つ登録し、その Client ID と Secret をキャニスターレベルの設定に貼り付けます。各エンドユーザーはその認証情報を使って OAuth 2.0 PKCE ハンドシェイクを実行し、それぞれの access_token と refresh_token を取得します。

この例は 4 つのファイルで構成されています。

  • src/backend/main.mo — アクター。state と include のみです。
  • src/backend/mixins/gmail-config.mo — 管理者認証で保護された Client ID と Secret。
  • src/backend/mixins/gmail-messaging.mo — ユーザーごとの OAuth と sendEmail。
  • src/backend/lib/gmail.mo — googlemail-client と google-oauth の連携。
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinGmailConfig "mixins/gmail-config";
import MixinGmailMessaging "mixins/gmail-messaging";
import LibGmail "lib/gmail";

actor {
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  let gmailConfig : {
    var clientId : Text;
    var clientSecret : Text;
  };
  include MixinGmailConfig(accessControlState, gmailConfig);

  let gmailConnections : Map.Map<Principal, LibGmail.GmailConnection>;
  let pendingGmailFlows : Map.Map<Principal, LibGmail.PendingOAuth>;
  include MixinGmailMessaging(gmailConfig, gmailConnections, pendingGmailFlows);
};

マイグレーションチェーンの先頭:

import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type GmailConnection = {
    accessToken : Text;
    refreshToken : Text;
    emailAddress : Text;
  };

  type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    gmailConfig : { var clientId : Text; var clientSecret : Text };
    gmailConnections : Map.Map<Principal, GmailConnection>;
    pendingGmailFlows : Map.Map<Principal, PendingOAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      gmailConfig = { var clientId = ""; var clientSecret = "" };
      gmailConnections = Map.empty<Principal, GmailConnection>();
      pendingGmailFlows = Map.empty<Principal, PendingOAuth>();
    };
  };
};
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

mixin (
  accessControlState : AccessControl.AccessControlState,
  gmailConfig : { var clientId : Text; var clientSecret : Text },
) {
  public query func isGmailConfigured() : async Bool {
    gmailConfig.clientId.size() > 0;
  };

  public shared ({ caller }) func setGmailCredentials(clientId : Text, clientSecret : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set Gmail credentials");
    };
    gmailConfig.clientId := clientId;
    gmailConfig.clientSecret := clientSecret;
  };
};
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibGmail "../lib/gmail";

mixin (
  gmailConfig : { var clientId : Text; var clientSecret : Text },
  gmailConnections : Map.Map<Principal, LibGmail.GmailConnection>,
  pendingGmailFlows : Map.Map<Principal, LibGmail.PendingOAuth>,
) {
  public query ({ caller }) func isMyGmailConnected() : async Bool {
    Map.containsKey(gmailConnections, Principal.compare, caller);
  };

  public query ({ caller }) func getMyGmailEmailAddress() : async ?Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to view your connected Gmail address");
    };
    switch (Map.get(gmailConnections, Principal.compare, caller)) {
      case (?connection) ?connection.emailAddress;
      case null null;
    };
  };

  public shared ({ caller }) func startGmailOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Gmail");
    };
    if (gmailConfig.clientId.size() == 0) {
      Runtime.trap("Gmail is not configured (admin must set credentials)");
    };
    await* LibGmail.startAuthorize(
      gmailConfig.clientId,
      redirectUri,
      caller,
      pendingGmailFlows,
    );
  };

  public shared ({ caller }) func completeGmailOAuth(code : Text, state : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Gmail");
    };
    if (gmailConfig.clientId.size() == 0) {
      Runtime.trap("Gmail is not configured");
    };
    let ?pending = Map.get(pendingGmailFlows, Principal.compare, caller) else {
      Runtime.trap("No pending OAuth flow — call startGmailOAuth first");
    };
    if (state != pending.state) {
      Runtime.trap("OAuth state did not match the pending Gmail flow");
    };
    Map.remove(pendingGmailFlows, Principal.compare, caller);
    let connection = await* LibGmail.exchangeCode(
      gmailConfig.clientId,
      gmailConfig.clientSecret,
      code,
      pending.redirectUri,
      pending.codeVerifier,
    );
    Map.add(gmailConnections, Principal.compare, caller, connection);
  };

  public shared ({ caller }) func sendEmail(
    to : Text,
    subject : Text,
    body : Text,
  ) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to send email");
    };
    let ?connection = Map.get(gmailConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Gmail account first");
    };
    await* LibGmail.sendEmail(
      gmailConfig.clientId,
      gmailConfig.clientSecret,
      connection,
      caller,
      gmailConnections,
      to,
      subject,
      body,
    );
  };

  public shared ({ caller }) func disconnectMyGmail() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(gmailConnections, Principal.compare, caller);
  };
};
import Error "mo:core/Error";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import Text "mo:core/Text";
import Runtime "mo:core/Runtime";
import OAuth "mo:google-oauth/OAuth";
import { gmail_users_messages_send } "mo:googlemail-client/Apis/UsersApi";
import { type Message; JSON = Message } "mo:googlemail-client/Models/Message";
import { defaultConfig; type Config } "mo:googlemail-client/Config";

module {
  public type GmailConnection = {
    accessToken : Text;
    refreshToken : Text;
    emailAddress : Text;
  };

  public type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  // Send-only: learn the address via OIDC userinfo (`openid email`), so no
  // `gmail.readonly`. Add `.../gmail.readonly` here ONLY if the app reads mail.
  let SCOPES : Text = "openid email https://www.googleapis.com/auth/gmail.send";

  func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer(token);
      is_replicated = ?false;
      max_response_bytes = ?Nat64.fromNat(2_000_000);
    };
  };

  public func startAuthorize(
    clientId : Text,
    redirectUri : Text,
    caller : Principal,
    pendingFlows : Map.Map<Principal, PendingOAuth>,
  ) : async* Text {
    let codeVerifier = await OAuth.generateCodeVerifier();
    let state = await OAuth.generateCodeVerifier();
    Map.add(pendingFlows, Principal.compare, caller, {
      codeVerifier;
      redirectUri;
      state;
    });
    OAuth.buildAuthorizeUrl(clientId, redirectUri, SCOPES, state, OAuth.computeCodeChallenge(codeVerifier));
  };

  public func exchangeCode(
    clientId : Text,
    clientSecret : Text,
    code : Text,
    redirectUri : Text,
    codeVerifier : Text,
  ) : async* GmailConnection {
    let tokens = await OAuth.exchangeAuthorizationCode(clientId, clientSecret, code, redirectUri, codeVerifier);
    let accessToken = accessTokenOf(tokens, "Token exchange");
    let refreshToken = tokens.refreshToken
      ?? Runtime.trap("Token exchange failed: missing refresh_token");
    // Learn the connected address via OIDC userinfo — needs only `openid email`,
    // never `gmail.readonly`. (If the app also reads mail and requests
    // `gmail.readonly`, `gmail_users_getProfile` is an equivalent alternative.)
    let emailAddress = (await OAuth.getUserEmail(accessToken))
      ?? Runtime.trap("Failed to fetch connected email from userinfo");
    { accessToken; refreshToken; emailAddress };
  };

  /// Send an email. On HTTP 401, refreshes the access token once and retries.
  /// Persists the refreshed token into `gmailConnections` keyed by `caller`.
  /// Any other failure propagates to the caller.
  public func sendEmail(
    clientId : Text,
    clientSecret : Text,
    connection : GmailConnection,
    caller : Principal,
    gmailConnections : Map.Map<Principal, GmailConnection>,
    to : Text,
    subject : Text,
    body : Text,
  ) : async* Text {
    let rawMessage = "To: " # to # "\r\n"
      # "Subject: " # subject # "\r\n"
      # "Content-Type: text/plain; charset=UTF-8\r\n\r\n" # body;
    let message : Message = { Message.init {} with raw = ?rawMessage.encodeUtf8() };
    try {
      messageIdOf(await* gmail_users_messages_send(
        configForToken(connection.accessToken),
        "me",
        ?#_1_,
        "",
        ?#json,
        "",
        "",
        "",
        "",
        true,
        "",
        "",
        "",
        message,
      ));
    } catch e {
      let msg = e.message();
      if (not (msg.contains(#text("401")) or msg.contains(#text("Unauthorized")))) {
        throw e;
      };
      let refreshed = await OAuth.refreshAccessToken(clientId, clientSecret, connection.refreshToken);
      let newToken = accessTokenOf(refreshed, "Token refresh");
      Map.add(gmailConnections, Principal.compare, caller, {
        connection with accessToken = newToken;
      });
      messageIdOf(await* gmail_users_messages_send(
        configForToken(newToken),
        "me",
        ?#_1_,
        "",
        ?#json,
        "",
        "",
        "",
        "",
        true,
        "",
        "",
        "",
        message,
      ));
    };
  };

  func accessTokenOf(tokens : OAuth.TokenResponse, operation : Text) : Text {
    switch (tokens.error) {
      case (?error) {
        let description = switch (tokens.errorDescription) {
          case (?value) ": " # value;
          case null "";
        };
        Runtime.trap(operation # " failed: " # error # description);
      };
      case null {};
    };
    tokens.accessToken ?? Runtime.trap(operation # " failed: missing access_token");
  };

  func messageIdOf(result : Message) : Text = result.id ?? "";
};

5. 利用可能な API

google-oauth(OAuth 2.0 の仕組み)
関数目的
OAuth.urlEncode(text)フォーム本文用の RFC 3986 パーセントエンコーディング
OAuth.parseTokenResponse(text)Google のトークンエンドポイントの JSON を解析します
OAuth.exchangeAuthorizationCode(...)認証コードをトークンと交換します
OAuth.refreshAccessToken(...)有効期限切れのアクセストークンを更新します
OAuth.generateCodeVerifier()PKCE の code_verifier を生成します(オンチェーンの乱数)
OAuth.computeCodeChallenge(verifier)PKCE の code_challenge を計算します(S256)
OAuth.buildAuthorizeUrl(...)Google OAuth 認証 URL を構築します
OAuth.getUserEmail(accessToken)OIDC userinfo 経由で連携済みのメールアドレスを取得します(openid email のみ必要)
googlemail-client(Gmail REST API)

上記の標準アクターでは、意図的にプロフィールの取得とメッセージの送信のみを実装しています。ほかの生成された操作を追加する場合も、ベアラー認証と is_replicated = ?false を維持し、sendEmail と同じ単一のリフレッシュ再試行パターンを適用してください。

同等のインターフェイスが2つあります。標準アクターで使用しているフラットなフリー関数は、引き続き完全にサポートされています。

関数目的
gmail_users_messages_sendRFC 5322 メッセージを送信する
gmail_users_messages_getIDでメッセージを取得する
gmail_users_messages_listメールボックス内のメッセージを一覧表示する
gmail_users_drafts_create下書きを作成する
gmail_users_drafts_sendIDで下書きを送信する
gmail_users_drafts_getIDで下書きを取得する
gmail_users_drafts_list下書きを一覧表示する
gmail_users_getProfileユーザーのプロフィール(メールアドレス、合計数)を取得する

0.2.0では、googledriveとgooglecalendarにあるものと同じ、ネストされたクラスの Client.mo ファサードも提供されます。これはGmailの階層的なoperationIdsを反映しています。Configを一度だけ取り込み、名前空間のみを付け替えます。Apisモジュールが引き続き信頼できる情報源となるため、1つのアクター内で両方のインターフェイスを混在させても問題ありません。

import { type Config } "mo:googlemail-client/Config";
import { Client } "mo:googlemail-client/Client";
import { type Message; JSON = Message } "mo:googlemail-client/Models/Message";

module {
  // `messages.send` takes 13 parameters: userId, the 11 shared Google query
  // parameters, then the Message. `$.xgafv` is spelled `DollarPeriodxgafv`.
  public func send(config : Config, mime : Blob) : async Text {
    let gmail = Client(config);
    let message : Message = { Message.init {} with raw = ?mime };
    let sent = await gmail.messages.send(
      "me", ?#_1_, "", ?#json, "", "", "", "", true, "", "", "", message,
    );
    sent.id ?? "";
  };

  public func profile(config : Config) : async Text {
    let gmail = Client(config);
    let me = await gmail.getProfile("me", ?#_1_, "", ?#json, "", "", "", "", true, "", "", "");
    me.emailAddress ?? "";
  };
}

gmail.draftsにはcreate/get/list/sendがあり、gmail.messagesにはget/list/sendがあります。getProfileには独自のリソースセグメントがないため、Clientに直接配置されています。

0.1.6からの移行。 省略可能な列挙型クエリパラメーター3つ(DollarPeriodxgafv($.xgafv)、alt、および2つのget操作のformat)は、両方のインターフェイスで?Tになりました。そのため、すべての呼び出し箇所で?が必要です。

// 0.1.6
await* gmail_users_messages_send(cfg, "me", #_1_, "", #json, …, message);
// 0.2.0
await* gmail_users_messages_send(cfg, "me", ?#_1_, "", ?#json, …, message);

代わりにnullを渡すと、クエリ文字列からパラメーターを完全に省略できます。これが?を使う利点です。列挙型以外のパラメーターに変更はなく、引き続きnullは指定しません。これは、googlecalendarが0.2.0で行った変更と同じです。このスキルの以前のドラフトでは、src/Apis/UsersApi.moが.openapi-generator-ignoreによって凍結され、?T導入前のシグネチャが維持されていたため、googlemailはこの変更を免れると説明していました。現在は何も凍結されていません。これまで手作業で適用されていた2つのパッチは、どちらも生成されるようになりました。後者(rawのbase64url対応)は今回のリリースからです。

6. サイクルとレスポンスサイズ

google-oauthライブラリはmo:ic/CallのCall.httpRequestを使用します。この関数は、ic0.cost_http_requestシステムAPIを使って必要なサイクル数を正確に自動計算し、付加します。トークンの交換やリフレッシュに、手動でサイクル数を見積もる必要はありません。

googlemail-clientの呼び出しでは、cyclesを未設定(null)にしてください。各呼び出しには、そのリクエストに対してICが請求する料金が付加されます。料金は実際に送信されるメッセージのサイズに基づくため、大きなメッセージでも手動で予算を設定する必要はありません。また、アウトコールが無料のサブネットでは料金はゼロです。?nを指定すると、正確にnサイクルが付加されます。nが料金を下回る場合、ICは呼び出しを拒否します。また、キャニスターがn未満のサイクルしか保持していないサブネットでは、呼び出しがトラップします。大きなペイロードを含む可能性のあるメッセージの読み取りでは、max_response_bytes = ?2_000_000を設定してください。

7. 注意すべき点

  • is_replicated = ?false — §3 を参照してください。必須です。
  • Google のリフレッシュトークンはローテーションされません。 X/Twitter と異なり、Google はリフレッシュのたびに新しい refresh_token を発行しません。元の refresh_token を保持し、新しい access_token のみを永続化してください。§4 の sendEmail 関数はこの処理に対応しています。
  • アクセストークンの有効期限は1時間です。 sendEmail 関数は HTTP 401 を検出すると、google-oauth.refreshAccessToken を使って通知なしで更新し、1回だけ再試行します。それ以外の失敗はすべて reject として伝播するため、その失敗に耐える必要がある呼び出し元(たとえば、大きな更新全体を失敗させてはいけない送信処理)は、呼び出し元でエラーを捕捉します。更新に失敗すると accessTokenOf 内でトラップするため、呼び出し元では捕捉できません。その場合は「アカウントを再接続してください」と表示してください。
  • コールバック URI は完全一致が必要です。 末尾のスラッシュ、クエリ文字列、ポートを含むすべての文字が、認可 URL とリダイレクト先で一致していなければなりません。一致しない場合、Google は redirect_uri_mismatch を返します。redirectUri には固定値 window.location.origin + "/connect/gmail" を使用してください。これは設定ページに表示される値であり、/connect/gmail ルートが所有する値でもあります。また、この URI を Google Web クライアントに登録してください。ページによって変わる window.location.pathname から URI を構築しないでください。
  • 表示されている値を変更せずに startGmailOAuth に渡してください。生の *.icp0.io カナスター URL は決して渡さないでください。 Caffeine アプリは複数のオリジン(*-draft.caffeine.xyz のドラフト、*.caffeine.xyz のライブドメイン、生の <canister-id>.icp0.io URL)で提供されます。リダイレクト URI は共通ヘルパー(window.location.origin + "/connect/gmail")1か所で計算し、設定ページのコピー可能なフィールドと startGmailOAuth に渡す値の両方で同じヘルパーを使ってください。Google に送信される値(startGmailOAuth 経由)が設定ページに表示された値や管理者が登録した値と異なると、たとえばビルド時設定値や *.icp0.io のカナスターオリジンが使われると、Google は redirect_uri_mismatch を返します。
  • RFC 5322 の raw Blob。 メッセージは raw フィールドにプレーンな Blob として渡してください(?Text.encodeUtf8(mime))。googlemail-client が API 用に Base64URL エンコードします。これは RFC 4648 §5 に準拠したもので、Gmail が要求する形式です。また、format = ?#full/?#raw でメッセージを読み込む際には、raw を Base64URL からデコードします。自分でエンコードしないでください(2重にエンコードされ、Gmail に拒否されます)。0.2.0 より前は読み取り方向に不具合があり、Gmail から返された raw をデコードすると null になっていました。
  • HTTP 429 のレート制限。 エラーは呼び出し元に伝えてください。カナスター内で通知なしに再試行しないでください。送信を再試行すると、重複して配信されるおそれがあります。
  • アクセストークンを公開しないでください。 gmailConnections は sendEmail 内での Map.get(gmailConnections, ..., caller) からのみ読み取ります。getMyGmailConnection も getMyAccessToken もイテレーターも用意しないでください。ベアラートークンが漏れると、ユーザーのアカウントが侵害されます。
  • すべての Gmail API v1 呼び出しで xgafv = ?#_1_、alt = ?#json を指定してください(0.2.0 より前は #_1_ と #json をそのまま指定していました。§5 の移行に関する注記を参照してください)。省略可能な文字列パラメーターは ""、prettyPrint は false にしてください。
  • ?T を使うのは enum クエリパラメーターだけです。ほかは通常の位置引数です。 DollarPeriodxgafv、alt、format には ?#_1_/?#json/?#full、またはクエリ文字列から省略する場合は null を指定します。Text と Bool のパラメーター(fields、prettyPrint、quotaUser など)には ""/false を指定します。これらに null を指定すると型チェックに通りません。
  • userId = "me" は認証済みユーザーを指します。

フロントエンド

このスキルを使ったビルドでは、以下の4項目すべてを必ず実装してください。(アプリが Google Calendar コネクタも使用する場合は、以下の「Gmail + Calendar を組み合わせたアプリ」に従ってください。そこでは /settings/gmail + /connect/gmail が、共通の /settings/google + /connect/google に置き換わります。以下の要件は引き続き適用されますが、2つのパスだけが変わります。)これらは提案ではなく受け入れ基準です。ビルドの完了前に各項目を確認してください。ビルドで見落とされやすい要件は次の3つです。1つでも欠けると、コネクタは不完全ではなく、動作しません。

  • 認証情報ページが存在し、アクセスできること。 アプリには Client ID/Secret の入力欄(項目2)を備えた /settings/gmail ページが必須であり、サインイン済みの管理者がナビゲーションリンクまたは接続ページの未設定時プロンプトからアクセスできなければなりません。認証情報を入力するページがないまま「Connect Gmail」ボタンだけを表示するのは、最もよくある失敗であり、コネクタを使用不能にします。
  • 管理者設定ページに、コピー可能なリダイレクト URI の文字列がそのまま表示されること。 <your-domain> というプレースホルダーでも、「アプリの URL に /connect/gmail を追加」といった、管理者が組み立てる必要のある説明文でもありません。読み取り専用フィールドに window.location.origin + "/connect/gmail" の実際の値を表示し、管理者がコピーできるようにしてください。具体的には、https://my-app.caffeine.xyz で配信されるアプリでは、フィールドに https://my-app.caffeine.xyz/connect/gmail だけを表示する必要があります。これがなければ、管理者は Google に URI を登録できず、すべての接続が失敗します。
  • /connect/gmail が Google のコールバックを処理する実際のルートであること。 ボタンだけのページではいけません。フォールスルーしてキャッチオールやホームへのリダイレクトに進む場合、または認証済みのアクターの準備が整う前に completeGmailOAuth を呼び出す場合、接続は失敗しても通知されず、アプリには「未接続」と表示されます。
  1. ログインフロー — 必須です。 Gmail は匿名でない呼び出し元がいなければ動作しません。ユーザーごとの OAuth ハンドシェイクでは caller : Principal をキーにトークンが保存され、管理者の認証情報設定は #admin によって制御されます。ログインフローは extension-authorization から取得します。useInternetIdentity、ログイン/ログアウトボタン、および認証済みアイデンティティをすべてのバックエンド呼び出しに注入する useActor の配線を使用してください。

  2. 管理者設定ページ — /settings/gmail(管理者のみアクセス可能)。このページは必須です。このページがなければ、Gmail 対応ビルドは未完成です:

    • 認証情報の入力欄より前に「Google 認証情報の取得方法」パネルを表示します。一度だけ、約5分で完了する設定であることを管理者に伝え、次の番号付き手順を案内します(エージェントの完了メッセージでも同じ手順を繰り返す必要があります):
      1. Google Cloud Console を開き、任意の Google アカウントでサインインします。
      2. プロジェクトを作成するか、既存のプロジェクトを選択します。
      3. Gmail API を有効にします(APIs & Services → Library → 「Gmail API」を検索 → Enable)。
      4. OAuth 同意画面を設定します(APIs & Services → OAuth consent screen → External。アプリ名、サポート用メールアドレス、開発者のメールアドレスを設定します。Google のデフォルトのスコープで問題ありません)。
      5. タイプが Web application の OAuth クライアント ID を作成します(APIs & Services → Credentials → Create Credentials → OAuth client ID)。
      6. Authorized redirect URIs に、このページのコピー可能なフィールドに表示された値をそのまま追加します。
      7. 発行された Client ID と Client Secret を下の入力欄にコピーして保存します。 Google Cloud Console を開く便利なリンクも含めてください。
    • 共通のヘルパー const gmailRedirectUri = () => window.location.origin + "/connect/gmail"; を使って、実際の URI を読み取り専用のコピー可能なフィールドに表示します。たとえば、アプリを https://my-app.caffeine.xyz で開いている場合、表示値は https://my-app.caffeine.xyz/connect/gmail です。<app-domain> のみを表示したり、管理者に URI の推測を求めたりしてはいけません。
    • setGmailCredentials(clientId, clientSecret) に紐付けられた2つのパスワード入力欄を用意します。Enter キーで送信できるようにし、成功したら入力欄をクリアします。
    • isGmailConfigured()(Bool を返します)に基づくステータスインジケーターを表示します。「Configured」/「Not configured」と表示し、認証情報は決して表示しません。
    • このページにアクセスできるようにしてください。 アプリのメインナビゲーション(共通の Layout)から、管理者がこのページに移動できるようにリンクする必要があります。extension-authorization を使い、isCallerAdmin が true の場合はリンクを表示し、それ以外の場合は非表示にしてください。ナビゲーションが定義されている箇所にリンクを追加し、このページ内には追加しないでください。アクセス方法のない /settings/gmail ルートは、ビルドとして不完全です。ナビゲーションだけに頼らないでください。以下の未設定時の案内が、セットアップの必要性をユーザーが知るための主要な方法です。
  3. 「Gmail を接続」ページとコールバックページ — /connect/gmail(サインイン済みのすべてのユーザーが利用できます)。この専用ページでは、同意後に Google からリダイレクトされた際に、そのリダイレクトを受け取り処理する必要があります。接続ボタンだけのページではありません。

    • 未設定の場合の処理をすべてのユーザーに用意します。 isGmailConfigured() は公開クエリです(サインイン済みのユーザーなら誰でも呼び出せます)。false が返された場合、機能しない接続ボタンを表示してはいけません。管理者には認証情報を入力するための /settings/gmail へのリンクを表示します。管理者以外には、行き詰まらせることなく状況を説明するメッセージを表示します。例:「Gmail はまだ設定されていません。アプリの管理者が設定で Google の認証情報を追加する必要があります。」設定が完了してから「Gmail を接続」ボタンを有効にします。
    • 「Gmail を接続」ボタンは startGmailOAuth(gmailRedirectUri()) にバインドします。カニスターから返された URL にブラウザーをリダイレクトします。任意の現在のパス名からコールバック URL を導出してはいけません。固定の /connect/gmail ルートと設定ページの URI は同一である必要があります。
    • /connect/gmail を実際のアプリケーションルートとして登録します。Google のコールバックを受け取り、処理前にキャッチオールリダイレクト、レイアウトのデフォルト、またはホームページへフォールスルーしてはいけません。
    • 戻りの処理では、URLSearchParams から error、code、state を読み取ります。error がある場合は、接続の失敗または拒否の状態を表示し、カニスターを呼び出してはいけません。code と state の両方がある場合にのみ、どこかへ遷移したり URL を消去したりする前に completeGmailOAuth(code, state) を呼び出し、完了を待ちます。処理中は「Gmail を接続しています…」という状態を表示し続けます。先にルートを置き換えたり、ホームページへリダイレクトしたり、クエリパラメーターを破棄したりしてはいけません。そうすると一度限りのコードが失われ、ユーザーは接続されないままになります。
    • 一度限りのコールバックを呼び出す前に、アクターの準備が整うまで待ちます。 completeGmailOAuth を呼び出す前に、useInternetIdentity().isAuthenticated が true になり、useActor(createActor) から null ではなく、フェッチ中でもないアクターが提供されるまでページは待つ必要があります。その時点まで startedRef などの一度だけ実行するためのガードを設定してはいけません。初回のレンダリングではアクターを利用できないことが多く、そうしないと「Actor not ready」エラーによって、認証コードが URL に残っているにもかかわらず、唯一の再試行機会が消費されてしまいます。
    • いずれかの終了経路の後、history.replaceState を呼び出して OAuth のクエリパラメーターを削除します。これにより、ページの更新時に一度限りの認証コードが再利用されるのを防ぎます。
    • isMyGmailConnected()(Bool を返します)に基づいてステータスを表示します。接続済みの場合は getMyGmailEmailAddress() を呼び出して「次のアカウントで接続済み:[email protected]」と表示します。この関数が返すのは保存されたメールアドレスのみで、どちらのベアラートークンも返しません。
    • 「Gmail の接続を解除」ボタンは任意です。表示する場合は disconnectMyGmail() にバインドします。
  4. 空状態での案内。 isMyGmailConnected() が false の場合、メール送信 UI に /connect/gmail へのインラインリンク「メールを送信するには Gmail を接続」を表示します。isGmailConfigured() が false で、呼び出し元が管理者の場合は、認証情報ページを見つけやすくし、単にアクセスできるだけの状態にしないため、/settings/gmail への「Gmail を設定」リンクを表示します。

ルートの推奨レイアウト:

/                   →  Main UI (any signed-in user; empty-state when no Gmail connection)
/settings/gmail     →  Admin credential config (admin-only)
/connect/gmail      →  Per-user OAuth handshake (any signed-in user)
# If the app ALSO uses Google Calendar: drop the two routes above and use a
# single /settings/google + /connect/google — see "Combined Gmail + Calendar apps".

Gmail と Calendar を組み合わせたアプリ

アプリで両方のコネクタを使用する場合、Google 接続は 2 つではなく、共有のものを1 つ作成します(認可コードは 1 回限り使用できるため、フローを 2 つ作成すると同意画面が 2 回表示されます)。 フロントエンド:

  • 管理ページは /settings/google の 1 つだけ — Client ID / Client Secret のフォームを 1 つ、isGoogleConfigured ステータスを 1 つ、正確に window.location.origin + "/connect/google" と表示するコピー可能なリダイレクト URI フィールドを 1 つ用意します。
  • 接続ルートは /connect/google の 1 つだけ — 上記のフロントエンドセクションで必要な、実際のコールバックルートと同じです。「Connect Google」を表示し、リダイレクトを受け取り、アクターの準備ができるまで待ってから、完了処理を 1 回呼び出します。2 つ目のコールバックルートは作成しません。
  • /settings/gmail、/connect/gmail、/settings/calendar、/connect/calendar は作成しないでください。 これ以外のフロントエンド要件はすべて引き続き適用されます。変更されるのはこれらのパスだけです。

バックエンド — 共有フローを1 回だけ記述します(コネクタごとの 2 つの OAuth フローを置き換えます)。コネクタごとの startAuthorize / exchangeCode / refresh 関数と同じ構成で、正確な違いは次のとおりです。

  • 単一の Client ID/Secret を保存する、#admin ゲート付き設定セッターを 1 つ用意します。
  • SCOPES = 以下の統合スコープ。両方の API を 1 回の同意で認可します。
  • completeGoogleOAuth(code, state) は OAuth.getUserEmail(OIDC userinfo — openid email のみが必要で、gmail.readonly は不要)を使って接続されたメールアドレスを取得し、単一の Map<Principal, GoogleConnection> に 1 つの接続 { accessToken; refreshToken; emailAddress } を保存します。
  • Gmail の送信処理と Calendar の呼び出し処理は、それぞれ同じ accessToken から独自のクライアント Config を作成し、それぞれ 401 時の 1 回限りのリトライを維持します。
  • 接続とクライアント設定は1 つの共有状態値に保持し、Gmail と Calendar の両方のミックスインにパラメーターとして渡します。これにより、両方が同じ接続を読み書きします(writing-motoko のミックスイン規則を参照)。
let SCOPES : Text =
  "openid email "                                    // learn the address via userinfo
  # "https://www.googleapis.com/auth/gmail.send "
  # "https://www.googleapis.com/auth/calendar";
// Add "https://www.googleapis.com/auth/gmail.readonly " ONLY if the app reads mail.

両方のサービスで共有する1 つの接続として配線します。設定、接続マップ、保留中フローマップをそれぞれ 1 回だけ宣言し、同じバインディングをすべてのミックスインに渡します。Gmail と Calendar のメッセージングミックスインは、独自の設定や接続を宣言しません。共有の googleConfig と googleConnections を受け取ります(401 時のリトライには設定が必要です)。

actor {
  let accessControlState : AccessControl.AccessControlState;
  include MixinAuthorization(accessControlState, null);

  // ONE shared credential + connection state for both services.
  let googleConfig : { var clientId : Text; var clientSecret : Text };
  let googleConnections : Map.Map<Principal, Google.Connection>;
  let pendingGoogleFlows : Map.Map<Principal, Google.PendingOAuth>;

  include MixinGoogleConfig(accessControlState, googleConfig);                    // setGoogleCredentials / isGoogleConfigured (#admin-gated setter)
  include MixinGoogleOAuth(googleConfig, googleConnections, pendingGoogleFlows);  // startGoogleOAuth / completeGoogleOAuth, SCOPES = union above
  include MixinGmailMessaging(googleConfig, googleConnections);                  // sendEmail — refresh-on-401 needs config; reads the shared connection
  include MixinCalendarMessaging(googleConfig, googleConnections);               // calendar calls — same shared config + connection
};

このバリアントでは、マイグレーションチェーンのヘッドがコネクタごとのものを置き換えます。

import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";

module {
  type Connection = {
    accessToken : Text;
    refreshToken : Text;
    emailAddress : Text;
  };

  type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  type NewActor = {
    accessControlState : AccessControl.AccessControlState;
    googleConfig : { var clientId : Text; var clientSecret : Text };
    googleConnections : Map.Map<Principal, Connection>;
    pendingGoogleFlows : Map.Map<Principal, PendingOAuth>;
  };

  public func migration(_old : {}) : NewActor {
    {
      accessControlState = AccessControl.initState();
      googleConfig = { var clientId = ""; var clientSecret = "" };
      googleConnections = Map.empty<Principal, Connection>();
      pendingGoogleFlows = Map.empty<Principal, PendingOAuth>();
    };
  };
};

Gmail と Calendar に別々の設定/接続状態や別々の OAuth フローを用意しないでください。認可コードは 1 回限り使用でき、状態を分けると同期がずれます(writing-motoko のミックスイン規則を参照)。

1 つの OAuth クライアントで両方の API を有効にし、.../connect/google のリダイレクト URI だけを登録します。ユーザーが 2 つの異なる Google アカウントへの接続を明示的に求めた場合に限り、2 つのパネルに分けます。

すべてのバリアントに共通

  • Gmail 関連のすべてのルートでサインインが必要です。 /settings/... と接続ルート(統合アプリでは /connect/gmail または /connect/google)を、extension-authorization の認証ガード(useInternetIdentity + !isAuthenticated の場合のリダイレクト)を通して配線します。
  • フロントエンドでトークンを永続化することはありません。 localStorage、IndexedDB、Cookie は使いません。すべてカニスターを介して処理します。ブラウザーに表示されるのは Bool のステータスフラグと OAuth リダイレクト URL だけです。
  • OAuth の state パラメーターはカニスターが生成し、検証します。 カニスターはランダムなノンスを、保留中の検証子およびコールバック URI とともに保存します。フロントエンドは完了処理の呼び出しに code と state の両方を渡す必要があります(completeGmailOAuth、統合アプリでは completeGoogleOAuth)。どちらの値もフロントエンドで作成または変更することはありません。
  • メール送信 UI 自体はシンプルです。 to、subject、body の入力欄と送信ボタンを用意します。クライアント側の Gmail SDK も、トークン処理も、JSON シリアライズも不要です。Gmail クライアントはカニスターです。

関連項目

コメント