Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
}
}
Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions skills/search-emails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
98 changes: 97 additions & 1 deletion zemail/config.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
41 changes: 24 additions & 17 deletions zemail/gmail_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
53 changes: 50 additions & 3 deletions zemail/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand All @@ -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"
Expand Down Expand Up @@ -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."


Expand Down Expand Up @@ -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}"

Expand Down