Skip to main content
Glama
email-mcp

email-mcp

by sasha-id
README.md
# email-mcp

MCP server (stdio) exposing structured email tools over IMAP/SMTP for multiple
accounts: `email_list_accounts`, `email_list_folders`, `email_search`,
`email_read`, `email_attachment`, `email_mark`, `email_move`, `email_delete`,
`email_send`, `email_authenticate`.

## Setup

```bash
npm install
npm run build
```

Copy `accounts.example.json` to `~/.config/email-mcp/accounts.json` (or set
`EMAIL_MCP_CONFIG`) and fill in your accounts.

- `auth.type: "password"` — plain IMAP LOGIN / SMTP AUTH. `pass` is either a
  literal or a 1Password reference (`op://vault/item/field`), resolved via
  `op read` at first use — the `op` CLI must be signed in.
- `auth.type: "m365-oauth"` — Exchange Online / Microsoft 365 via XOAUTH2
  device-code flow (basic-auth IMAP is dead there). `tenant` is your domain or
  tenant ID; `clientId` is optional and defaults to Thunderbird's public client.
  Run the `email_authenticate` tool once per account; the refresh token is
  cached at `~/.config/email-mcp/tokens/<account>.json` (chmod 600).
- `appendToSent: true` — copy sent mail to the `\Sent` folder via IMAP APPEND
  (for servers that don't save it automatically; keep `false` for M365/Gmail).

## Register with Claude Code (user scope)

```bash
claude mcp add --scope user email -- node /path/to/email-mcp/dist/index.js
```

Or in JSON config:

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

## Skill

Symlink the bundled orchestration skill into your personal skills directory so
Claude Code picks it up:

```bash
ln -s /path/to/email-mcp/skills/email-manager ~/.claude/skills/email-manager
```

## Rules mode (non-interactive sorting)

`email-mcp rules` sorts mail by ordered, deterministic rules from YAML files,
with an optional LiteLLM classifier for mail no rule matches. It uses the same
account config, 1Password references and M365 tokens as the server. With no
subcommand, `email-mcp` still starts the MCP server.

```bash
email-mcp rules check <file.yml>...        # validate; exit 0/1
email-mcp rules run <file.yml>... [--dry-run] [--accounts a,b] [--since YYYY-MM-DD]
                                  [--include-read] [--state <path>] [--report <path.json>]
                                  [--no-classifier]
email-mcp rules folders <file.yml>... [--create]
```

- `run` handles unread mail in the file's mailboxes by default. Add `--since`
  and `--include-read` for a one-off backlog pass.
- `--dry-run` still searches, fetches and calls the classifier, but changes
  nothing and never sets `\Seen`. It prints the planned action for each message.
- `--state` remembers messages already decided `keep` (by a rule or the
  classifier), so later runs skip them. Entries older than 60 days are pruned.
- `--report` writes a JSON report. It is written even if the run fails.
- Exit codes: `0` ok, `1` config or validation error, `2` some account failed
  (the other accounts were still processed).
- `folders` lists the folders each account's move, map and purge actions
  target. `--create` creates the missing ones, but only those listed in
  `create_folders`.

```yaml
version: 1
accounts: [personal, work]       # account names from accounts.json (required)
mailboxes: [INBOX]               # default [INBOX]
unread_only: true                # default true
default_action: keep             # no rule matched and no classifier verdict
sweep_trash_unread: false        # mark unread mail in \Trash read after the pass
account_purpose:                 # optional context line for the classifier
  personal: "personal mail: family, home, bank"
classifier:                      # optional
  provider: luna                 # luna | jev
  min_confidence: 0.6            # below => default_action
  map:                           # verdict -> action; unlisted or "unsure" => default_action
    fyi: read
    finance: {move: Finance, read: true}
    auth: {move: Auth, read: true}
    trash: trash
purge:                           # time-based clean-up of a folder
  - {folder: Auth, older_than: 24h, action: trash}
create_folders:                  # folders `rules folders --create` may create
  personal: [Finance, Auth]
rules:                           # ordered; first match wins
  - id: codes
    accounts: [personal]         # optional filter
    when:                        # all conditions must hold; case-insensitive JS regexes
      from: '@accounts\.example\.com$'
      subject: 'verification code'
    action: {move: Auth, read: true}
  - id: replies
    when: {sent_thread: true}    # subject matches something you sent in the last 90 days
    action: keep
```

Conditions: `from`, `from_not`, `from_name`, `subject`, `subject_not`, `to`,
`cc`, `to_cc`, `list_id`, `body` (fetched only when a rule reaches that
condition), `sent_thread`, `sent_recipient` (a sender you wrote to in the last
180 days), and `older_than` (`Nh` or `Nd`). Actions:

- `keep`: change nothing.
- `read`: mark read.
- `trash`: mark read, then move to `\Trash`. It never expunges.
- `{move: F, read: bool}`: move to folder `F`. If `F` is missing, only the read
  flag is applied and the report records `folder_missing`.
- `classify`: send the message to the classifier.

The classifier calls LiteLLM at `LITELLM_BASE_URL` using `LITELLM_API_KEY`.
Without either it is skipped for the run and a warning goes in the report. If it fails, the
message gets `default_action`, except that a failure never trashes mail. The
Junk folder is never sent to the classifier.

## Development

```bash
npm test          # vitest unit suite (no network)
npm run build     # tsc → dist/
```

Manual end-to-end testing: run the server and exercise each tool against a real account.