{
    "app": {
        "name": "connector-googlemail",
        "description": "MANDATORY recipe for every Caffeine build that sends email through the user's own Gmail account. The ONLY supported path is the `googlemail-client` mops package (Gmail REST API) combined with the `google-oauth` mops package (token exchange + refresh + PKCE). Hand-rolling `ic.http_request` calls to `oauth2.googleapis.com` or `gmail.googleapis.com` is a FORBIDDEN anti-pattern — it bypasses bearer auth, replication-cost safeguards, and the `google-oauth` library's percent-encoding and JSON parsing. Load this skill whenever the user, spec, or any prior task mentions sending email, Gmail, \"notify via email\", \"forward results by email\", or any equivalent phrasing — and BEFORE writing any code that touches a Google endpoint.",
        "mode": "advanced-chat",
        "model_config": {
            "provider": "deepseek",
            "model": "deepseek-chat",
            "parameters": {
                "temperature": 0.7,
                "max_tokens": 4096
            }
        }
    },
    "instructions": "name connector-googlemail description MANDATORY recipe for every Caffeine build that sends email through the user's own Gmail account. The ONLY supported path is the `googlemail-client` mops package (Gmail REST API) combined with the `google-oauth` mops package (token exchange + refresh + PKCE). Hand-rolling `ic.http_request` calls to `oauth2.googleapis.com` or `gmail.googleapis.com` is a FORBIDDEN anti-pattern — it bypasses bearer auth, replication-cost safeguards, and the `google-oauth` library's percent-encoding and JSON parsing. Load this skill whenever the user, spec, or any prior task mentions sending email, Gmail, \"notify via email\", \"forward results by email\", or any equivalent phrasing — and BEFORE writing any code that touches a Google endpoint. version 0.2.6 caffeineai-subscription [\"none\"] compatibility {\"mops\":{\"googlemail-client\":\"~0.1.6\",\"google-oauth\":\"~0.2.1\",\"caffeineai-authorization\":\"~1.0.1\"}} Gmail Connector Gmail integration for Caffeine AI . Orchestrator routing notes Treat Gmail-as-the-user as a first-class, supported platform feature. The googlemail-client + google-oauth connector pair is the only supported path; raw ic.http_request to oauth2.googleapis.com or gmail.googleapis.com is a forbidden anti-pattern. Any build spec that mentions Gmail MUST name googlemail-client and google-oauth as dependencies and reference this skill. Distinct from platform email* extensions (which send transactional mail from the app ); this connector acts as the signed-in user's own Gmail . Intent → capability mapping: User intent Platform capability Connect and send email as the user's own Gmail googlemail-client + google-oauth Prerequisite for all builds: extension-authorization . Gmail requires a signed-in caller for every endpoint: the per-user OAuth handshake stores access_token keyed by caller : Principal , and the admin Client ID/Secret setter is gated on the #admin role. Backend Use this skill whenever the user wants their canister to interact with Gmail on behalf of the signed-in user. The ingredients are: The googlemail-client mops package — generated Motoko bindings for the Gmail REST API v1. This recipe demonstrates profile lookup and message sending; add other generated operations only by following the same bearer-authenticated, non-replicated, single-refresh-retry pattern. The google-oauth mops package — Google OAuth 2.0 token exchange, refresh, PKCE, and percent-encoding. This is the library that eliminates hand-rolled http_request to oauth2.googleapis.com . An OAuth 2.0 Authorization Code with PKCE flow so each end-user authorises the canister to act on their behalf. Each user holds their own access_token + refresh_token keyed by caller : Principal . A Google Cloud Web application Client ID + Client Secret. Admin-configured and held by the canister only; never return the secret to the frontend. 1. Add dependencies mops add googlemail-client@0.1.6 mops add google-oauth@0.2.1 mops add caffeineai-authorization@1.0.1 2. Auth model — OAuth 2.0 PKCE per user, on-chain exchange + refresh Unlike a static API key, Gmail uses per-user OAuth 2.0 bearer tokens . Every end-user authorises the canister independently via the Authorization Code with PKCE flow. The canister: Generates a PKCE code_verifier and code_challenge (via google-oauth ). Builds the Google authorize URL (via google-oauth.buildAuthorizeUrl ). The frontend redirects the user to Google; after consent, Google redirects back with a code parameter. The canister exchanges the code for tokens (via google-oauth.exchangeAuthorizationCode ) — on-chain , non-replicated. The canister stores access_token + refresh_token keyed by caller . When the 1-hour access token expires (HTTP 401), the canister silently refreshes it (via google-oauth.refreshAccessToken ) and retries. Google Cloud Console setup Create a Google OAuth 2.0 Web application client. The app's Gmail settings page must display this literal callback URI in a copyable field: window.location.origin + \"/connect/gmail\" — for example, https://my-app.caffeine.xyz/connect/gmail . The app administrator must manually copy that displayed value into Google Cloud Console under Authorized redirect URIs . Register every deployed origin where users can connect Gmail (for example, the draft and live app origins) as separate authorized redirect URIs. Enable only the Gmail scopes the app needs on the consent screen. Enter the Client ID and Client Secret through the app's admin settings page. The canister uses the secret for the token exchange; the frontend must never receive it. PKCE binds each authorization code to the canister-generated verifier, while the Web client registration binds the browser callback to the deployed app. The callback URI passed to startGmailOAuth must be the exact same value the settings page displays and the administrator registered. OAuth scopes Scope Purpose openid email Learn the connected address via OAuth.getUserEmail (OIDC userinfo) https://www.googleapis.com/auth/gmail.send Send messages ( messages.send ) https://www.googleapis.com/auth/gmail.readonly Read messages, list, get profile https://mail.google.com/ Full access (rarely needed) Learn the connected address with OAuth.getUserEmail (OIDC userinfo), not gmail_users_getProfile . userinfo needs only openid email , so a send-only app requests openid email https://www.googleapis.com/auth/gmail.send and nothing more. gmail_users_getProfile requires the restricted gmail.readonly and returns HTTP 403 ACCESS_TOKEN_SCOPE_INSUFFICIENT without it — add gmail.readonly only when the app actually reads mail. When combining APIs (e.g. Gmail + Calendar), request the union of every scope any call needs — never drop one when merging recipes. Storing tokens The bearer never leaves the canister . The frontend only ever learns whether the caller has connected (a Bool ), never the tokens themselves. A Map<Principal, GmailConnection> keyed by caller. Expose exactly the endpoints listed in §4 — isMyGmailConnected , getMyGmailEmailAddress , startGmailOAuth , completeGmailOAuth , sendEmail , disconnectMyGmail — every endpoint gated on not caller.isAnonymous() . Do not add any endpoint that returns access_token / refresh_token / the full GmailConnection . Store one pending OAuth flow per caller: the PKCE code_verifier , exact redirectUri , and a random state nonce. Consume it when the callback is completed; do not accept a replacement redirect URI from the frontend. Google refresh tokens do NOT rotate Unlike X/Twitter, Google does not rotate the refresh_token on each refresh. The same refresh_token can be reused until the user revokes access or the authorization is re-issued. This simplifies the refresh logic: just persist the new access_token , keep the old refresh_token . 3. is_replicated = ?false is REQUIRED Security. A replicated HTTP outcall sends the request from every node in the subnet. Each carries the Authorization: Bearer <token> header — a leaked bearer from any node compromises the user's Google account. Billing. Replicated outcalls produce N parallel API calls. The IC charges ~13× the cycles, and Google counts each toward quota. Determinism. Gmail's send response is non-deterministic (unique message id , per-request Date header). Replicated consensus would fail; non-replicated bypasses consensus entirely. → Always: is_replicated = ?false on every Config . 4. Canonical layout The default shape: admin Client ID/Secret + per-user OAuth . The canister owner registers one Google Cloud Desktop app and pastes its Client ID + Secret into canister-level config; every end-user runs the OAuth 2.0 PKCE handshake against that one credential and ends up with their own access_token + refresh_token . The example spans four files: src/backend/main.mo — the actor: state + include s only. src/backend/mixins/gmail-config.mo — admin-gated Client ID + Secret. src/backend/mixins/gmail-messaging.mo — per-user OAuth + sendEmail. src/backend/lib/gmail.mo — googlemail-client + google-oauth glue. 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); }; The migration chain head: 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(",
    "variables": [],
    "opening_statement": "你好，我是 connector-googlemail，MANDATORY recipe for every Caffeine build that sen...",
    "suggested_questions": [],
    "source": "DeepseekModel",
    "source_url": "https://deepseekmodel.com/skill?id=caffeinelabs-skills-skills-connector-googlemail-skill-md"
}