extension-email-marketing
Send personalised marketing emails to subscribers with an unsubscribe link.
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 `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.
Full skill instructions
Gmail integration for Caffeine AI.
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.
Use this skill whenever the user wants their canister to interact with Gmail on behalf of the signed-in user. The ingredients are:
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.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.access_token + refresh_token keyed by caller : Principal.mops add [email protected]
mops add [email protected]
mops add [email protected]
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:
code_verifier and code_challenge (via google-oauth).google-oauth.buildAuthorizeUrl).code parameter.google-oauth.exchangeAuthorizationCode) — on-chain, non-replicated.access_token + refresh_token keyed by caller.google-oauth.refreshAccessToken) and retries.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.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.
| 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.
The bearer never leaves the canister. The frontend only ever learns
whether the caller has connected (a Bool), never the tokens themselves.
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.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.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.
is_replicated = ?false is REQUIREDAuthorization: Bearer <token>
header — a leaked bearer from any node compromises the user's Google
account.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.
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 + includes 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(
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`.
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")))) {
Runtime.trap("Gmail send failed: " # msg);
};
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 ?? "";
};
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:
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 ?:
// 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.
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.
is_replicated = ?false — see §3. Non-negotiable.refresh_token on each refresh. Keep the original
refresh_token and only persist the new access_token. The sendEmail
function in §4 handles this.sendEmail function catches
HTTP 401, silently refreshes via google-oauth.refreshAccessToken, and
retries once. If the refresh also fails, surface "re-connect your account".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.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.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.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.?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.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:
/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.<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".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:
useInternetIdentity, login/logout buttons, the useActor plumbing
that injects the authenticated identity into every backend call.
An admin settings page — /settings/gmail (admin-gated). This page
is required; a Gmail build is incomplete without it:
const gmailRedirectUri = () => window.location.origin + "/connect/gmail";.
For example, if the app is open at https://my-app.caffeine.xyz, the
displayed value is https://my-app.caffeine.xyz/connect/gmail. Never
show only <app-domain> or ask the administrator to infer the URI.setGmailCredentials(clientId, clientSecret).
Submit on enter; clear inputs on success.isGmailConfigured() (returns Bool).
Show "Configured" / "Not configured" — never display the credentials.isCallerAdmin is
true, hide it otherwise (via
extension-authorization). Add that
link wherever the nav is defined, not inside this page. A /settings/gmail
route with no way to reach it is a broken build. Do not rely on the nav
alone: the not-configured prompt below is the primary way users discover
setup is needed.A "Connect Gmail" and callback page — /connect/gmail (any signed-in
user). This dedicated page must catch and handle Google's redirect after
consent; it is not only a page with a connect button:
isGmailConfigured() is a
public query (any signed-in user may call it). When it returns false, do
not show a dead connect button. Admins see a link to /settings/gmail to
enter credentials. Non-admins must see an explanation, not a dead end — e.g.
"Gmail isn't set up yet — the app's administrator needs to add Google
credentials in Settings." Enable the "Connect Gmail" button only once
configured.startGmailOAuth(gmailRedirectUri()). Redirect the browser to the URL
returned by the canister. Do not derive the callback from an arbitrary
current pathname; the fixed /connect/gmail route and the settings-page
URI must be identical./connect/gmail as a real application route. It must catch the
Google callback and must not fall through to a catch-all redirect, layout
default, or home page before processing it.error, code, and state from
URLSearchParams. If error is present, show the failed/declined
connection state and do not call the canister. Only when both code
and state are present, call and await
completeGmailOAuth(code, state) before navigating anywhere or clearing
the URL. Keep a visible "Connecting Gmail…" state while it is pending.
Do not replace the route, redirect to the home page, or discard the
query parameters first — that loses the one-time code and leaves the
user disconnected.useInternetIdentity().isAuthenticated and
useActor(createActor) to provide a non-null, non-fetching actor before
calling completeGmailOAuth. Do not set a startedRef/one-shot guard
until then: on first render the actor is often unavailable, and an
"Actor not ready" failure otherwise consumes the only retry while the
authorization code is still in the URL.history.replaceState to remove the
OAuth query parameters. This prevents a page refresh from reusing a
one-time authorization code.isMyGmailConnected() (returns Bool). When
connected, call getMyGmailEmailAddress() to show "Connected as
[email protected]". This returns only the stored email address, never
either bearer token.disconnectMyGmail().Empty-state nudges. When isMyGmailConnected() is false, render an
inline "Connect Gmail to send" link to /connect/gmail on the send-email UI.
When isGmailConfigured() is false and the caller is an admin, render a
"Set up Gmail" link to /settings/gmail so the credentials page is
discoverable, not just reachable.
Suggested route layout:
/ → 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".
When an app uses both connectors, build one shared Google connection, not two (an auth code is single-use, so two flows would force two consent screens). Frontend:
/settings/google — a single Client ID / Client Secret
form, one isGoogleConfigured status, and one copyable redirect-URI field
showing exactly window.location.origin + "/connect/google"./connect/google — the same real callback route the
Frontend section above requires: it renders "Connect Google", catches the
redirect, waits for actor readiness, then calls completion once. No second
callback route./settings/gmail, /connect/gmail, /settings/calendar, or
/connect/calendar. Every other Frontend requirement above still applies —
only these paths change.Backend — write the shared flow once (it replaces both per-connector OAuth
flows). It is the same shape as the per-connector startAuthorize /
exchangeCode / refresh functions, with these exact differences:
#admin-gated config setter storing a single Client ID/Secret.SCOPES = the union below — both APIs in one consent.completeGoogleOAuth(code, state) learns the connected email via
OAuth.getUserEmail (OIDC userinfo — needs only openid email, not
gmail.readonly) and stores one connection
{ accessToken; refreshToken; emailAddress } in a single
Map<Principal, GoogleConnection>.Config from
that one accessToken, each keeping its single-refresh-on-401 retry.writing-motoko mixins rule).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.
Wire it as one connection shared by both services — declare the config,
connection map, and pending-flow map once and pass the same bindings to
every mixin. The Gmail and Calendar messaging mixins do not declare their own
config or connection; they receive the shared googleConfig and
googleConnections (config is needed for the refresh-on-401 retry):
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
};
This variant's migration chain head replaces the per-connector one:
<!-- motoko-check:skip -->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>();
};
};
};
Do not give Gmail and Calendar separate config/connection state or separate
OAuth flows — one auth code is single-use, and separate state desyncs (see the
writing-motoko mixins rule).
Enable both APIs on the one OAuth client and register only the single
.../connect/google redirect URI. Split into two separate panels only if
the user explicitly asks to connect two different Google accounts.
/settings/... and the connect route (/connect/gmail, or /connect/google
in a combined app) through
extension-authorization's
auth guard (useInternetIdentity + redirect when !isAuthenticated).localStorage, no
IndexedDB, no cookies — the canister mediates everything. The browser
only ever sees Bool status flags and the OAuth redirect URLs.state parameter is canister-generated and validated. The
canister stores a random nonce with the pending verifier and callback URI.
The frontend must pass both code and state to the completion call
(completeGmailOAuth, or completeGoogleOAuth in a combined app);
it never creates or modifies either value.to, subject,
body, a submit button. No client-side Gmail SDK, no token handling,
no JSON serialization — the canister is the Gmail client.mops add [email protected] — Gmail REST API bindings.mops add [email protected] — Google OAuth 2.0 library (token exchange, refresh, PKCE, getUserEmail userinfo, DateTime RFC 3339 helpers).googlemail-client wraps.useInternetIdentity / useActor frontend plumbing, and the #admin role gate.Send personalised marketing emails to subscribers with an unsubscribe link.
Support for sending an email with a link the recipient can click to prove they own the email address.
General file/object storage, such as for images, videos, files, documents and other bulk data. Perfect fit for image galleries, video galleries, and other file or object management. Supports large files beyond IC limit, with browser-cached HTTP URL access.
Authorization system with role-based access control. Must-have for all apps that manage personal or access-restricted data.
Support for sending service/transactional emails. Don't use this for sending marketing emails or verification emails.
HTTP outcalls performed by the backend canister (not in the frontend).
Core infrastructure providing backend connection configuration, storage client, and React app entry point.
Requests invite-link / RSVP based access where guests can submit responses without login while admin can view responses with login.
Approval-based user management.
MANDATORY recipe for every Caffeine build that calls OpenAI (ChatGPT, GPT-4o, an LLM, a chatbot, embeddings). The ONLY supported path is the `openai-client` mops package with a canister-side API-key bearer. Hand-rolling `ic.http_request` to `api.openai.com/v1/...` is a FORBIDDEN anti-pattern — it leaks the bearer across replicated outcalls (security + 13× billing impact), bypasses the typed request/response bindings, and forces hand-rolled JSON on a language with poor JSON support. Load this skill whenever the user, spec, or any prior task mentions ChatGPT, GPT (any version), OpenAI, an LLM, a chatbot, or embeddings — and BEFORE writing any code that touches `api.openai.com`.
Send an email with multiple to, cc and bcc addresses.
MANDATORY recipe for every Caffeine build that posts to X (Twitter). The ONLY supported path is the `x-client` mops package with OAuth 2.0 PKCE. Hand-rolling `ic.http_request` or `icBooking.http_request` calls to `api.x.com/2/tweets`, `api.x.com/2/oauth2/token`, or any other X endpoint is a FORBIDDEN anti-pattern — it bypasses bearer auth, replication-cost safeguards, and `x-client`'s null-field handling. Load this skill whenever the user, spec, or any prior task mentions tweeting, live-tweeting, posting-to-X, posting-a-status, sharing-to-Twitter, or any equivalent phrasing — and BEFORE writing any code that touches `api.x.com`.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience