connector-googlemail について
ログインユーザーとして Motoko canister から Gmail を操作する Caffeine AI 向けコネクタースキルです。googlemail-client と google-oauth を組み合わせ、ユーザーごとの PKCE 認証、メール操作、トークン更新を行い、認証情報は canister 内に保持します。
主な機能
- 01Gmail メッセージの送信と管理
- 02ユーザーごとの OAuth 2.0 PKCE
- 03期限切れアクセストークンの更新
- 04Google OAuth と Gmail クライアント
- 05canister 内での認証情報保持
活用シーン
- 01ユーザー自身の Gmail からメールを送信
- 02接続済みメールボックスの閲覧や一覧表示
- 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 を操作する場合は必ずこのスキルを使用してください。必要なものは次のとおりです。
googlemail-clientmops パッケージ — Gmail REST API v1 用の生成済み Motoko バインディングです。この手順ではプロフィールの取得とメッセージの送信を説明します。生成済みのその他の操作を追加する場合は、同じベアラー認証、非複製、単一のリフレッシュ再試行のパターンに従ってください。google-oauthmops パッケージ — Google OAuth 2.0 のトークン交換、更新、PKCE、パーセントエンコーディングを提供します。このライブラリにより、oauth2.googleapis.comへのhttp_requestを手作業で実装する必要がなくなります。- 各エンドユーザーが代理操作をカニスターに許可するための、PKCE を使った OAuth 2.0 認可コードフローです。ユーザーごとに
access_tokenとrefresh_tokenを保持し、caller : Principalをキーとして保存します。 - 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 を使った認可コードフローを通じて、個別にカニスターへの操作を許可します。カニスターは次の処理を行います。
- PKCE の
code_verifierとcode_challengeを生成します(google-oauthを使用)。 - Google の認可 URL を構築します(
google-oauth.buildAuthorizeUrlを使用)。 - フロントエンドからユーザーを Google にリダイレクトします。同意後、Google は
codeパラメーターを付けてリダイレクトします。 - カニスターがコードをトークンと交換します(
google-oauth.exchangeAuthorizationCodeを使用)。この処理はオンチェーンで、非複製です。 - カニスターが
access_tokenとrefresh_tokenをcallerをキーとして保存します。 - 1 時間の有効期限が切れたアクセストークンに対して HTTP 401 が返された場合、カニスターはユーザーに見せずにトークンを更新し(
google-oauth.refreshAccessTokenを使用)、再試行します。
Google Cloud Console の設定
- Google OAuth 2.0 のウェブアプリケーションクライアントを作成します。
- アプリの Gmail 設定ページに、次のコールバック URI をそのままコピーできるフィールドで表示する必要があります:
window.location.origin + "/connect/gmail"— 例:https://my-app.caffeine.xyz/connect/gmail。アプリの管理者は、表示された値を手動で Google Cloud Console の承認済みのリダイレクト URIにコピーする必要があります。ユーザーが Gmail に接続できるデプロイ済みのオリジン(例:ドラフト版と本番アプリのオリジン)は、それぞれ別の承認済みリダイレクト URI として登録します。 - 同意画面では、アプリに必要な Gmail スコープのみを有効にします。
- Client ID と Client Secret は、アプリの管理者設定ページから入力します。キャニスターはトークン交換にシークレットを使用します。フロントエンドがシークレットを受け取ることは決してありません。
PKCE により、各認可コードはキャニスターが生成したベリファイアに紐付けられます。一方、Web クライアントの登録により、ブラウザーのコールバックはデプロイ済みアプリに紐付けられます。startGmailOAuth に渡すコールバック URI は、設定ページに表示され、管理者が登録した値と完全に一致している必要があります。
OAuth スコープ
| スコープ | 目的 |
|---|---|
openid email | OAuth.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 は必須です
- セキュリティ。 レプリケートされた HTTP アウトコールは、サブネット内のすべてのノードからリクエストを送信します。各リクエストには
Authorization: Bearer <token>ヘッダーが含まれるため、いずれかのノードからベアラートークンが漏洩すると、ユーザーの Google アカウントが侵害されます。 - 課金。 レプリケートされたアウトコールでは、N 個の API 呼び出しが並行して発生します。IC ではサイクルが約 13 倍課金され、Google では各呼び出しがクォータにカウントされます。
- 決定性。 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_send | RFC 5322 メッセージを送信する |
gmail_users_messages_get | IDでメッセージを取得する |
gmail_users_messages_list | メールボックス内のメッセージを一覧表示する |
gmail_users_drafts_create | 下書きを作成する |
gmail_users_drafts_send | IDで下書きを送信する |
gmail_users_drafts_get | IDで下書きを取得する |
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.ioURL)で提供されます。リダイレクト URI は共通ヘルパー(window.location.origin + "/connect/gmail")1か所で計算し、設定ページのコピー可能なフィールドとstartGmailOAuthに渡す値の両方で同じヘルパーを使ってください。Google に送信される値(startGmailOAuth経由)が設定ページに表示された値や管理者が登録した値と異なると、たとえばビルド時設定値や*.icp0.ioのカナスターオリジンが使われると、Google はredirect_uri_mismatchを返します。 - RFC 5322 の
rawBlob。 メッセージは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を呼び出す場合、接続は失敗しても通知されず、アプリには「未接続」と表示されます。
-
ログインフロー — 必須です。 Gmail は匿名でない呼び出し元がいなければ動作しません。ユーザーごとの OAuth ハンドシェイクでは
caller : Principalをキーにトークンが保存され、管理者の認証情報設定は#adminによって制御されます。ログインフローはextension-authorizationから取得します。useInternetIdentity、ログイン/ログアウトボタン、および認証済みアイデンティティをすべてのバックエンド呼び出しに注入するuseActorの配線を使用してください。 -
管理者設定ページ —
/settings/gmail(管理者のみアクセス可能)。このページは必須です。このページがなければ、Gmail 対応ビルドは未完成です:- 認証情報の入力欄より前に「Google 認証情報の取得方法」パネルを表示します。一度だけ、約5分で完了する設定であることを管理者に伝え、次の番号付き手順を案内します(エージェントの完了メッセージでも同じ手順を繰り返す必要があります):
- Google Cloud Console を開き、任意の Google アカウントでサインインします。
- プロジェクトを作成するか、既存のプロジェクトを選択します。
- Gmail API を有効にします(APIs & Services → Library → 「Gmail API」を検索 → Enable)。
- OAuth 同意画面を設定します(APIs & Services → OAuth consent screen → External。アプリ名、サポート用メールアドレス、開発者のメールアドレスを設定します。Google のデフォルトのスコープで問題ありません)。
- タイプが Web application の OAuth クライアント ID を作成します(APIs & Services → Credentials → Create Credentials → OAuth client ID)。
- Authorized redirect URIs に、このページのコピー可能なフィールドに表示された値をそのまま追加します。
- 発行された 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ルートは、ビルドとして不完全です。ナビゲーションだけに頼らないでください。以下の未設定時の案内が、セットアップの必要性をユーザーが知るための主要な方法です。
- 認証情報の入力欄より前に「Google 認証情報の取得方法」パネルを表示します。一度だけ、約5分で完了する設定であることを管理者に伝え、次の番号付き手順を案内します(エージェントの完了メッセージでも同じ手順を繰り返す必要があります):
-
「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()にバインドします。
- 未設定の場合の処理をすべてのユーザーに用意します。
-
空状態での案内。
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 クライアントはカニスターです。
関連項目
mops add [email protected]— Gmail REST API バインディング。mops add [email protected]— Google OAuth 2.0 ライブラリ(トークン交換、更新、PKCE、getUserEmailユーザー情報、DateTimeRFC 3339 ヘルパー)。- Web サーバーアプリケーション向け Google OAuth 2.0 — Web クライアントのリダイレクト URI と認可コードフローのリファレンス。
- Gmail API v1 リファレンス —
googlemail-clientがラップする API。 - RFC 7636 — Proof Key for Code Exchange — PKCE の仕様。
- extension-authorization — 必須の前提条件。Internet Identity ログイン、
useInternetIdentity/useActorのフロントエンド連携、#adminロールゲートを提供します。