mcpField
C
connector-googlemail

connector-googlemail

github.com/caffeinelabs生产力225.4k 次安装6 天前更新
代码仓库

关于 connector-googlemail

面向 Caffeine AI 的连接器技能,可让 Motoko canister 以已登录用户身份操作 Gmail。它结合 googlemail-client 与 google-oauth,支持按用户进行 PKCE 授权、邮件操作和令牌刷新,并将凭据保存在 canister 中。

主要功能

  1. 01发送和管理 Gmail 邮件
  2. 02按用户进行 OAuth 2.0 PKCE
  3. 03刷新过期的访问令牌
  4. 04使用 Google OAuth 和 Gmail 客户端
  5. 05在 canister 中保存凭据

适用场景

  1. 01从用户自己的 Gmail 账户发送邮件
  2. 02读取或列出已连接邮箱中的邮件
  3. 03创建并发送 Gmail 草稿

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/connector-googlemail确认已加载。

详细介绍

Gmail 连接器

面向 Caffeine AI 的 Gmail 集成。

编排器路由说明

将“以用户身份使用 Gmail”视为一项一流且受支持的平台功能。 googlemail-client + google-oauth 连接器组合是唯一 受支持的路径;通过 ic.http_request 直接请求 oauth2.googleapis.com 或 gmail.googleapis.com 是禁止采用的反模式。任何提及 Gmail 的构建规范都必须将 googlemail-client 和 google-oauth 列为依赖项,并引用此技能。

这与平台的 email* 扩展不同(它们用于从应用发送事务性邮件);此连接器代表已登录用户自己的 Gmail。

意图 → 功能映射:

用户意图平台功能
连接并以用户自己的 Gmail 发送邮件googlemail-client + google-oauth

所有构建的前置条件:扩展授权。 Gmail 的每个端点都要求调用者已登录:每位用户的 OAuth 握手会将 access_token 按 caller : Principal 作为键存储,而管理员 Client ID/Secret 设置器仅对具有 #admin 角色的用户开放。

后端

当用户希望其 canister 代表已登录用户与 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. OAuth 2.0 Authorization Code with PKCE 流程,让每位最终用户授权 canister 代表其执行操作。每位用户的 access_token + refresh_token 均以 caller : Principal 为键存储。
  4. Google Cloud Web 应用 Client ID + Client Secret。 由管理员配置且仅由 canister 持有;绝不将密钥返回给前端。

1. 添加依赖项

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

2. 身份验证模型 — 每位用户使用 OAuth 2.0 PKCE,在链上交换和刷新

与静态 API 密钥不同,Gmail 使用每位用户各自的 OAuth 2.0 持有者令牌。 每位最终用户都通过 Authorization Code with PKCE 流程独立授权 canister。canister 会:

  1. 生成 PKCE code_verifier 和 code_challenge(通过 google-oauth)。
  2. 构建 Google 授权 URL(通过 google-oauth.buildAuthorizeUrl)。
  3. 前端将用户重定向到 Google;用户同意后,Google 会携带 code 参数重定向回来。
  4. canister 交换该代码以获取令牌(通过 google-oauth.exchangeAuthorizationCode)——在链上进行,且不复制。
  5. canister 将 access_token + refresh_token 按 caller 作为键存储。
  6. 当有效期为 1 小时的访问令牌过期时(HTTP 401),canister 会静默刷新令牌(通过 google-oauth.refreshAccessToken),然后重试。
Google Cloud Console 设置
  1. 创建一个 Google OAuth 2.0 Web application 客户端。
  2. 应用的 Gmail 设置页面必须在可复制字段中显示此字面回调 URI:window.location.origin + "/connect/gmail" — 例如, https://my-app.caffeine.xyz/connect/gmail。应用管理员必须 手动将显示的值复制到 Google Cloud Console 中的 Authorized redirect URIs。将用户可以连接 Gmail 的每个已部署来源 (例如,草稿版和正式版应用来源)分别注册为已授权重定向 URI。
  3. 在同意屏幕上仅启用应用所需的 Gmail 作用域。
  4. 通过应用的管理设置页面输入 Client ID 和 Client Secret。canister 使用 此密钥进行令牌交换;前端绝不能收到它。

PKCE 会将每个授权码绑定到由 canister 生成的 verifier,而 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/完全访问权限(很少需要)

