gmail-headless-oauth

Manual OAuth2 token exchange for Gmail on headless servers. Bypass gmail-mcp-multiauth browser requirement. Generate auth URLs, exchange codes, manage multi-account credentials with auto-refresh.

5 stars

Best use case

gmail-headless-oauth is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

Manual OAuth2 token exchange for Gmail on headless servers. Bypass gmail-mcp-multiauth browser requirement. Generate auth URLs, exchange codes, manage multi-account credentials with auto-refresh.

Teams using gmail-headless-oauth should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/gmail-headless-oauth/SKILL.md --create-dirs "https://raw.githubusercontent.com/vamseeachanta/workspace-hub/main/.agents/skills/email/gmail-headless-oauth/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/gmail-headless-oauth/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How gmail-headless-oauth Compares

Feature / Agentgmail-headless-oauthStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

Manual OAuth2 token exchange for Gmail on headless servers. Bypass gmail-mcp-multiauth browser requirement. Generate auth URLs, exchange codes, manage multi-account credentials with auto-refresh.

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# Gmail Headless OAuth2

Set up Gmail API OAuth2 on servers without a browser. The standard gmail-mcp-multiauth `setup` command calls `open()` to launch a browser and listens on localhost:3000 — this fails on headless CLI servers. This skill provides the manual workaround.

## When to Use

- Setting up Gmail API access on a headless server (no GUI browser)
- gmail-mcp-multiauth `setup` fails or hangs waiting for browser callback
- Need to re-authorize after token expiry
- Adding new Gmail accounts to an existing setup

## Prerequisites

- GCP project with Gmail API enabled
- OAuth 2.0 Desktop App credentials (client_secret JSON downloaded)
- Each Gmail account added as "test user" in GCP OAuth consent screen (unless app is verified/published)

## Pitfall: GCP Test Users (CRITICAL)

If your GCP app is in "Testing" status (not published), only emails listed as **test users** can authorize. Other accounts get: `Error 403: access_denied — Hermes Gmail Automation has not completed the Google verification process`.

**Fix:** Go to https://console.cloud.google.com/apis/credentials/consent → Click **"Audience"** in the left sidebar (NOT "Clients") → scroll to "Test users" → Add each email.

## Step 1: Store OAuth Client Credentials Locally

```python
# NEVER hardcode in scripts — GitHub push protection WILL block it
# Store in ~/.gmail-mcp/oauth-env.json (chmod 600)
import json, os
path = os.path.expanduser("~/.gmail-mcp/oauth-env.json")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
    json.dump({"client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_SECRET"}, f)
os.chmod(path, 0o600)
```

Scripts load via: `os.environ.get("GMAIL_OAUTH_CLIENT_ID")` or fallback to this file.

## Step 2: Generate Auth URLs

Use the OOB (out-of-band) redirect — user gets the code on-screen instead of via localhost callback:

```python
client_id = "YOUR_CLIENT_ID"
scopes = "https://www.googleapis.com/auth/gmail.modify+https://www.googleapis.com/auth/gmail.settings.basic"
redirect = "urn:ietf:wg:oauth:2.0:oob"

auth_url = (
    f"https://accounts.google.com/o/oauth2/v2/auth?"
    f"scope={scopes}&response_type=code&access_type=offline"
    f"&client_id={client_id}&redirect_uri={redirect}"
)
print(auth_url)
```

The same URL works for all accounts — the difference is which Google account the user signs in as.

## Step 3: Exchange Auth Code for Tokens

**CRITICAL: Auth codes are SINGLE-USE.** If the exchange fails (network, typo, sandbox restart), the user must generate a new code. Plan accordingly — do NOT attempt exchange in a sandbox that may lose state.

```python
import json, os, urllib.request, urllib.parse

def exchange_code(code, client_id, client_secret):
    data = urllib.parse.urlencode({
        "code": code,
        "client_id": client_id,
        "client_secret": client_secret,
        "redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
        "grant_type": "authorization_code",
    }).encode("utf-8")
    req = urllib.request.Request(
        "https://oauth2.googleapis.com/token",
        data=data, method="POST"
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode())

# Returns: {"access_token": "ya29...", "refresh_token": "1//...", "expires_in": 3599, ...}
```

