This wires your real Gmail inbox into the dashboard. Tokens stay on your machine, AES-256-GCM encrypted in src/data/gmail.db.
gmail-workspace-dev (anything works).gmail-workspace-devgmail-workspace-localhttp://localhost:3030/api/auth/google/callback.env.localAppend to .env.local in the repository root (create it if needed):
GOOGLE_CLIENT_ID=<paste client id>.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=<paste client secret>
GOOGLE_REDIRECT_URI=http://localhost:3030/api/auth/google/callback
(The redirect URI defaults to that value if omitted, so it’s optional.)
bun run dev
The TopBar pill should now read “Not connected” (was previously the same but inert).
/settings?gmail=connected&email=.... The page now shows your account with a Sync now button.Click Sync now (or in the chat: “ingest my inbox”).
What happens:
| Step | What | Where |
|---|---|---|
| 1 | users.messages.list (paginated, INBOX label) |
Gmail API → 5 pages × 100 = up to 500 messages |
| 2 | users.messages.get × 25 in parallel × N batches |
metadata only (From / To / Subject / Date / snippet — no body) |
| 3 | parse “Name |
local |
| 4 | upsert into emails table (PK = gm_<gmail_msg_id>) |
local SQLite |
| 5 | run heuristic classifier on every fresh row | local |
| 6 | store historyId watermark on the token row |
for future incremental syncs |
Re-running is idempotent (same PK = update). Rows from bun run seed (PK = seed_*) coexist with real Gmail rows (PK = gm_*).
From the moment you’re connected, these tools mirror to Gmail before mutating local state (per HONEST_STATUS):
archive_email → users.messages.modify({ removeLabelIds: ["INBOX"] }) then re-fetch to confirmbulk_archive → same, per idsnooze_email → removeLabelIds: ["UNREAD"]If Gmail returns an error (auth, network, rate limit), the tool returns { ok: false, error } and does not touch local state. Fully reversible.
format: "metadata" only returns headers + Gmail’s pre-computed snippet (~100 chars).summarize_thread tool (forthcoming v0.3) is the only path that opts in to fetching body, and it caches per-email so the body is sent at most once.src/data/.token-key (mode 0600). Override via GMAIL_TOKEN_KEY=<64 hex chars> env if you’d rather manage the key yourself.Settings → Gmail → Disconnect wipes the token row from oauth_tokens. The gm_* email rows remain in your local store (so you don’t lose the lane organization). Re-running bun run seed does NOT delete them — only the seeds table is rewritten.
| Symptom | Cause | Fix |
|---|---|---|
state_mismatch after callback |
cookie blocked or session expired | Try again; ensure same browser tab |
Error 400: redirect_uri_mismatch |
URI not registered in console | Match exactly: http://localhost:3030/api/auth/google/callback (no trailing slash) |
access_denied |
App still in Testing + you’re not a test user | Add your email to OAuth consent screen → Test users |
invalid_grant after a few hours |
refresh token expired (rare in Testing mode) | Click Disconnect + Connect Gmail again |
| Sync returns 0 messages | empty INBOX label or all archived | Try query: "in:anywhere newer_than:30d" via chat |
Current setup is fine for personal local-first use. Before exposing this on a server you’d want:
.token-key to OS keychain (keytar or libsecret).gmail.users.watch + Cloud Pub/Sub topic).users.messages.modify call before it executes.