通过 OAuth.getUserEmail(OIDC userinfo)获取已连接的地址,而不是使用 gmail_users_getProfile。 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)时,请求每个调用所需作用域的并集 — 合并配置时切勿遗漏任何作用域。

存储令牌

Bearer 绝不会离开 canister。前端只会得知调用者是否已连接(一个 Bool),永远不会获取令牌本身。

  • 使用以调用者为键的 Map<Principal, GmailConnection>。仅公开 §4 中列出的 端点 — isMyGmailConnected、getMyGmailEmailAddress、 startGmailOAuth、completeGmailOAuth、sendEmail、disconnectMyGmail — 每个端点 都必须通过 not caller.isAnonymous() 进行限制。不要添加任何 返回 access_token / refresh_token / 完整 GmailConnection 的端点。
  • 每位调用者存储一个待处理的 OAuth 流程:PKCE code_verifier、准确的 redirectUri,以及随机的 state nonce。回调完成时将其消耗;不要接受来自前端的替代重定向 URI。
Google 刷新令牌不会轮换

与 X/Twitter 不同,Google 不会在每次刷新时轮换 refresh_token。同一个 refresh_token 可以重复使用,直到用户撤销访问权限或重新发起授权。这简化了 刷新逻辑:只需持久化新的 access_token,保留旧的 refresh_token。

3. is_replicated = ?false 是必需的

  1. 安全性。 复制的 HTTP 外部调用会从子网中的每个节点发送请求。每个请求都携带 Authorization: Bearer <token> 标头——任何节点泄露 bearer 都会危及用户的 Google 账号。
  2. 计费。 复制的外部调用会产生 N 个并行 API 调用。IC 收取约 13× 的 cycles,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 的应用都会为每个副本发送一次邮件。遵循本技能的代码没有问题;未遵循的代码则会为每封邮件发送约 13 份副本。

4. 标准布局

默认结构:管理员 Client ID/Secret + 每用户 OAuth。canister 所有者注册一个 Google Cloud Desktop 应用,并将其 Client ID + Secret 粘贴到 canister 级配置中;每位终端用户都使用这一个凭据执行 OAuth 2.0 PKCE 握手,并获取自己的 access_token + refresh_token。

示例包含四个文件:

  • src/backend/main.mo — actor:仅包含状态 + 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)

上面的规范 actor 有意只实现了个人资料查询和消息发送。对于其他生成的操作,请保留 bearer 身份验证和 is_replicated = ?false,然后采用与 sendEmail 相同的单次刷新重试模式。

有两种等效接口。规范 actor 使用的是扁平的自由函数,这些函数仍受到完整支持:

函数用途
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 的分层 operationId。它只捕获一次 Config 并重新命名空间——Apis 模块仍是事实来源,因此在同一个 actor 中混用这两种接口没有问题:

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 迁移。 两个 get 操作中的三个可选枚举查询参数——DollarPeriodxgafv($.xgafv)、alt 和 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 中经历的同一次破坏性变更;本技能的早期草稿曾表示 googlemail 可以避免这次变更,因为 src/Apis/UsersApi.mo 之前被 .openapi-generator-ignore 冻结,并保留了变更为 ?T 之前的签名。现在没有任何内容被冻结——它包含的两个手动补丁都已改为生成,其中第二个(raw 的 base64url)是从此版本开始生成的。

6. 周期与响应大小

google-oauth 库使用来自 mo:ic/Call 的 Call.httpRequest,通过系统 API ic0.cost_http_request 自动计算并附加所需的准确周期数。令牌交换或刷新调用无需手动预算周期数。