## Step 4: Save Credentials Per-Account

```python
account = "ace"  # or "personal", "skestates"
account_dir = os.path.expanduser(f"~/.gmail-{account}")
os.makedirs(account_dir, exist_ok=True)

# Save tokens
cred_path = os.path.join(account_dir, "credentials.json")
with open(cred_path, "w") as f:
    json.dump(tokens, f, indent=2)
os.chmod(cred_path, 0o600)

# Copy OAuth keys for MCP compatibility
oauth_src = os.path.expanduser("~/.gmail-mcp/gcp-oauth.keys.json")
oauth_dst = os.path.join(account_dir, "gcp-oauth.keys.json")
shutil.copy2(oauth_src, oauth_dst)
os.chmod(oauth_dst, 0o600)
```

## Step 5: Verify via Gmail API

```python
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
req = urllib.request.Request(
    "https://gmail.googleapis.com/gmail/v1/users/me/profile",
    headers=headers
)
with urllib.request.urlopen(req, timeout=30) as resp:
    profile = json.loads(resp.read().decode())
    print(f"Connected: {profile['emailAddress']} — {profile['messagesTotal']} messages")
```

## Step 6: Create MCP Start Script

```bash
#!/bin/bash
export GMAIL_OAUTH_PATH=~/.gmail-{account}/gcp-oauth.keys.json
export GMAIL_CREDENTIALS_PATH=~/.gmail-{account}/credentials.json
export GMAIL_ACCOUNT_NAME={account}
exec node ~/.npm-global/lib/node_modules/gmail-mcp-multiauth/dist/index.js
```

Save to `~/.gmail-{account}/start-mcp.sh`, chmod +x.

## Token Refresh (automated)

Tokens expire in 3600s. Refresh using the stored refresh_token:

```python
def refresh_access_token(account):
    cred_path = os.path.expanduser(f"~/.gmail-{account}/credentials.json")
    with open(cred_path) as f:
        saved = json.load(f)

    with open(os.path.expanduser("~/.gmail-mcp/oauth-env.json")) as f:
        cfg = json.load(f)

    data = urllib.parse.urlencode({
        "client_id": cfg["client_id"],
        "client_secret": cfg["client_secret"],
        "refresh_token": saved["refresh_token"],
        "grant_type": "refresh_token",
    }).encode("utf-8")

    req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data, method="POST")
    with urllib.request.urlopen(req, timeout=30) as resp:
        new_tokens = json.loads(resp.read().decode())

    saved.update(new_tokens)  # Preserves refresh_token
    with open(cred_path, "w") as f:
        json.dump(saved, f, indent=2)

    return new_tokens["access_token"]
```

## Multi-Account Batch Auth

