ESS is a local-first email search service with a CLI and an MCP server. It stores canonical email data in SQLite and indexes searchable content in Tantivy.
Who it's for: Developers and AI agents that need programmatic access to email data without cloud dependencies or third-party SaaS.
Why ESS:
- Local-first — your email data stays on your machine in SQLite + Tantivy, not in someone else's cloud
- MCP-native — five tools (
ess_search,ess_thread,ess_contacts,ess_recent,ess_stats) ready for any MCP client - Fast full-text search — Tantivy provides sub-second search across thousands of emails
- Multi-account — manage professional and personal accounts with scope filtering (
--scope pro) - Flexible ingest — import JSON archives or sync live from Microsoft Graph and Gmail APIs
- Imports JSON email archives into a local SQLite database.
- Syncs from Microsoft Graph and Gmail APIs (delta sync with token caching).
- Indexes email text for fast full-text search.
- Exposes both CLI commands and MCP tools (
ess_search,ess_thread,ess_contacts,ess_recent,ess_stats). - Supports multi-account setups with account-type scoping (
professional,personal).
Graph sync dynamically discovers all mailbox folders via GET /users/{email}/mailFolders (including hidden folders). Every discovered folder is synced using delta queries, so custom folders, subfolder hierarchies, and folders created by third-party clients (e.g. Superhuman's "Done" → Archive) are all captured.
Well-known folders are normalised to short ESS labels:
| Display name | ESS emails.folder |
|---|---|
| Inbox | inbox |
| Sent Items | sent |
| Archive | archive |
| Drafts | drafts |
| Deleted Items | trash |
| Junk Email | spam |
| Outbox | outbox |
| Conversation History | conversation_history |
Custom/user-created folders use their lowercased display name as the label. Child folders use a parent/child path format.
System folders (Sync Issues, Conflicts, Local Failures, Server Failures) and search folders are excluded automatically.
Each folder has its own delta cursor in sync_state keyed by folder ID:
graph_delta_link:{account_id}:{folder_id}
Legacy delta cursors (well-known name keys and pre-multi-folder inbox keys) are migrated automatically on next sync.
Initial sync uses the Graph /messages endpoint to enumerate all messages in each folder, then establishes a delta baseline for future incremental syncs. Subsequent syncs use delta queries, which are fast (typically seconds) and only fetch new/changed/deleted messages.
Meeting invite notifications (subjects like "Updated invitation: ...", "Accepted: ...") are eventMessage types in the Graph API. They inherit from message and are returned by /messages, so ESS syncs them alongside regular email. Calendar events (structured start/end times, attendees, RSVP) are separate Graph API resources and are not part of email sync.
The Graph API totalItemCount on a folder counts all item types (emails, calendar items, tasks, FAI), not just messages. The /messages endpoint returns only email-type items. This means totalItemCount will typically exceed the actual number of synced emails. Additionally, the Deleted Items folder's totalItemCount includes Recoverable Items (soft-deleted dumpster) which are not accessible via the Graph API.
ESS uses /messages pagination count as the source of truth, not totalItemCount.
- The Exchange Online In-Place Archive (Online Archive mailbox) is not accessible via Microsoft Graph API (v1.0 or beta). This is a Microsoft platform limitation. Superhuman's "Done" action uses the primary mailbox's Archive folder, which is synced normally.
- Recoverable Items in
Deleted Items(permanently deleted items still in retention hold) are not accessible via the Graph API and are not synced.
- Rust toolchain (
cargo, Rust 1.75+ recommended) - Linux/macOS shell
- Optional for Graph sync:
- Microsoft Graph app credentials (
ESS_CLIENT_ID,ESS_CLIENT_SECRET,ESS_TENANT_ID)
- Microsoft Graph app credentials (
- Optional for Gmail sync:
- Google OAuth credentials (
ESS_GMAIL_CLIENT_ID,ESS_GMAIL_CLIENT_SECRET,ESS_GMAIL_REFRESH_TOKEN) or per-account--configJSON
- Google OAuth credentials (
cd /path/to/ess
cargo install --path .cd /path/to/ess
./scripts/install.shThe installer:
- Builds release binary
- Installs
essto~/.local/bin/ess - Creates
~/.ess/config.tomlif missing
ess --version
ess --helpess accounts add you@company.com professional --tenant-id <tenant-id>account_id defaults to the lowercased email address.
ess import /path/to/archive --account you@company.comIf only one account exists, --account is optional.
ess search "quarterly planning" --from alice@company.com --since 2026-01-01 --limit 20ess show <message-id>
ess thread <conversation-id>ESS Stats
=========
Accounts: 2
Emails: 12500
Contacts: 1830
Emails by account
-----------------
you@company.com 10200
personal@gmail.com 2300
Index Docs: 12500
Index Size (bytes): 536870912
{
"database": {
"total_accounts": 2,
"total_emails": 12500,
"total_contacts": 1830,
"emails_by_account": [
{ "account_id": "you@company.com", "count": 10200 },
{ "account_id": "personal@gmail.com", "count": 2300 }
]
},
"index_doc_count": 12500,
"index_size_bytes": 536870912
}From Subject Date
------------------------ -------------------------------------------------------- ----------
Claude Team Introducing Claude Opus 4.6 and agent teams 2026-02-05
Anthropic Secure link to log in to Claude.ai 2026-02-01
Claude Team Welcome to Claude — let's get started 2026-01-30
Global flags (available on all commands):
--jsonoutput JSON instead of table/text--scope <pro|personal|all>filter by account type
Search indexed emails.
Example:
ess search "budget review" --account you@company.com --folder inbox --limit 25Options:
--from <email>--since <YYYY-MM-DD>--until <YYYY-MM-DD>--account <account-id>--folder <folder>--limit <n>
List emails with lightweight filters.
Example:
ess list --unread --account you@company.com --limit 50Options:
--from <email>--unread--account <account-id>--limit <n>
Show one email by ID.
Example:
ess show AAMkAG...Show all messages in a conversation.
Example:
ess thread AAQkAG...Sync configured accounts from Microsoft Graph and Gmail.
Examples:
# Sync all accounts
ess sync
# Sync from Microsoft Graph
ess sync --account work@company.com
# Sync from Gmail
ess sync --account personal@gmail.com
ess sync --watchOptions:
--account <account-id>--full--watch
Import local JSON archive files.
Example:
ess import ./fixtures/archive --account you@company.comOptions:
--account <account-id>
List/search contacts inferred from emails.
Example:
ess contacts --query "alice"Options:
--query <text>--enrich(placeholder; currently prints a notice and returns current data)
Manage account metadata/state.
Examples:
ess accounts list
ess accounts add you@gmail.com personal
ess accounts remove you@gmail.com
ess accounts sync-statusSubcommands:
listadd <email> <professional|personal> [--tenant-id <tenant-id>]remove <account-id>sync-status
Show DB and index stats.
Example:
ess statsRebuild Tantivy index from SQLite source-of-truth.
Example:
ess reindexRun the MCP server over stdio.
Example:
ess mcpA reference .mcp.json is included in the repo as a starting point. Add ESS to your MCP client config:
{
"servers": {
"ess": {
"command": "ess",
"args": ["mcp"]
}
}
}ess_search: full-text search with filtersess_thread: fetch messages in a conversationess_contacts: search contacts by name/emailess_recent: list recent emails with optional unread/scope filtersess_stats: database/index summary
Example tools/call payload:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "ess_search",
"arguments": {
"query": "security review",
"scope": "professional",
"limit": 10
}
}
}ESS imports .json files from a directory. Each file represents one email. The connector accepts both Microsoft Graph API format and a simpler flat format.
{
"id": "msg-001",
"subject": "Q2 Planning Kickoff",
"receivedDateTime": "2026-01-15T10:30:00Z",
"from": { "name": "Alice Chen", "address": "alice@example.com" },
"toRecipients": [
{ "name": "Bob Smith", "address": "bob@example.com" }
],
"body": { "contentType": "text", "content": "Let's schedule the kickoff for next week." },
"bodyPreview": "Let's schedule the kickoff for next week."
}| Field | Required | Description |
|---|---|---|
id |
yes | Unique message identifier |
subject |
no | Email subject line |
receivedDateTime |
no | ISO 8601 timestamp (falls back to sentDateTime, then current time) |
sentDateTime |
no | When the email was sent |
from |
no | Object with name and/or address (or Graph format with emailAddress.name/emailAddress.address) |
toRecipients |
no | Array of recipient objects (same format as from) |
ccRecipients |
no | Array of CC recipients |
bccRecipients |
no | Array of BCC recipients |
body |
no | Object with contentType ("text" or "html") and content, or a plain string |
bodyPreview |
no | Short text preview of the body |
importance |
no | "low", "normal", or "high" |
isRead |
no | Boolean |
hasAttachments |
no | Boolean |
headers |
no | Object with MIME headers (Message-ID, Thread-Topic, etc.) |
conversationId |
no | Thread/conversation grouping ID |
internetMessageId |
no | RFC 2822 Message-ID |
categories |
no | Array of category strings |
webLink |
no | URL to the message in a web client |
Fields can be nested under an "email" wrapper object. The connector also reads MIME headers (From, To, Cc, Bcc, Message-ID, Thread-Topic) as fallbacks when top-level fields are missing.
ESS runtime state is stored under ~/.ess/:
~/.ess/ess.dbSQLite database~/.ess/index/Tantivy index directory
Installer-created template config file (~/.ess/config.toml):
[general]
default_scope = "all"
[accounts]
# [accounts.work]
# account_id = "you@company.com"
# email = "you@company.com"
# type = "professional"
# tenant_id = "your-tenant-id"Graph sync credentials are read from environment variables or account config JSON:
ESS_TENANT_IDESS_CLIENT_IDESS_CLIENT_SECRET- Optional overrides:
ESS_GRAPH_TOKEN_URLESS_GRAPH_API_BASE
Add multiple accounts:
ess accounts add work@company.com professional --tenant-id <tenant-id>
ess accounts add personal@gmail.com personalRun commands across all accounts or target one:
ess sync
ess sync --account work@company.com
ess search "invoice" --scope pro
ess list --scope personalWhen populating ESS for the first time with real email data:
Run syncs sequentially, one account at a time. The Tantivy search index only supports one writer process. Running two ess sync commands concurrently will cause the second to fail with an index lock error.
# Correct: sequential
ess sync --account work@company.com
ess sync --account personal@gmail.com
# Wrong: concurrent (will fail)
ess sync --account work@company.com &
ess sync --account personal@gmail.com & # index lock errorGmail initial syncs are slow for large mailboxes. The Gmail API requires one HTTP request per message during full sync. A mailbox with 20,000 emails will take a while. ESS refreshes the OAuth token automatically during long syncs, so token expiry is handled. Monitor progress with:
ess stats --json # check email counts while sync runsInterrupted syncs are safe to restart. SQLite upserts prevent duplicate emails. If a sync fails partway through, simply re-run it. The sync will re-enumerate messages but skip those already stored. However, for Gmail, the historyId watermark isn't saved until the full sync completes, so restarts redo the full messages.list enumeration.
Per-account credentials go in --config JSON. When different accounts use different OAuth apps (e.g., two Gmail accounts from different Google Cloud projects), pass per-account credentials via --config:
ess accounts add user@gmail.com personal \
--config '{"connector":"gmail_api","client_id":"...","client_secret":"...","refresh_token":"..."}'Credentials are stored in the local SQLite database (~/.ess/ess.db), never committed to git.
Rebuild the index after problems. If a sync was killed mid-write or the index shows corruption (merge errors, missing segments), rebuild from SQLite:
rm -rf ~/.ess/index
ess reindexSQLite is the source of truth. The index can always be rebuilt.
After the initial load completes:
Delta syncs are fast. Gmail uses historyId and Graph uses delta tokens for incremental sync. Only new/changed/deleted messages are fetched. A typical delta sync takes seconds.
Graph keeps one delta token per folder. This prevents cross-folder cursor conflicts and allows inbox/sent/archive/drafts/trash/spam to advance independently.
Use --watch for automatic periodic syncing:
ess sync --watch # polls for changes on a timerNever run multiple sync processes simultaneously. The Tantivy index writer is exclusive. Use ess sync (no --account flag) to sync all accounts sequentially in one process.
If search results seem stale or incomplete, rebuild the index:
ess reindex # rebuilds from SQLite, no data lossExpired delta tokens trigger automatic fallback. If a Gmail historyId or Graph delta token expires (too long between syncs), ESS falls back to a full sync automatically. A warning is logged but no manual intervention is needed.
Index sizing: Expect roughly 0.3-0.5 GB of index per 1,000 emails (varies with email body sizes). A 20K email corpus produces a ~6-9 GB Tantivy index.
--scope controls account-type filtering:
all: no account-type filterpro: onlyprofessionalaccountspersonal: onlypersonalaccounts
Examples:
ess search "travel" --scope personal
ess list --scope pro --unread
ess stats --scope all +--------------------+
| Graph API / Gmail |
| API / JSON |
| Connectors |
+---------+----------+
|
v
+-------------+ upsert/query +------------------+
| CLI / +------------------->| SQLite (~/.ess) |
| MCP Server | | canonical store |
+------+------+ +---------+--------+
| |
| search/reindex | reindex/source-of-truth
v v
+------+------------------------------+ +--------------------------+
| Tantivy index (~/.ess/index) | | Contacts + sync_state |
| subject/from/body full-text search | | account stats/state keys |
+-------------------------------------+ +--------------------------+
Primary modules:
src/main.rs: CLI dispatchsrc/connectors/: Graph API, Gmail API, and JSON import connectorssrc/db/: SQLite models, schema, query APIssrc/indexer/: Tantivy indexing and searchsrc/mcp/: MCP stdio server and tools
- ESM (Email Search Memory) — pattern engine companion. ESM extracts communication patterns from ESS search results and uses them to improve outbound email. Use
esm reflectto mine patterns from ESS data, andesm contextto get drafting guidance before writing.
cargo fmt
cargo clippy --all-targets --all-features -- -D warnings
cargo test
cargo run -- --help- Run unit + integration tests.
- Verify CLI text and
--jsonmode. - Validate MCP
initialize,tools/list, and at least onetools/call. - For search/index changes, run
ess reindexand a smoke search.
- Keep logging on stderr so JSON outputs remain parseable.
- Preserve UTF-8-safe snippet handling in search formatting.
- The
.gitignoreincludes common build and runtime artifacts you should expect when working with the repo. Review it when setting up your environment.