对于 googlemail-client 调用,将 cycles 留空(null):每次调用都会附加 IC 对该请求收取的费用,费用根据实际发送的消息计算,因此大消息无需手动预算;在出站调用免费的子网上,费用为零。?n 会附加恰好 n 个周期:如果 n 低于费用,IC 会拒绝该调用;如果 canister 持有的周期少于 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 静默刷新,并重试一次。其他所有失败都会作为拒绝传播,因此需要处理这类失败的调用方(例如,发送失败不应导致更大的更新失败时)应在调用处捕获。刷新失败仍会在 accessTokenOf 中被拦截,因此调用方无法捕获;应为此提示用户“重新连接你的账号”。
  • **回调 URI 必须完全匹配。**授权 URL 和重定向 URI 中的每个字符(末尾斜杠、查询字符串、端口)都必须一致,否则 Google 会返回 redirect_uri_mismatch。将固定值 window.location.origin + "/connect/gmail" 用作 redirectUri——设置页面显示的值与 /connect/gmail 路由使用的值相同——并在 Google Web 客户端中注册该确切 URI。不要用 window.location.pathname 构建 URI,因为它会随页面而变化。
  • 将页面显示的值原样传给 startGmailOAuth,切勿传入原始的 *.icp0.io canister URL。Caffeine 应用会通过多个来源提供服务(*-draft.caffeine.xyz 草稿域名、*.caffeine.xyz 正式域名,以及原始的 <canister-id>.icp0.io URL)。在一个共享辅助函数中计算重定向 URI(window.location.origin + "/connect/gmail"),并在设置页面的可复制字段和传给 startGmailOAuth 的值中使用同一个辅助函数。如果传给 Google 的值(通过 startGmailOAuth)与设置页面显示且管理员注册的值不同,例如使用构建时配置值或 *.icp0.io canister 来源,Google 就会返回 redirect_uri_mismatch。
  • **RFC 5322 raw Blob。**将邮件作为普通 Blob 传入 raw 字段(?Text.encodeUtf8(mime))。googlemail-client 会为 API 将其编码为 base64url——即 Gmail 要求的 RFC 4648 §5 编码——并在读取 format = ?#full/?#raw 的邮件时将 raw 从 base64url 解码。不要自行编码(这会导致双重编码,Gmail 会拒绝)。在 0.2.0 之前,读取方向存在问题:从 Gmail 返回的 raw 会被解码为 null。
  • **HTTP 429 速率限制。**将错误传给调用方;切勿在 canister 内静默重试——重试发送可能导致重复投递。
  • 不要暴露访问令牌。gmailConnections 只能由 sendEmail 内部的 Map.get(gmailConnections, ..., caller) 读取。不要提供 getMyGmailConnection、getMyAccessToken,也不要提供迭代器。泄露的 bearer token 会导致对应用户的账号被攻陷。
  • 所有 Gmail API v1 调用都使用 xgafv = ?#_1_、alt = ?#json(0.2.0 之前使用裸值 #_1_ 和 #json——见 §5 中的迁移说明)。可选字符串参数设为 "",prettyPrint 设为 false。
  • 只有枚举查询参数使用 ?T;其余参数都是普通的位置参数。DollarPeriodxgafv、alt 和 format 接受 ?#_1_ / ?#json / ?#full,或者用 null 将它们从查询字符串中省略。Text 和 Bool 参数(fields、prettyPrint、quotaUser 等)接受 "" / false;这些参数不能使用 null,否则无法通过类型检查。
  • **userId = "me"**表示已通过身份验证的用户。

前端

