Skip to content

Connecting Gmail

Gmail is read (and, from the email.send skill, written to) through the real Gmail REST API over OAuth — there’s no IMAP/SMTP fallback in the current build, unlike the “Gmail API when connected; IMAP/SMTP fallback” language in PLAN.md §4; see the note at the bottom of this page.

Configuring the OAuth client

Terminal window
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=http://localhost:4000/api/connections/gmail/callback # default

Create these in Google Cloud Console, add the redirect URI exactly (it must match verbatim), and gmailConfigured() (apps/api/src/twin/gmail.ts) flips to true once both the client id and secret are present. GET /api/twin/status reports providers[].gmail.configured from exactly this check, with the hint “Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env” when it’s false.

The OAuth dance

GET /api/connections/gmail/auth → redirects to Google's consent screen
GET /api/connections/gmail/callback → exchanges the code, saves the connection, redirects back

The auth route generates a random state nonce (10-minute TTL, held in memory) tied to your beingId, and requests exactly two scopes: gmail.readonly and gmail.send, plus openid/email to learn your address. access_type=offline + prompt=consent ensures a refresh_token comes back even on a re-consent. The callback verifies the state, exchanges the code for tokens, fetches your email address from Google’s userinfo endpoint, and saves everything — access token, refresh token, expiry, your email — as the encrypted gmail connection, then redirects to /settings?gmail=connected (or ...=error / ...=unconfigured) and kicks off a first sync immediately.

Tokens refresh themselves transparently: accessTokenFor() checks if the access token has more than 60 seconds of life left; if not, it uses the refresh token to get a new one and re-saves the connection. A refresh failure marks the connection status: 'error' with a plain message rather than silently retrying forever.

What a sync does

POST /api/twin/sync/gmail → { started: true, provider: 'gmail' }

syncGmail() lists your latest 50 threads (MAX_THREADS), skips any thread already turned into a memory (tracked by source LIKE 'email:%'), and fetches the full content of up to 15 new ones per sync (TWIN_MAX_EMAILS_PER_SYNC). Message bodies are parsed out of Gmail’s nested MIME parts (preferring text/plain, falling back to a hand-stripped text/html), truncated to 3,000 characters.

One cheap-tier structured call summarises the whole batch at once — not one call per email — into:

{ threadId, summary, kind: 'document' | 'episode', importance, needsReply: boolean, replyHint, tags }

kind is document for reference material (receipts, tickets) and episode for conversations; importance follows a 0.8 / 0.4 / 0.15 scale for personal / routine / newsletter mail; needsReply is deliberately conservative — true only when a real person is actually waiting on you — because every true becomes a Signal on your desktop. Each summary is stored as a memory with source: "email:<threadId>" (deduped by that source prefix, not by content, so a re-read of the same thread never doubles up). Without an OpenRouter key, heuristicSummary() takes over: automated-looking senders (no-reply, newsletter, notification, …) are demoted to low-importance document memories, and needsReply only fires heuristically when the message is unread, not automated, and contains a question mark.

Reply-needed signals

{
"kind": "decision",
"title": "Reply to Jordan Alvarez?",
"body": "\"Q3 numbers\" — They're asking whether Friday still works for the review.",
"options": [
{ "id": "reply", "label": "Reply", "hint": "Open Gmail" },
{ "id": "draft", "label": "Draft for me", "hint": "Muse writes a draft" },
{ "id": "ignore", "label": "Ignore" }
],
"priority": "normal",
"source": "email",
"action": { "provider": "gmail", "threadId": "...", "messageId": "...", "from": "...", "subject": "Q3 numbers" }
}

See Signals and the omnibar for how these render and get answered.

Sending mail

The email.send skill (see Skills catalogue) calls sendGmail(), which builds a raw RFC 2822 message (with In-Reply-To/References headers when replying inside a thread), base64url-encodes it, and posts it to messages.send. Every send also writes a sent-kind Twin activity.

A note on the PLAN.md gap

PLAN.md §4 and §6 describe Gmail with an “IMAP/SMTP fallback” for accounts without OAuth set up. The current code has no IMAP/SMTP path at all — Gmail is OAuth-only; without GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET, the connection simply stays unconfigured (configured: false with the plain-language hint above), rather than falling back to app-password IMAP/SMTP. This is tracked as a real gap in Roadmap & FAQ.