connector-googlemail
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 `
By caffeinelabs · 16,393 installs
npx skills add caffeinelabs/skills --skill connector-googlemail
Source repository · Upstream listing
Gmail Connector
Gmail integration for [Caffeine AI](https://caffeine.ai?utm source=caffeine skill&utm medium=referral).
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](../extension authorization/SKILL.md).
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:
1. 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.
2. 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 .
3. 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 .
4. 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
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:
1. Generates a PKCE code verifier and code challenge (via google oauth ).
2. Builds the Google authorize URL (via google oauth.buildAuthorizeUrl ).
3. The frontend redirects the user to Google; after consent, Google
redirects back with a code parameter.
4. The canister exchanges the code for tokens (via
google oauth.exchangeAuthorizationCode ) — on chain , non replicated.
5. The canister stores access token + refresh token keyed by caller .
6. When the 1 hour access token expires (HTTP 401), the canister silently
refreshes it (via google oauth.refreshAccessToken ) and retries.
Google Cloud Console setup
1. Create a Google OAuth 2.0 Web application client.
2. 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.
3. Enable only the Gmail scopes the app needs on the consent screen.
4. 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
1. 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.
2. Billing. Replicated outcalls produce N parallel API calls. The IC
charges ~13× the cycles, and Google counts each toward quota.
3. 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.
The migration chain head:
5. Available API surface
google oauth (OAuth 2.0 mechanics)
Function Purpose
OAuth.urlEncode(text) RFC 3986 percent encoding for form bodies
OAuth.parseTokenResponse(text) Parse Google token endpoint JSON
OAuth.exchangeAuthorizationCode(...) Exchange auth code for tokens
OAuth.refreshAccessToken(...) Refresh an expired access token
OAuth.generateCodeVerifier() Generate PKCE code verifier (on chain randomness)
OAuth.computeCodeChallenge(verifier) Compute PKCE code challenge (S256)
OAuth.buildAuthorizeUrl(...) Build the Google OAuth authorize URL
OAuth.getUserEmail(accessToken) Fetch the connected email via OIDC userinfo (needs only openid email )
googlemail client (Gmail REST API)
The canonical actor above intentionally implements only profile lookup and
message sending. For another generated operation, keep bearer authentication
and is replicated = ?false , then apply the same single refresh retry pattern
as sendEmail .
Function Purpose
gmail users messages send Send an RFC 5322 message
gmail users messages get Get a message by id
gmail users messages list List messages in mailbox
gmail users drafts create Create a draft
gmail users drafts send Send a draft by id
gmail users drafts get Get a draft by id
gmail users drafts list List drafts
gmail users getProfile Get the user's profile (email, totals)
6. Cycles and response sizes
The google oauth library uses Call.httpRequest from mo:ic/Call , which
auto computes and attaches the exact required cycles via the
ic0.cost http request system API. No manual cycle budgeting is needed
for token exchange or refresh calls.
For googlemail client calls, defaultConfig.cycles = 30 000 000 000
(30B). A typical send costs ~10–15B cycles. Bump to 60B for large
messages. Set max response bytes = ?2 000 000 for message reads that
may include large payloads.
7. Things that will bite you
is replicated = ?false — see §3. Non negotiable.
Google refresh tokens do NOT rotate. Unlike X/Twitter, Google does
not issue a new refresh token on each refresh. Keep the original
refresh token and only persist the new access token . The sendEmail
function in §4 handles this.
Access tokens expire in 1 hour. The sendEmail function catches
HTTP 401, silently refreshes via google oauth.refreshAccessToken , and
retries once. If the refresh also fails, surface "re connect your account".
Callback URI exact match. Every character (trailing slash, query
string, port) must match between the authorize URL and the redirect.
Google returns redirect uri mismatch otherwise. Use the fixed
window.location.origin + "/connect/gmail" for redirectUri — the same
value the settings page displays and the /connect/gmail route owns — and
register that exact URI on the Google Web client. Do not build it from
window.location.pathname , which varies by page.
Pass the displayed value to startGmailOAuth unchanged — never the raw
.icp0.io canister URL. A Caffeine app is served at several origins (the
draft.caffeine.xyz draft, the .caffeine.xyz live domain, and the raw
<canister id .icp0.io URL). Compute the redirect URI in one shared
helper ( window.location.origin + "/connect/gmail" ) and use that same helper
both for the copyable field on the settings page and for the value handed to
startGmailOAuth . If the value sent to Google (via startGmailOAuth ) differs
from what the settings page showed and the admin registered — e.g. a
build time/config value or the .icp0.io canister origin — Google returns
redirect uri mismatch .
RFC 5322 raw Blob. Pass the message as a plain Blob in the
raw field ( ?Text.encodeUtf8(mime) ). The googlemail client
base64 encodes it for the API — do not base64 encode it yourself
(that double encodes and Gmail rejects it).
HTTP 429 rate limit. Surface the error to the caller; never
silently retry inside the canister — a send retry may deliver duplicates.
Don't expose the access token. gmailConnections is read only by
Map.get(gmailConnections, ..., caller) inside sendEmail . No
getMyGmailConnection , no getMyAccessToken ,
no iterator. A leaked bearer is a per user account compromise.
xgafv = 1 , alt = json for al