Skip to main content
Glama
boringfy

email-mcp

by boringfy
README.md
# email-mcp

A local [MCP](https://modelcontextprotocol.io) server for Gmail with **strict alias whitelisting**: the connected AI client can only see, search, reply to, send from, or draft as an explicitly whitelisted set of alias addresses. Every other message in the mailbox is unreachable — including by direct message/thread/attachment ID — and indistinguishable from a message that doesn't exist.

Built for mailboxes that receive mail for many aliases (Google Workspace "Send mail as" addresses) where an AI assistant should be able to handle *some* of that mail, but must never read the rest.

## Security model

The server process holds Gmail OAuth tokens and is trusted; the MCP client (the AI) is untrusted. All enforcement lives in one module, `src/gmail/gateway.ts` (`SecureGmailGateway`) — the only code that touches the Gmail API. Three layers:

1. **Server-built queries (Layer 1).** Every Gmail search has a whitelist clause (`from:/to:/cc:/deliveredto:` per alias) ANDed in server-side. There is no raw query passthrough; free text is tokenized and each token quoted, so Gmail search operators (`OR`, `-`, `{}`, `deliveredto:` …) cannot be injected to escape the constraint.
2. **Post-fetch header verification (Layer 2 — the real guarantee).** Before any content is returned, the message's `From`/`To`/`Cc`/`Bcc`/`Delivered-To`/`X-Original-To` headers are parsed with an RFC 5322 parser ([`email-addresses`](https://www.npmjs.com/package/email-addresses)) and at least one exact addr-spec must match the whitelist. Display-name spoofing (`"sales@boringfy.com" <attacker@evil.com>`), lookalike domains, and substring tricks don't match; malformed headers never grant access. Denials return a uniform "not found" — denied and nonexistent are indistinguishable. Threads are filtered per message; hidden counts are never revealed.
3. **Send/draft gating (Layer 3).** `from_alias` must be whitelisted **and** a registered Gmail send-as alias. User-supplied header values are CRLF-stripped (header-injection defense). Replies verify the parent message via Layer 2 first.

OAuth scopes are minimal: `gmail.readonly`, `gmail.send`, `gmail.compose`. No `gmail.modify` — the server cannot delete mail or change labels even if asked.

> **Scope caveat:** `gmail.readonly` technically lets the *server process* read the whole mailbox — Gmail offers no narrower read scope. The whitelist constrains what crosses the boundary to the AI client, which is the boundary this project enforces.

## Tools

| Tool | Inputs | Returns |
|---|---|---|
| `list_allowed_aliases` | — | Whitelisted aliases; per alias whether it is a registered send-as (can be used as `from_alias`) and its display name |
| `search_emails` | `free_text`, `subject`, `from_address`, `to_address`, `alias`, `after`/`before` (`YYYY/MM/DD`), `has_attachment`, `unread_only`, `max_results`, `page_token` | Message summaries (headers, snippet, `matched_alias`) + pagination token |
| `get_message` | `message_id` | Headers, decoded plain-text body, attachment metadata |
| `get_thread` | `thread_id` | The thread's whitelist-visible messages (headers + snippet) |
| `get_attachment` | `message_id`, `attachment_id` | Base64 content, size-capped (`limits.maxAttachmentBytes`) |
| `send_email` | `from_alias`, `to`, `cc`, `bcc`, `subject`, `body_text`, `reply_to_message_id` | Sent message/thread IDs. Replying sets `In-Reply-To`/`References` and threads automatically. Disabled when `requireDraftOnly` is true |
| `create_draft` | same as `send_email` | Draft ID — for human review/send in Gmail |

Deliberately absent: delete, trash, label read/write, raw Gmail query passthrough, history/watch.

## Configuration

The config file path comes from the `EMAIL_MCP_CONFIG` environment variable (default: `./email-mcp.config.json`). See `config.example.json`.

| Key | Default | Meaning |
|---|---|---|
| `whitelistedAliases` | *(required)* | Addresses the AI may see and act as. Server refuses to start if empty or malformed |
| `oauth.clientId` / `oauth.clientSecret` | *(required)* | Google OAuth **Desktop app** client credentials |
| `oauth.tokenPath` | *(required)* | Where OAuth tokens are stored (written with mode `600`) |
| `limits.maxSearchResults` | `25` | Hard cap on search page size |
| `limits.maxAttachmentBytes` | `3000000` | Attachment download cap |
| `requireDraftOnly` | `false` | If `true`, `send_email` is not registered at all — the AI can only create drafts you review in Gmail |

The whitelist lives only in this file. Nothing in the tool surface can read or change it.

## Setup

### 1. Google Cloud project (one-time)

1. Create or choose a project at <https://console.cloud.google.com> and enable the **Gmail API**.
2. **OAuth consent screen**: for a Google Workspace account choose **Internal** (no verification, stable refresh tokens). Otherwise **External** and add yourself as a test user (test-mode refresh tokens expire after 7 days until the app is published).
3. **Credentials → Create credentials → OAuth client ID → Desktop app.** Copy the client ID and secret. (Desktop clients need no authorized origins or redirect URIs — the loopback redirect is allowed automatically.)

### 2. Install and configure

```bash
git clone <this repo> && cd email-mcp
npm install
npm run build
cp config.example.json email-mcp.config.json
# edit: whitelistedAliases, oauth.clientId, oauth.clientSecret, oauth.tokenPath
```

### 3. Authorize (one-time)

```bash
node dist/index.js auth
```

Opens a consent URL; approve as the mailbox owner. Tokens are saved to `oauth.tokenPath` with mode `600`.

Running on a remote/headless machine? The redirect targets `127.0.0.1:<port>` on that machine. Either forward the printed port from the machine your browser runs on (`ssh -L <port>:127.0.0.1:<port> <host>`), or paste the full redirect URL your browser lands on into a local `curl "<url>"` on the server.

### 4. Register with an MCP client

`.mcp.json` (Claude Code) or the `mcpServers` block of `claude_desktop_config.json` (Claude Desktop):

```json
{
  "mcpServers": {
    "email": {
      "command": "node",
      "args": ["/absolute/path/to/email-mcp/dist/index.js"],
      "env": { "EMAIL_MCP_CONFIG": "/absolute/path/to/email-mcp/email-mcp.config.json" }
    }
  }
}
```

## Testing

```bash
npm test        # unit + adversarial suites (see below)
npx @modelcontextprotocol/inspector node dist/index.js   # interactive smoke test
```

The adversarial suites cover: Gmail query injection (plus a fuzz pass), header spoofing (display names, lookalike domains, group syntax, malformed headers), direct ID-fetch denial with proof that no content fetch occurred, mixed-thread filtering, send/draft gating, and CRLF header injection.

Recommended manual red-team pass after connecting a real account: take the ID of a message you know is outside the whitelist and ask the AI to fetch it (expect "Message not found"); try query injection through `free_text`; try `send_email` with a non-whitelisted `from_alias`.

## Project layout

```
src/
├── index.ts                  # entry: `auth` subcommand vs stdio server
├── server.ts                 # MCP tool registration (the exposed surface)
├── config.ts                 # zod-validated config loading
├── auth/                     # OAuth installed-app flow + 600-perm token store
├── gmail/
│   ├── gateway.ts            # SecureGmailGateway — sole security chokepoint
│   ├── render.ts             # Gmail payload → text body / attachments
│   └── mime.ts               # RFC 822 builder (CRLF-safe)
└── security/
    ├── whitelist.ts          # normalized exact-match set
    ├── queryBuilder.ts       # Layer 1
    └── headerVerifier.ts     # Layer 2
```

## Known limitations

- With many aliases (> ~15) the search clause gets long; if Gmail rejects it, chunk searches per alias group (Layer 2 already guarantees safety).
- Mail delivered via groups/forwarding may not be findable through `deliveredto:` search, though it still passes Layer 2 on direct/thread fetch via `X-Original-To`.
- Whitelisted receive-only addresses (not registered send-as) can't send; `list_allowed_aliases` shows which can.
- The AI can compose arbitrary outbound text from whitelisted aliases — your MCP client's per-tool-call approval prompt is the human gate, or set `requireDraftOnly: true`.
- The token file is protected by file permissions only; any process running as the same OS user could read it.
- Whitelist matching is exact ASCII; internationalized addresses are not punycode-folded.

## License

MIT