connector-googlemail — скилл для Claude Code: как установить
Инструкция по отправке писем из канистры от имени личного аккаунта Gmail пользователя через Motoko и Mops. Защищает от сырых HTTP-запросов к Google API.
npx skills add caffeinelabs/skills --skill connector-googlemail -a claude-codeЧто делает connector-googlemail
Скилл задаёт архитектуру интеграции с Gmail в проектах Caffeine AI на Motoko. Вместо прямых вызовов ic.http_request агент обязан использовать связку пакетов googlemail-client и google-oauth. Это исключает ручную сборку запросов к gmail.googleapis.com и сохраняет механизмы защиты от расходов на репликацию.
Авторизация построена на протоколе OAuth 2.0 с PKCE для каждого пользователя. Канистра сохраняет access_token и refresh_token с привязкой к caller : Principal и сама обновляет токены при ответе HTTP 401. Фронтенд никогда не получает токены на руки — они остаются внутри бэкенда.
Для запуска потребуются веб-клиент Google Cloud с Client ID и Secret, а также настроенный модуль caffeineai-authorization. Сырой вызов Google API сломает безопасность.
Какие задачи решает
- настроить отправку писем из канистры от лица пользователя
- организовать OAuth 2.0 с PKCE на бэкенде без утечки токенов
- подключить клиенты googlemail-client и google-oauth через Mops
- исключить небезопасные сырые вызовы ic.http_request к Google API
Примеры запросов агенту
- Подключи отправку писем через личный Gmail пользователя с помощью пакетов mops
- Настрой авторизацию OAuth 2.0 с PKCE для работы с Gmail API в канистре
- Добавь зависимости googlemail-client и google-oauth для интеграции с Gmail
Как установить connector-googlemail
1Через Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill connector-googlemail -a claude-code2Вручную: положить файлы
~/.claude/skills/connector-googlemail/SKILL.mdПапка скилла целиком; для одного проекта — .claude/skills/connector-googlemail/
1Через Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill connector-googlemail -a codex2Вручную: положить файлы
.agents/skills/connector-googlemail/SKILL.mdДля всех проектов — ~/.agents/skills/
1Через Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill connector-googlemail -a cursor2Вручную: положить файлы
.cursor/skills/connector-googlemail/SKILL.mdCursor читает и .agents/skills/, и .claude/skills/
1GitHub CLI
gh skill install caffeinelabs/skills connector-googlemail2Вручную: положить файлы
.github/skills/connector-googlemail/SKILL.md1Через Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill connector-googlemail -a gemini-cli2Командой платформы
gemini skills install https://github.com/caffeinelabs/skills --path skills/connector-googlemailВручную: положить файлы
.devin/skills/connector-googlemail/SKILL.mdСтарый путь .windsurf/skills/ тоже читается
Через Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill connector-googlemail -a opencodeClaude.ai
Упакуйте папку в zip и загрузите в Settings → Capabilities → Skills
По шагам, с командами для каждой платформы: Как установить скилл в Claude Code: пять способов
Из того же набора25
Похожие
Рядом в теме «Бэкенд и API»
Всё по теме «Бэкенд и API» →Вопросы о connector-googlemail
Как установить connector-googlemail в Claude Code?
Выполните команду npx skills add caffeinelabs/skills --skill connector-googlemail -a claude-code.
Зачем нужен скилл connector-googlemail?
Он задаёт правила для отправки почты через личный Gmail пользователя в проектах Caffeine AI. Скилл запрещает писать сырые запросы через ic.http_request и принудительно подключает библиотеки googlemail-client и google-oauth.
Какие зависимости нужны для работы с Gmail по этому рецепту?
Потребуются пакеты Mops: [email protected], [email protected] и [email protected]. Также необходимо настроить веб-приложение в Google Cloud Console для получения Client ID и Client Secret.
Исходный текст
SKILL.md · Apache-2.0
---
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.11
caffeineai-subscription: [none]
compatibility:
mops:
googlemail-client: "~0.3.0"
google-oauth: "~0.2.1"
caffeineai-authorization: "~1.0.1"
---
# 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
```bash
mops add [email protected]
mops add [email protected]
mops add [email protected]
```
## 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`.
**Since 0.2.0 the package ships that default.** `defaultConfig.is_replicated`
is `?false`, so `{ defaultConfig with auth = … }` is already safe; the explicit
assignment stays worthwhile because it keeps the requirement visible.
**If you are upgrading from 0.1.x, check your code for this.** Before 0.2.0
`defaultConfig` carried `is_replicated = null`, which *means replicated*, and
the generator forces non-replication only for PUT and DELETE —
`messages.send` is a POST. Any app that took `defaultConfig` as it came sent
each mail once per replica. Code that followed this skill was fine; code that
did not was sending ~13 copies of every email.
## 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.
```motoko filepath=src/backend/main.mo
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:
```motoko filepath=src/backend/migrations/00000000_000000.mo
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>();
};
};
};
```
```motoko filepath=src/backend/mixins/gmail-config.mo
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;
};
};
```
```motoko filepath=src/backend/mixins/gmail-messaging.mo
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);
};
};
```
```motoko filepath=src/backend/lib/gmail.mo
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. 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`.
Two equivalent surfaces. The flat free functions are what the canonical actor
above uses and remain fully supported:
| 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) |
0.2.0 also ships the nested-class `Client.mo` facade that googledrive and
googlecalendar have, mirroring Gmail's hierarchical operationIds. It captures
`Config` once and only re-namespaces — the `Apis` modules stay the source of
truth, so mixing the two surfaces in one actor is fine:
```mo:googlemail-client
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` carries `create` / `get` / `list` / `send`; `gmail.messages`
carries `get` / `list` / `send`; `getProfile` sits directly on `Client` because
it has no resource segment of its own.
**Migrating from 0.1.6.** Three optional enum query parameters —
`DollarPeriodxgafv` (`$.xgafv`), `alt`, and `format` on the two `get`
operations — are now `?T` on both surfaces, so every call site needs a `?`:
```mo:googlemail-client
// 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);
```
Pass `null` instead to omit the parameter from the query string entirely, which
is what the `?` buys you. Non-enum parameters are unchanged and still take no
`null`. This is the same break googlecalendar took in its 0.2.0; earlier
drafts of this skill said googlemail would escape it, because
`src/Apis/UsersApi.mo` used to be frozen by `.openapi-generator-ignore` and
kept its pre-`?T` signatures. Nothing is frozen any more — both hand patches
it carried are generated now, the second of them (base64url for `raw`) as of
this release.
## 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, leave `cycles` unset (`null`): each call
attaches the fee the IC charges for that request, sized from the message
actually sent, so large messages need no manual budget, and the fee is zero
on subnets where outcalls are free. `?n` attaches exactly `n` cycles: the IC rejects the call when `n` is below its fee, and the call traps on a subnet where the canister holds fewer than `n`. 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. Every other failure propagates as a reject, so a caller that
must survive it (for example, a send that should not fail a larger update)
catches it there. A failed refresh still traps in `accessTokenOf`, so no
caller can catch it; surface "re-connect your account" for it.
- **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`
base64url-encodes it for the API — RFC 4648 §5, which is what Gmail
requires — and decodes `raw` back from base64url when you read a message
with `format = ?#full`/`?#raw`. Do **not** encode it yourself (that
double-encodes and Gmail rejects it). Before 0.2.0 the read direction was
broken: a `raw` coming back from Gmail decoded to `null`.
- **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 all Gmail API v1 calls (bare `#_1_`
and `#json` before 0.2.0 — see the migration note in §5). Leave optional
string parameters `""` and `prettyPrint = false`.
- **Only the enum query parameters are `?T`; the rest are plain positional
values.** `DollarPeriodxgafv`, `alt` and `format` take `?#_1_` / `?#json` /
`?#full` — or `null` to omit them from the query string. `Text` and `Bool`
parameters (`fields`, `prettyPrint`, `quotaUser`, …) take `""` / `false`;
`null` will not type-check for those.
- **`userId = "me"`** refers to the authenticated user.
# Frontend
Every build using this skill MUST ship all four items below. (If the app
**also** uses the Google Calendar connector, follow "Combined Gmail + Calendar
apps" below instead — it replaces `/settings/gmail` + `/connect/gmail` with one
shared `/settings/google` + `/connect/google`. The requirements below still
apply; only the two paths change.) These are **acceptance criteria, not
suggestions** — verify each before the build is done. These three are the
requirements builds skip, and any one missing makes the connector **broken,
not merely incomplete**:
- **The credentials page exists and is reachable.** The app MUST have the
`/settings/gmail` page with Client ID/Secret inputs (item 2), and a signed-in
admin MUST be able to reach it — via a nav link or the not-configured prompt on
the connect page. A "Connect Gmail" button with no page to enter credentials is
the most common failure and leaves the connector unusable.
- **The admin settings page displays the literal, copyable redirect URI.** Not a
`<your-domain>` placeholder, not "your app URL + /connect/gmail" as text for
the admin to assemble — the actual string
`window.location.origin + "/connect/gmail"` rendered in a read-only field the
admin can copy. Concretely: an app served from `https://my-app.caffeine.xyz`
must show a field containing exactly `https://my-app.caffeine.xyz/connect/gmail`
and nothing else. Without it the admin cannot register the URI in Google and
every connection fails.
- **`/connect/gmail` is a real route that handles Google's callback** — not a
button-only page. If it falls through to a catch-all/home redirect, or calls
`completeGmailOAuth` before the authenticated actor is ready, the connection
silently fails and the app shows "not connected".
1. **A login flow — required.** Gmail cannot work without a non-anonymous
caller; the per-user OAuth handshake stores tokens keyed by
`caller : Principal`, and the admin credential setter gates on
`#admin`. The login flow comes from
[`extension-authorization`](../exten...
© caffeinelabs · Apache-2.0 · caffeinelabs/skills