Raft Gmail is a self-hosted capability service that lets a Raft human connect Gmail accounts and grant specific Raft Agents access to specific accounts.
Version 0.1 intentionally exposes only six Agent actions:
- list Gmail accounts and scopes currently authorized for the calling Agent;
- request human approval for Gmail access;
- search mail;
- read a message;
- create a Gmail draft;
- update a Gmail draft.
It does not expose send, schedule, delete, archive, or mark-read actions. An Agent with the gmail.draft grant can create a draft directly, but cannot send it through this service.
Raft Gmail uses two independent identity layers:
- Login with Raft identifies the human account owner or calling Agent and binds that principal to the token's Raft Server.
- Google OAuth is completed by the human owner for each Gmail account they connect.
The service stores Google refresh tokens encrypted at rest. Agents receive an opaque service-local session and never receive a Google token or raw Raft token. Grants are scoped by Gmail account, Raft Server, exact Agent ID, and capability (gmail.read or gmail.draft).
Ordinary action results are returned to the calling Agent. The service does not store or guess a destination Raft channel.
Google does not offer a general OAuth scope that permits draft creation but cryptographically forbids sending. gmail.compose permits both. Raft Gmail therefore enforces “draft, never send” in its public action manifest, HTTP routes, source, and regression checks. Treat the self-host operator as part of the trusted computing base.
Requirements:
- Node.js 22 or newer;
- PostgreSQL 15 or newer;
- a Raft OAuth app with the shared human/Agent callback URL;
- a Google OAuth client with Gmail API enabled.
cp .env.example .env
docker compose up -d postgres
npm install
npm run migrate
npm run devnpm run dev starts the owner dashboard at http://localhost:5173 and proxies its API and OAuth routes to the service on port 4184. Register this Raft callback URL for that local origin:
http://localhost:5173/auth/raft/callback
Human and Agent Login with Raft intentionally share this one callback. The service uses the exchanged principal type to establish either a human browser session or a service-local Agent session.
Register this Google callback URL:
http://localhost:5173/auth/google/callback
Open http://localhost:5173 and use the owner dashboard to:
- sign in with Raft as a human;
- connect one or more Gmail accounts;
- choose Add Agent, copy the generated prompt into a conversation with the Agent, and let the Agent submit a request with its authenticated Raft identity;
- open Access requests, choose one or more Gmail accounts, and approve read, draft, or both permissions (or deny the request);
- pause, edit, or revoke any account-specific grant at any time.
The dashboard deliberately has no send action. The public Agent manifest is available at /.well-known/raft-app-manifest.json and /.well-known/raft-agent-manifest.json through either the Vite proxy or the service. It uses the raft-agent-manifest.v0 HTTP-action contract consumed by raft integration invoke.
For a production-style local run, use npm run build && npm start; the service then serves the compiled dashboard and API together at http://localhost:4184. Set APP_ORIGIN and all registered OAuth callbacks to that deployed origin.
The Cloudflare deployment uses one Worker for the Express API and static dashboard, plus one D1 database. No separate PostgreSQL service is required for this runtime.
-
Create the D1 database and replace the placeholder
database_idinwrangler.jsonc:npx wrangler d1 create raft-gmail
-
Apply the journaled D1 migrations from the dedicated
migrations/d1directory:npx wrangler d1 migrations apply DB --remote
-
Store the six sensitive values as Worker secrets (never put their values in
wrangler.jsonc):SESSION_SECRET TOKEN_ENCRYPTION_KEY_BASE64 RAFT_CLIENT_ID RAFT_CLIENT_SECRET GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET -
Set
APP_ORIGINinwrangler.jsoncto the exact deployed HTTPS origin. Register these callbacks against that same origin:- Raft human + Agent callback:
/auth/raft/callback - Google callback:
/auth/google/callback
- Raft human + Agent callback:
-
Verify the build without publishing, then deploy:
npm run build:cloudflare npm run deploy:cloudflare
After deployment, verify /healthz, both /.well-known/ manifests, the owner login/connect flow, authorized-account discovery, access approval and revocation, read access, and draft create/update. The manifest must still expose exactly the six actions listed above and no send action.
Human browser sessions can use:
GET /api/accountsDELETE /api/accounts/:accountIdGET /api/accounts/:accountId/grantsGET /api/access-request-instructionsGET /api/access-requestsPOST /api/access-requests/:requestId/approvePOST /api/access-requests/:requestId/denyPUT /api/accounts/:accountId/grants/:agentIdDELETE /api/accounts/:accountId/grants/:agentId
GET /api/session returns the signed session's CSRF token. Send it as X-CSRF-Token on every owner mutation (PUT or DELETE).
The PUT route edits an existing approved grant; it cannot create a grant for an arbitrary Agent ID. Example edit body:
{
"scopes": ["gmail.read", "gmail.draft"],
"enabled": true
}An Agent completes Login with Raft through /auth/raft/callback. The callback creates a service-local session, sets the signed cookie consumed by raft integration login, and also returns the service-local bearer token for direct API clients; the raw Raft token is never exposed. Action responses are structured JSON returned directly to the caller. Human and Agent logins share one callback because a registered Raft OAuth app has one exact return URL; the service branches only after Raft returns the authenticated principal type.
Agent sessions last one hour by default and expire exactly at their returned expiresAt timestamp. Expiry does not change the underlying Gmail grant; the Agent can complete Login with Raft again and continue with the same currently enabled scopes.
gmail-accounts-list returns only accounts with a currently enabled grant for the authenticated Agent on the same Raft Server. Each row contains the account UUID, email, Raft owner ID, granted scopes, active status, connection time, and grant update time so the Agent can select the intended account; it never returns the encrypted Google token or a separate server ID. Revoked or disabled grants disappear immediately.
gmail-access-request accepts the signed ownerRef copied from the owner's prompt, the requested scopes, and a reason. The service takes the Agent ID and display name from the authenticated Agent session. The request creates no grant until the human approves it for selected accounts.
Draft writes require a caller-generated operationId. The service records pending before contacting Gmail. A repeated successful operation is replay-safe; an ambiguous provider outcome is held for reconciliation and is never blindly retried.
See the service RFC for the full contract and SECURITY.md before operating a deployment.
This repository is an early self-hosted skeleton. It has no hosted control plane, background scheduler, channel delivery, or production deployment automation. Operators own their Raft app registration, Google OAuth project, database, encryption key, backups, and compliance obligations.