When authorizing multiple accounts in one session:
1. Generate ONE auth URL (it's the same for all accounts)
2. Ask user to open it, sign in as each account separately, paste codes labeled
3. Exchange ALL codes in a SINGLE execute_code block (codes are single-use, sandbox state may not persist between turns)

## Pitfalls

1. **Auth codes are single-use** — if exchange fails, user must re-authorize
2. **execute_code sandbox resets between turns** — exchange code in the same block you receive it
3. **GitHub push protection blocks OAuth credentials** — never hardcode client_id/client_secret in committed files
4. **GCP test user list** — must add each email BEFORE they can authorize. The setting is under "Audience" not "Clients" in the GCP console
5. **Some Google accounts can't generate App Passwords** — Workspace accounts without 2FA admin control, or accounts with Advanced Protection. OAuth still works for these.
6. **himalaya config v1.2.0 breaks on duplicate keys** — `backend.auth.type = "password"` and `backend.auth.type = "cmd"` on separate lines causes TOML parse error. Use only the password type with `.cmd` for the command.
7. **Scopes are per-authorization** — if you need Drive access later, you must re-authorize with additional scopes. Gmail-only scopes won't give Drive access.
8. **himalaya v1.2.0 `-a` flag position** — the account flag goes on the SUBCOMMAND, not global: `himalaya envelope list -a ace` NOT `himalaya -a ace envelope list` or `himalaya --account ace envelope list`.
9. **Token refresh returns NO new refresh_token** — the response only has access_token. Always `saved.update(new_tokens)` to preserve the original refresh_token.
10. **OOB redirect deprecated but still works** — Google deprecated `urn:ietf:wg:oauth:2.0:oob` for new apps in 2022, but it still functions for Desktop App type credentials. If it stops working, switch to localhost redirect with a background HTTP server.
11. **GitGuardian will detect secrets in local git history** even if GitHub push protection blocks the push. Always amend the commit to remove secrets before they enter any git history.

## Verified Working Setup (2026-04-06)

Three accounts fully authorized on ace-linux-1:
- ace: OAuth2 + himalaya App Password
- personal: OAuth2 + himalaya App Password
- skestates: OAuth2 only (App Passwords unavailable — Workspace account limitation)

GCP Project: hermes-gmail-automation
Credential paths: ~/.gmail-{ace,personal,skestates}/credentials.json
Shared config: ~/.gmail-mcp/oauth-env.json (client_id + client_secret)
Digest script: scripts/email/gmail-digest.py (stdlib only, zero pip deps)

Related Skills

gmail-triage

5
from vamseeachanta/workspace-hub

Daily multi-account Gmail inbox triage — scan unread, classify by urgency, cross-reference contacts, generate actionable digest. Supports ace/personal/skestates accounts.

gmail-outreach

5
from vamseeachanta/workspace-hub

Outbound email actions — periodic relationship touchbase messages and batch unsubscribe from newsletters/marketing. Combines gmail-touchbase + gmail-unsubscribe into one skill.

gmail-operations

5
from vamseeachanta/workspace-hub

Class-level Gmail and email operations: multi-account setup, OAuth, triage, extraction, archiving, attachments, unsubscribe, and touchbase workflows.

gmail-multi-account

5
from vamseeachanta/workspace-hub

Multi-account Gmail management via himalaya CLI. Three accounts (aceengineer, achantav, skestates) with distinct triage rules, contact DBs, and tone profiles. Foundation skill for email automation.

gmail-extract-and-act

5
from vamseeachanta/workspace-hub

The email-as-queue workflow — extract structured data from emails, act on it, track thread state, and delete emails when topics complete. Email is transient; extracted data is persistent.

gmail-attachment-to-document

5
from vamseeachanta/workspace-hub

Download attachments from Gmail threads, parse their content (Excel, PDF), extract structured data, and save to target repos with proper legal scanning.

gmail-unsubscribe

5
from vamseeachanta/workspace-hub

Identify and batch-unsubscribe from newsletters and marketing emails across Gmail accounts. Scans List-Unsubscribe headers, generates candidates, executes with user approval.

gmail-touchbase

5
from vamseeachanta/workspace-hub

Periodic relationship maintenance via email — identify contacts due for outreach, draft personalized check-ins, queue for user approval. Supports per-account tone and cadence.

gmail-extract-archive

5
from vamseeachanta/workspace-hub

DEPRECATED — superseded by gmail-extract-and-act. Extract Gmail data into archive repo, parse attachments, legal scan, then delete. Uses archive-everything model.

gmail-extract-and-clean

5
from vamseeachanta/workspace-hub

DEPRECATED — superseded by gmail-extract-and-act. Extract emails from Gmail to /mnt/ace/<repo>/, archive to repos, commit, then delete. Uses archive-everything model.

gmail-email-to-repo-extraction

5
from vamseeachanta/workspace-hub

Extract structured data from Gmail inbox emails, enrich with domain-specific classification, legal-scan against deny list, commit to appropriate repo, then optionally delete originals.

gmail-data-extraction

5
from vamseeachanta/workspace-hub

Extract structured data from Gmail emails using REST API (no pip dependencies). Covers inbox scanning, subject line regex extraction, email text parsing, thread-aware drafting, and legal-scan-before-commit workflow.