每次使用此技能构建时,都必须包含以下四项。(如果应用还使用 Google Calendar 连接器,请改为遵循下方的“Gmail + Calendar 组合应用”——它会将 /settings/gmail + /connect/gmail 替换为共用的 /settings/google + /connect/google。以下要求仍然适用;只有这两个路径会变。)这些是验收标准,而非建议——构建完成前请逐项核验。以下三项是构建时最容易遗漏的要求,缺少其中任何一项都会导致连接器无法使用,而不仅仅是不完整:

  • 凭据页面必须存在且可访问。 应用必须有 /settings/gmail 页面,其中包含 Client ID/Secret 输入框(第 2 项);已登录的管理员必须能够通过导航链接或连接页面上的未配置提示访问该页面。只有“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,不能包含其他内容。没有这个 URI,管理员就无法在 Google 中注册它,每次连接都会失败。
  • /connect/gmail 必须是真正处理 Google 回调的路由——不能只是一个按钮页面。如果请求落入兜底路由并重定向到首页,或者在经过身份验证的 actor 就绪之前调用 completeGmailOAuth,连接就会悄无声息地失败,应用会显示“未连接”。
  1. 登录流程——必需。 Gmail 需要非匿名调用者才能正常工作;每个用户的 OAuth 握手会以 caller : Principal 为键存储令牌,而管理员凭据设置器会检查 #admin 权限。登录流程来自 extension-authorization: useInternetIdentity、登录/登出按钮,以及将经过身份验证的身份注入每次后端调用的 useActor 接线。

  2. 管理员设置页面 — /settings/gmail(仅限管理员访问)。此页面必不可少;缺少此页面的 Gmail 构建并不完整:

    • 在凭据输入框前显示“如何获取 Google 凭据”面板。告知管理员这是一项一次性设置,约需 5 分钟,并说明以下编号步骤(Agent 的完成消息必须重复相同步骤):
      1. 打开 Google Cloud Console,并使用任意 Google 账号登录;
      2. 创建或选择一个项目;
      3. 启用 Gmail API(APIs & Services → Library → 搜索 “Gmail API” → Enable);
      4. 配置 OAuth consent screen(APIs & Services → OAuth consent screen → External;设置应用名称、支持邮箱和开发者邮箱;使用 Google 默认范围即可);
      5. 创建类型为 Web application 的 OAuth client ID(APIs & Services → Credentials → Create Credentials → OAuth client ID);
      6. 在 Authorized redirect URIs 下,添加此页面可复制字段中的准确值;
      7. 将生成的 Client ID 和 Client Secret 复制到下方输入框并保存。 添加一个便捷链接,用于打开 Google Cloud Console。
    • 使用一个共享辅助函数,在只读、可复制的字段中显示实际 URI:const gmailRedirectUri = () => window.location.origin + "/connect/gmail";。 例如,如果应用打开于 https://my-app.caffeine.xyz,显示的值应为 https://my-app.caffeine.xyz/connect/gmail。绝不要只显示 <app-domain>,也不要要求管理员自行推断 URI。
    • 两个密码输入框绑定到 setGmailCredentials(clientId, clientSecret)。按回车提交;成功后清空输入框。
    • 由 isGmailConfigured()(返回 Bool)驱动状态指示器。显示“已配置”/“未配置”——绝不显示凭据。
    • 确保可访问此页面。 应用主导航(共享 Layout)必须为管理员添加指向此页面的链接——当 isCallerAdmin 为 true 时显示链接,否则隐藏(通过 extension-authorization)。在导航定义处添加该链接,不要将其放在此页面内。无法访问的 /settings/gmail 路由属于构建缺陷。不要只依赖导航:下方的“尚未配置”提示才是用户发现需要设置的主要途径。
  3. “连接 Gmail”页面和回调页面——/connect/gmail(任何已登录用户)。此专用页面必须捕获并处理 Google 在用户同意后发出的重定向;它不能只是一个带有连接按钮的页面:

    • 为所有用户处理未配置的情况。 isGmailConfigured() 是一个公开查询(任何已登录用户都可以调用)。当它返回 false 时,不要显示无法使用的连接按钮。管理员可以看到指向 /settings/gmail 的链接,以便输入凭据。非管理员必须看到说明,而不是无路可走——例如:“Gmail 尚未设置——应用管理员需要在 Settings 中添加 Google 凭据。”只有完成配置后,才启用“连接 Gmail”按钮。
    • “连接 Gmail”按钮绑定到 startGmailOAuth(gmailRedirectUri())。将浏览器重定向到 canister 返回的 URL。不要根据任意当前路径名推导回调地址;固定的 /connect/gmail 路由和设置页面中的 URI 必须一致。
    • 将 /connect/gmail 注册为实际应用路由。它必须捕获 Google 回调,且在处理回调之前,不得落入全匹配重定向、布局默认页面或主页。
    • 返回时,从 URLSearchParams 中读取 error、code 和 state。如果存在 error,显示连接失败/已拒绝状态,且不要调用 canister。只有当 code 和 state 都存在时,才调用并等待 completeGmailOAuth(code, state) 完成,然后再导航到其他位置或清除 URL。等待期间保持显示“正在连接 Gmail…”状态。不要先替换路由、重定向到主页或丢弃查询参数——这会丢失一次性代码,导致用户仍未连接。
    • 在调用一次性回调之前等待 actor 就绪。 页面必须等待 useInternetIdentity().isAuthenticated 为真,且 useActor(createActor) 提供了非空、非加载中的 actor,然后才能调用 completeGmailOAuth。在此之前不要设置 startedRef/单次调用保护:首次渲染时 actor 通常尚不可用,否则“Actor not ready”错误会耗尽唯一一次重试机会,而授权代码仍留在 URL 中。
    • 任一终止路径完成后,调用 history.replaceState 移除 OAuth 查询参数,避免刷新页面时重复使用一次性授权代码。
    • 状态由 isMyGmailConnected() 驱动(返回 Bool)。连接成功后,调用 getMyGmailEmailAddress(),显示“已连接:[email protected]”。此函数只返回存储的电子邮件地址,不会返回任何 bearer token。
    • 可选的“断开 Gmail”按钮绑定到 disconnectMyGmail()。
  4. 空状态提示。 当 isMyGmailConnected() 返回 false 时,在发送邮件界面中显示行内链接“连接 Gmail 以发送”,指向 /connect/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 连接,而不是两个(授权码只能使用一次,因此两个流程会触发两次同意屏幕)。 前端:

  • 一个管理页面 /settings/google — 一个 Client ID / Client Secret 表单、一个 isGoogleConfigured 状态,以及一个可复制的重定向 URI 字段,准确显示 window.location.origin + "/connect/google"。
  • 一个连接路由 /connect/google — 与上文「前端」部分要求的真实回调路由相同:它会呈现“连接 Google”,捕获重定向,等待 Actor 就绪,然后调用一次完成操作。不要设置第二个回调路由。
  • 不要构建 /settings/gmail、/connect/gmail、/settings/calendar 或 /connect/calendar。上文所有其他前端要求仍然适用 — 只有这些路径有所变化。

