From 4aa569bc8c7c2c851a3dd6a4e8e84d49680d25f6 Mon Sep 17 00:00:00 2001 From: GazingInSpring Date: Sun, 24 May 2026 22:27:52 +0000 Subject: [PATCH] auth: bring-your-own Google OAuth client instead of shared app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gmail.readonly is a Google restricted scope, so a single shared ZeroEntropy OAuth app would need a CASA security audit before external users could authorize without warnings. Instead, each user now supplies their own Google Cloud OAuth client credentials, so authorization runs against a Google project they control — self-use needs no Google verification. - Remove the hardcoded shared client_id/client_secret from gmail_client.py. - config.get_google_client_config resolves BYO credentials, mirroring how the ZeroEntropy API key is handled: env vars (ZEMAIL_GOOGLE_CLIENT_ID / ZEMAIL_GOOGLE_CLIENT_SECRET), a GOOGLE_CLIENT_SECRETS_FILE path, or a locally saved google_client.json. - New set_google_credentials MCP tool + GoogleCredentialsRequired error with clear setup instructions when no client is configured. - Pass the new env vars through .mcp.json; document them in plugin.json setup, the README Setup section, the search-emails skill, and .env.example (placeholders only — no real secrets). Co-Authored-By: Claude Opus 4.7 --- .claude-plugin/plugin.json | 10 +++- .env.example | 17 ++++++ .mcp.json | 5 +- README.md | 35 +++++++++++-- skills/search-emails/SKILL.md | 5 +- zemail/config.py | 98 ++++++++++++++++++++++++++++++++++- zemail/gmail_client.py | 41 +++++++++------ zemail/server.py | 53 +++++++++++++++++-- 8 files changed, 236 insertions(+), 28 deletions(-) create mode 100644 .env.example diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5ae0f42..f6248ed 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -11,8 +11,16 @@ "ZEROENTROPY_API_KEY": { "description": "Your ZeroEntropy API key. Free tier, no credit card required — grab one at https://dashboard.zeroentropy.dev/api-keys", "required": true + }, + "ZEMAIL_GOOGLE_CLIENT_ID": { + "description": "OAuth client ID from a Google Cloud project you create (ends with .apps.googleusercontent.com). Enable the Gmail API and create a 'Desktop app' OAuth client. See the README Setup section. You can also supply this later via the set_google_credentials tool.", + "required": false + }, + "ZEMAIL_GOOGLE_CLIENT_SECRET": { + "description": "OAuth client secret paired with ZEMAIL_GOOGLE_CLIENT_ID (typically starts with GOCSPX-).", + "required": false } }, - "instructions": "After installing, ask Claude to search your emails. On first use, it will open a browser window for Gmail authorization. Your emails are then indexed locally with ZeroEntropy embeddings for fast semantic search." + "instructions": "Zemail uses your own Google OAuth client so no Google app verification is needed for personal use. Create a Google Cloud project, enable the Gmail API, and create a 'Desktop app' OAuth client (see the README Setup section), then supply its Client ID + secret via the setup env vars above or by asking Claude to run set_google_credentials. On first email request, Claude opens a browser for read-only Gmail authorization, then indexes your inbox locally with ZeroEntropy embeddings." } } diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ea6be46 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Zemail configuration — copy to .env and fill in your own values. +# Never commit your real .env (it is gitignored). + +# ZeroEntropy API key (free, no credit card): https://dashboard.zeroentropy.dev/api-keys +ZEROENTROPY_API_KEY=ze_your_key_here + +# --- Bring-your-own Google OAuth client --- +# Zemail authorizes against a Google Cloud OAuth client that YOU create, so no +# Google app verification is required for personal use. See README "Setup". +# +# Option A: supply the client ID + secret directly. +ZEMAIL_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com +ZEMAIL_GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret + +# Option B (instead of A): point at the client_secret_*.json you downloaded +# from the Google Cloud Console Credentials page. +# GOOGLE_CLIENT_SECRETS_FILE=/absolute/path/to/client_secret_xxx.json diff --git a/.mcp.json b/.mcp.json index 3fd656e..3859b9e 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,7 +5,10 @@ "args": [], "env": { "CLAUDE_PLUGIN_DATA": "${CLAUDE_PLUGIN_DATA}", - "ZEROENTROPY_API_KEY": "${ZEROENTROPY_API_KEY}" + "ZEROENTROPY_API_KEY": "${ZEROENTROPY_API_KEY}", + "ZEMAIL_GOOGLE_CLIENT_ID": "${ZEMAIL_GOOGLE_CLIENT_ID}", + "ZEMAIL_GOOGLE_CLIENT_SECRET": "${ZEMAIL_GOOGLE_CLIENT_SECRET}", + "GOOGLE_CLIENT_SECRETS_FILE": "${GOOGLE_CLIENT_SECRETS_FILE}" } } } diff --git a/README.md b/README.md index a34e433..45081d9 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,36 @@ claude --plugin-dir ./zemail ## Setup -On install, Claude Code will prompt you for your **ZeroEntropy API key**. Grab a free one at [dashboard.zeroentropy.dev/api-keys](https://dashboard.zeroentropy.dev/api-keys) — takes 30 seconds and no credit card required. +Zemail needs two things: a ZeroEntropy API key, and your own Google OAuth client. -The first time you ask Claude something email-related, it will print a Google authorization URL — open it, grant read-only Gmail access, done. Zemail then syncs and indexes your inbox; subsequent searches are instant. +### 1. ZeroEntropy API key + +On install, Claude Code prompts you for your **ZeroEntropy API key**. Grab a free one at [dashboard.zeroentropy.dev/api-keys](https://dashboard.zeroentropy.dev/api-keys) — takes 30 seconds, no credit card. (You can also set it later by asking Claude to run `set_api_key`.) + +### 2. Your own Google OAuth client (bring-your-own credentials) + +Gmail's `gmail.readonly` scope is a Google **restricted scope**. For a single shared app, Google requires a paid security audit (CASA) before outside users can authorize without warnings. Zemail sidesteps this by having **each user authorize against their own Google Cloud OAuth client** — using your own client for your own account needs no verification. + +One-time setup in the [Google Cloud Console](https://console.cloud.google.com/): + +1. **Create or select a project** (top project picker > New Project). +2. **Enable the Gmail API**: APIs & Services > Library > search "Gmail API" > **Enable**. +3. **Configure the OAuth consent screen**: APIs & Services > OAuth consent screen. + - User type: **External**. + - Fill in the required app name / support email. + - On the **Test users** step, add your own Google account. (While the app stays in "Testing" you don't need verification; you'll just click through an "unverified app" notice once.) + - You do not need to add the `gmail.readonly` scope here for testing, but you may. +4. **Create the OAuth client**: APIs & Services > Credentials > **Create Credentials** > **OAuth client ID** > Application type: **Desktop app** > Create. Copy the **Client ID** and **Client secret**. +5. **Give the credentials to zemail** in any one of these ways: + - Ask Claude to run `set_google_credentials` with your client ID and secret (saved locally to your zemail data dir), **or** + - Set the env vars `ZEMAIL_GOOGLE_CLIENT_ID` and `ZEMAIL_GOOGLE_CLIENT_SECRET` (see `.env.example`), **or** + - Download the `client_secret_*.json` from the Credentials page and set `GOOGLE_CLIENT_SECRETS_FILE` to its path. + +### 3. Authorize Gmail + +The first time you ask Claude something email-related, it prints a Google authorization URL — open it, grant read-only Gmail access (click through the "unverified app" notice for your own app), done. Zemail then syncs and indexes your inbox; subsequent searches are instant. + +If you're on a remote/SSH machine where the browser redirect can't reach your terminal, copy the code shown on the success page and ask Claude to run `authorize_gmail` with it. ## Usage @@ -68,6 +95,7 @@ User asks Claude → search_emails_tool → Embed query with zembed-1 | Tool | Description | |------|-------------| | `set_api_key` | Save your ZeroEntropy API key | +| `set_google_credentials` | Save your own Google OAuth client ID + secret | | `sync_emails` | Fetch and index Gmail messages | | `search_emails_tool` | Semantic search over indexed emails | | `get_email` | Read full email content by ID | @@ -76,13 +104,14 @@ User asks Claude → search_emails_tool → Embed query with zembed-1 ## Privacy -Zemail requests the `gmail.readonly` OAuth scope — read-only access to your messages. Embeddings, metadata, and the OAuth token are stored on your local machine only. Message content is sent to ZeroEntropy's API for embedding and reranking; it is not stored there. Nothing is ever written back to Gmail. +Zemail requests the `gmail.readonly` OAuth scope — read-only access to your messages. You authorize against your own Google OAuth client, so your Gmail access is scoped to a Google Cloud project you control. Your Google client ID/secret, the OAuth token, embeddings, and metadata are stored on your local machine only. Message content is sent to ZeroEntropy's API for embedding and reranking; it is not stored there. Nothing is ever written back to Gmail. ## Requirements - Python 3.12+ - [uv](https://docs.astral.sh/uv/) - A Gmail account +- Your own Google Cloud OAuth client (free; see [Setup](#setup)) - A [ZeroEntropy API key](https://dashboard.zeroentropy.dev/api-keys) (free, no credit card) ## Development diff --git a/skills/search-emails/SKILL.md b/skills/search-emails/SKILL.md index 01602ec..48b1921 100644 --- a/skills/search-emails/SKILL.md +++ b/skills/search-emails/SKILL.md @@ -11,8 +11,9 @@ When the user asks about their emails or wants to find specific messages, use th 1. Call `index_status` to check if emails are already indexed 2. If no index, call `sync_emails` 3. If sync returns "API key not found", tell the user to grab a free key (no credit card required) at https://dashboard.zeroentropy.dev/api-keys and call `set_api_key` with it -4. If sync returns an OAuth URL, show it to the user and ask them to authorize in their browser, then call `sync_emails` again -5. If on a remote machine and auto-auth fails, ask the user for the code from the success page and call `authorize_gmail` +4. If sync returns "Google OAuth client credentials not found", relay the setup steps to the user: they create their own Google Cloud OAuth client (enable the Gmail API, create a "Desktop app" OAuth client ID), then call `set_google_credentials` with the client ID and secret. See the README "Setup" section. +5. If sync returns an OAuth URL, show it to the user and ask them to authorize in their browser, then call `sync_emails` again +6. If on a remote machine and auto-auth fails, ask the user for the code from the success page and call `authorize_gmail` ## Searching diff --git a/zemail/config.py b/zemail/config.py index 0bdf3f6..661be16 100644 --- a/zemail/config.py +++ b/zemail/config.py @@ -1,5 +1,6 @@ -"""Centralized config — resolves data directory for plugin vs local dev.""" +"""Centralized config — resolves data directory and Google OAuth client.""" +import json import os from pathlib import Path @@ -17,3 +18,98 @@ def get_data_dir() -> Path: d = Path.home() / ".zemail" d.mkdir(parents=True, exist_ok=True) return d + + +def _google_client_path() -> Path: + return get_data_dir() / "google_client.json" + + +def _env(name: str) -> str | None: + """Read an env var, treating empty or unexpanded ${...} literals as unset.""" + val = os.environ.get(name) + if val and "${" not in val: + return val + return None + + +GOOGLE_CLIENT_MISSING_MSG = ( + "Google OAuth client credentials not found. Zemail uses your own Google " + "Cloud OAuth client so it can authorize against your personal Google " + "project (no app verification required for self-use).\n\n" + "One-time setup:\n" + " 1. Go to https://console.cloud.google.com/ and create (or pick) a project.\n" + " 2. Enable the Gmail API: APIs & Services > Library > search 'Gmail API' > Enable.\n" + " 3. Configure the OAuth consent screen (User type: External; add your own\n" + " Google account as a Test user).\n" + " 4. APIs & Services > Credentials > Create Credentials > OAuth client ID >\n" + " Application type: 'Desktop app'. Copy the Client ID and Client secret.\n" + " 5. Provide them to zemail either by:\n" + " - calling set_google_credentials(client_id, client_secret), or\n" + " - setting ZEMAIL_GOOGLE_CLIENT_ID and ZEMAIL_GOOGLE_CLIENT_SECRET, or\n" + " - setting GOOGLE_CLIENT_SECRETS_FILE to the path of the client_secret_*.json\n" + " you downloaded from the Credentials page." +) + + +def save_google_client(client_id: str, client_secret: str) -> None: + """Persist the user's Google OAuth client credentials locally.""" + with open(_google_client_path(), "w") as f: + json.dump({"client_id": client_id, "client_secret": client_secret}, f) + + +def _client_config_from_parts(client_id: str, client_secret: str) -> dict: + return { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "redirect_uris": ["http://localhost"], + } + } + + +def get_google_client_config() -> dict | None: + """Resolve the user's Google OAuth client config (BYO credentials). + + Resolution order, mirroring how the ZeroEntropy API key is handled: + 1. ZEMAIL_GOOGLE_CLIENT_ID + ZEMAIL_GOOGLE_CLIENT_SECRET env vars. + 2. GOOGLE_CLIENT_SECRETS_FILE — path to a client_secret_*.json downloaded + from the Google Cloud Console (supports "installed" or "web" keys). + 3. google_client.json saved in the data dir via set_google_credentials. + + Returns a google-auth client config dict, or None if nothing is configured. + """ + client_id = _env("ZEMAIL_GOOGLE_CLIENT_ID") + client_secret = _env("ZEMAIL_GOOGLE_CLIENT_SECRET") + if client_id and client_secret: + return _client_config_from_parts(client_id, client_secret) + + secrets_file = _env("GOOGLE_CLIENT_SECRETS_FILE") + if secrets_file: + path = Path(secrets_file).expanduser() + if not path.exists(): + raise FileNotFoundError( + f"GOOGLE_CLIENT_SECRETS_FILE points to a missing file: {path}" + ) + with open(path) as f: + data = json.load(f) + if "installed" in data: + return {"installed": data["installed"]} + if "web" in data: + # Reuse the web client's id/secret for the installed/loopback flow. + web = data["web"] + return _client_config_from_parts(web["client_id"], web["client_secret"]) + raise ValueError( + f"{path} is not a recognized Google OAuth client file " + "(expected an 'installed' or 'web' top-level key)." + ) + + cp = _google_client_path() + if cp.exists(): + with open(cp) as f: + saved = json.load(f) + return _client_config_from_parts(saved["client_id"], saved["client_secret"]) + + return None diff --git a/zemail/gmail_client.py b/zemail/gmail_client.py index c7921b1..9872c8c 100644 --- a/zemail/gmail_client.py +++ b/zemail/gmail_client.py @@ -11,28 +11,35 @@ from googleapiclient.discovery import build from googleapiclient.errors import HttpError -from .config import get_data_dir - -# Baked-in OAuth client config (standard for installed/CLI apps) -_OAUTH_CLIENT_CONFIG = { - "installed": { - "client_id": "1044175826794-2a8ddqrqkpn1kt57ipn15ejvuhj9m17a.apps.googleusercontent.com", - "project_id": "zerank-2", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_secret": "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", - "redirect_uris": ["http://localhost"], - } -} - +from .config import ( + GOOGLE_CLIENT_MISSING_MSG, + get_data_dir, + get_google_client_config, +) + +# Each user brings their own Google OAuth client (BYO credentials), resolved +# from env vars / a client_secret file / locally-saved credentials. See +# config.get_google_client_config. SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] +def _require_client_config() -> dict: + config = get_google_client_config() + if config is None: + raise GoogleCredentialsRequired(GOOGLE_CLIENT_MISSING_MSG) + return config + + def _token_path(): return get_data_dir() / "gmail_token.pickle" +class GoogleCredentialsRequired(Exception): + """Raised when the user's Google OAuth client credentials are not configured.""" + def __init__(self, message: str = GOOGLE_CLIENT_MISSING_MSG): + super().__init__(message) + + class OAuthRequired(Exception): """Raised when Gmail OAuth authorization is needed.""" def __init__(self, auth_url: str): @@ -80,7 +87,7 @@ def _start_oauth_server(): if _oauth_server_thread and _oauth_server_thread.is_alive(): return _oauth_auth_url - flow = InstalledAppFlow.from_client_config(_OAUTH_CLIENT_CONFIG, SCOPES) + flow = InstalledAppFlow.from_client_config(_require_client_config(), SCOPES) # Find a free port sock = socket.socket() @@ -181,7 +188,7 @@ def complete_oauth(auth_code: str): creds = pickle.load(f) if creds and creds.valid: return - flow = InstalledAppFlow.from_client_config(_OAUTH_CLIENT_CONFIG, SCOPES) + flow = InstalledAppFlow.from_client_config(_require_client_config(), SCOPES) flow.redirect_uri = _oauth_redirect_uri or "http://localhost" flow.fetch_token(code=auth_code) creds = flow.credentials diff --git a/zemail/server.py b/zemail/server.py index f862264..de9df05 100644 --- a/zemail/server.py +++ b/zemail/server.py @@ -8,14 +8,20 @@ from mcp.server.fastmcp import FastMCP from zeroentropy import ZeroEntropy -from .config import get_data_dir +from .config import get_data_dir, save_google_client from .embeddings import ( email_to_text, embed_documents, load_embeddings, save_embeddings, ) -from .gmail_client import OAuthRequired, complete_oauth, fetch_all_emails, get_gmail_service +from .gmail_client import ( + GoogleCredentialsRequired, + OAuthRequired, + complete_oauth, + fetch_all_emails, + get_gmail_service, +) from .search import search_emails mcp = FastMCP("zemail") @@ -70,6 +76,40 @@ def set_api_key(api_key: str) -> str: return "API key saved! You can now use sync_emails and search_emails_tool." +@mcp.tool() +def set_google_credentials(client_id: str, client_secret: str) -> str: + """Save your own Google OAuth client credentials so zemail can access Gmail. + + Zemail authorizes against a Google Cloud OAuth client that YOU create, so no + Google app verification is needed for your own use. Create one at + https://console.cloud.google.com/ : enable the Gmail API, configure the OAuth + consent screen (add yourself as a Test user), then create an OAuth client ID + of type "Desktop app" and copy its Client ID and Client secret here. + + The credentials are saved locally (in your zemail data dir) and persist across + sessions. After saving, run sync_emails to start the Gmail authorization flow. + + Args: + client_id: OAuth client ID (ends with .apps.googleusercontent.com) + client_secret: OAuth client secret (typically starts with GOCSPX-) + """ + client_id = client_id.strip() + client_secret = client_secret.strip() + if not client_id.endswith(".apps.googleusercontent.com"): + return ( + "That doesn't look like a Google OAuth client ID — it should end with " + "'.apps.googleusercontent.com'. Create one in the Google Cloud Console " + "(Credentials > Create Credentials > OAuth client ID > Desktop app)." + ) + if not client_secret: + return "client_secret is empty — copy it from the same OAuth client in the Google Cloud Console." + save_google_client(client_id, client_secret) + return ( + "Google OAuth client saved! Now run sync_emails — it will print a Google " + "authorization URL for you to grant read-only Gmail access." + ) + + @mcp.tool() def sync_emails(max_emails: int = 2000, query: str = "", label: str = "INBOX") -> str: """Sync emails from Gmail and build the semantic search index. @@ -90,6 +130,8 @@ def sync_emails(max_emails: int = 2000, query: str = "", label: str = "INBOX") - try: service = get_gmail_service() + except GoogleCredentialsRequired as e: + return str(e) except OAuthRequired as e: return ( "Gmail authorization required. Please visit this URL in your browser:\n\n" @@ -157,7 +199,10 @@ def authorize_gmail(auth_code: str) -> str: Args: auth_code: The authorization code from the success page """ - complete_oauth(auth_code) + try: + complete_oauth(auth_code) + except GoogleCredentialsRequired as e: + return str(e) return "Gmail authorized successfully! You can now run sync_emails." @@ -218,6 +263,8 @@ def get_email(email_id: str) -> str: """ try: service = get_gmail_service() + except GoogleCredentialsRequired as e: + return str(e) except OAuthRequired as e: return f"Gmail authorization required. Visit: {e.auth_url}"