后端 — 只编写一套共享流程(用来替代两个按连接器分别设置的 OAuth 流程)。其结构与按连接器设置的 startAuthorize / exchangeCode / 刷新函数相同,但有以下确切差异:

  • 一个受 #admin 限制的配置设置器,用于存储单个 Client ID/Secret。
  • SCOPES = 下方范围的并集 — 在一次同意中授权两个 API。
  • completeGoogleOAuth(code, state) 通过 OAuth.getUserEmail 获取已连接的电子邮件地址(OIDC userinfo — 只需 openid email,无需 gmail.readonly),并在单个 Map<Principal, GoogleConnection> 中存储一个连接 { accessToken; refreshToken; emailAddress }。
  • Gmail 发送操作和 Calendar 调用各自根据该 accessToken 构建自己的客户端 Config,并各自保留一次 401 后刷新重试的逻辑。
  • 将连接和客户端配置保存在一个共享状态值中,并将其作为参数传递给 Gmail 和 Calendar 两个 mixin,以便两者读写同一个连接(参见 writing-motoko mixin 规则)。
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.

将其接线为两个服务共享的一个连接 — 只声明一次配置、连接映射和待处理流程映射,并将相同的绑定传递给每个 mixin。Gmail 和 Calendar 消息 mixin 不要声明自己的配置或连接;它们接收共享的 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 流程 — 授权码只能使用一次,单独的状态会导致不同步(参见 writing-motoko mixin 规则)。

在同一个 OAuth 客户端上启用两个 API,并且只注册一个 .../connect/google 重定向 URI。仅当用户明确要求连接两个不同的 Google 账户时,才拆分为两个独立面板。

所有变体的通用要求

  • 每个 Gmail 相关路由都要求登录。 通过 extension-authorization 的身份验证守卫(useInternetIdentity + 在 !isAuthenticated 时重定向),接入 /settings/... 和连接路由(组合应用中为 /connect/gmail 或 /connect/google)。
  • 前端绝不持久化令牌。 不使用 localStorage、IndexedDB 或 Cookie — 一切均由 canister 中介。浏览器只会看到 Bool 状态标志和 OAuth 重定向 URL。
  • OAuth state 参数由 canister 生成并验证。 canister 会将随机 nonce 与待处理的 verifier 和回调 URI 一同存储。前端必须将 code 和 state 都传递给完成调用(组合应用中为 completeGmailOAuth 或 completeGoogleOAuth);前端绝不创建或修改其中任何一个值。
  • 发送邮件的 UI 本身很简单: to、subject、body 输入框和一个提交按钮。不使用客户端 Gmail SDK、不处理令牌、不进行 JSON 序列化 — canister 就是 Gmail 客户端。

相关内容

评论

支持 Markdown