{
  "markdown": "# mailindex-mcp\n\n[![release](https://img.shields.io/github/v/release/alexanderkrauck/mailindex-mcp?sort=semver)](https://github.com/alexanderkrauck/mailindex-mcp/releases)\n[![image](https://img.shields.io/badge/ghcr.io-mailindex--mcp-blue?logo=docker&logoColor=white)](https://github.com/alexanderkrauck/mailindex-mcp/pkgs/container/mailindex-mcp)\n[![CI](https://github.com/alexanderkrauck/mailindex-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/alexanderkrauck/mailindex-mcp/actions/workflows/ci.yml)\n[![licence: MIT](https://img.shields.io/badge/licence-MIT-green)](LICENSE)\n[![MCP](https://img.shields.io/badge/MCP-19%20tools-8A2BE2)](#mcp-tools)\n\n**A mail client for an AI agent. Everything you can do in Thunderbird — search,\nmove, mark, delete, draft — over your own index, on your own machine.**\n\nMost email MCP servers forward each question straight to IMAP. That answers \"show\nme my last 10 messages\" and falls apart on everything else: `SEARCH` is\ninconsistent between providers, it cannot see inside attachments, it never tells\nthe model whether it actually searched everything, and it cannot do a thing about\nwhat it finds.\n\nSo when you ask an assistant to clear eight thousand newsletters out of your\ninbox, it tells you that would be eight thousand tool calls and suggests you go\nand do it by hand in the web interface.\n\nThis one keeps its own copy of your mail and acts on it:\n\n```\ndelete_mail(account_id=3, participants=[\"newsletter@example.com\", ...])\n→ matched: 4147, affected: 4147, to: \"[Google Mail]/Bin\"\n```\n\nOne call. One connection. Six seconds. That is the difference between a search\nbox and a mail client.\n\n![Claude answering a question about a mailbox through this server](docs/demo.gif)\n\n<sub>A real question against a live index of about 59,000 messages across six\naccounts. One string is blurred: a case number belonging to a real filing.</sub>\n\n## Why this instead of the other email MCP servers\n\n|  | This project | Typical email MCP |\n|---|---|---|\n| Can it change anything | Move, mark, delete, draft, manage folders — in bulk, by search | Read-only, or one message per call |\n| Search | Own PostgreSQL index, GIN full-text, stemmed across languages | Live IMAP `SEARCH` per call |\n| Result completeness | Exact `total_count`, signed cursors, per-account coverage | Whatever the folder returned |\n| Attachments | Text extracted and indexed at sync time (PDF, DOCX, XLSX, PPTX, OCR) | Base64 into the model's context, or not at all |\n| Attachment binaries | Never stored; refetched through a 5-minute signed URL | Stored on disk or inlined |\n| Transport | Remote HTTP endpoint | Local stdio process on your machine |\n| Setup on the client | Paste a URL | Install a runtime, edit config JSON, store credentials locally |\n| Users | Multi-tenant, every row owner-scoped | Single user |\n| Tool surface | 19 tools, all annotated | Frequently 40+ |\n\n**What it costs you, stated up front.** You run PostgreSQL and a container. The\nindex is about **28 MB per 1,000 messages** — a 50,000-message archive is roughly\n1.5 GB — and the image is 1.25 GB because it carries OCR language data. The first\nsync downloads every message once; search works on what has arrived while the\nrest continues in the background, and each account reports its own coverage so\nthe model knows what it has not seen yet.\n\nIn exchange your assistant can answer questions about mail from years ago,\nincluding text inside attachments, and then act on the answer. None of it leaves\nyour machine.\n\n## Quickstart\n\nFive minutes, local only, no accounts to create anywhere.\n\n```bash\ngit clone https://github.com/alexanderkrauck/mailindex-mcp.git\ncd mailindex-mcp\ncp .env.example .env\ndocker compose up -d\n```\n\nEvery compose file here pulls\n[`ghcr.io/alexanderkrauck/mailindex-mcp`](https://github.com/alexanderkrauck/mailindex-mcp/pkgs/container/mailindex-mcp)\n— amd64 and arm64, so a Raspberry Pi or an Apple Silicon machine works the same\nway. Append `--build` to any of them to compile it yourself instead; expect\nseveral minutes, because the image carries OCR language data.\n\n**Pin a version** for anything you care about. This is pre-1.0: the schema\nchanges between releases and migrations run automatically on start, so an\nunpinned `latest` can migrate your database the moment you restart.\n\n```bash\nMAILINDEX_IMAGE=ghcr.io/alexanderkrauck/mailindex-mcp:0.1.0 docker compose up -d\n```\n\n`MAILINDEX_IMAGE` works with all three compose files, and belongs in your `.env`\nrather than on the command line. Note the image tag has no `v` — the git tag is\n`v0.1.0`, the image is `0.1.0`, and `0.1` follows the latest patch of that minor\nversion.\n\nCheck it came up:\n\n```bash\ncurl http://localhost:8002/api/v1/health\n```\n\nThe default `development` mode is **unauthenticated** and Docker binds it to\n`127.0.0.1` only. It is meant for exactly this: trying the thing out on your own\nmachine.\n\n### Connect your AI client\n\nDo this before connecting a mailbox — it is how you connect one.\n\n```bash\nclaude mcp add --transport http mail http://localhost:8002/mcp\n```\n\nFor other clients, point them at `http://localhost:8002/mcp` over streamable HTTP.\nThen ask something your inbox search would struggle with — a phrase inside a PDF\nsomeone sent you three years ago works well.\n\n### Connect a mailbox\n\nJust ask your client — `add_mail_account` is one of the tools:\n\n> Connect my mailbox you@example.com, IMAP imap.example.com, SMTP\n> smtp.example.com\n\n**Do not give it the password.** Asked without one, it hands you back a\nshort-lived URL to a form that asks for the password alone and sends it from\nyour browser straight to the server. It never passes through the model, never\nlands in the conversation transcript your AI provider keeps, and it is the only\nway that works with clients such as ChatGPT that refuse to transmit secrets.\n\nUse an **app password** from your provider's security settings, never your\naccount login password. For Gmail, ask it to start the Gmail OAuth flow instead:\nthat uses the Gmail API and survives label changes better.\n\n<details>\n<summary>Or over HTTP, if you prefer a shell</summary>\n\n```bash\ncurl -X POST http://localhost:8002/api/v1/accounts \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"name\": \"personal\",\n    \"account_name\": \"you@example.com\",\n    \"username\": \"you@example.com\",\n    \"password\": \"your-app-password\",\n    \"host\": \"imap.example.com\",\n    \"port\": 993,\n    \"smtp_host\": \"smtp.example.com\",\n    \"smtp_port\": 465\n  }'\n```\n\n</details>\n\nSynchronization starts on its own and runs in the background. Search works on\nwhat has arrived already — ask **\"how much of my mail have you indexed so far?\"**\nand it will tell you exactly, per account, because every search reports its own\ncoverage rather than pretending to be complete.\n\n## Which setup do I need?\n\nFour ways people arrive at this, and the shortest honest path for each.\n\n### \"I want to see if this is real\" — 5 minutes, your laptop\n\nEverything runs locally, nothing to sign up for.\n\n```bash\ngit clone https://github.com/alexanderkrauck/mailindex-mcp.git\ncd mailindex-mcp && cp .env.example .env\ndocker compose up -d          # pulls the published image\nclaude mcp add --transport http mail http://localhost:8002/mcp\n```\n\nAdd a mailbox with an app password (see below), wait for it to sync, then ask\nyour client something your inbox search would lose. **What you need:** Docker,\nand an app password from your provider. **What to expect:** the first build\ncompiles psycopg2 and pulls ~130 MB of OCR language data, so it takes minutes,\nnot seconds. Auth is off and Docker binds to `127.0.0.1` only — fine here,\nnever expose it.\n\n### \"I want this on my phone and laptop, every day\" — one person, one server\n\nYou need a small VPS and a domain. Caddy gets the TLS certificate for you.\n\n```bash\ncat > .env <<'ENV'\nEMAILSERVER_DOMAIN=mail.example.com\nPOSTGRES_PASSWORD=...\nEMAILSERVER_API_TOKEN=...\nCREDENTIAL_ENCRYPTION_KEY=...\nSESSION_SECRET=...\nENV\ndocker compose -f docker-compose.single-user.yml up -d\n```\n\nEvery value from `openssl rand -base64 32`; the API token must be at least 32\ncharacters or the server refuses to start. **What you need:** a VPS with ~2 GB\nRAM and disk for roughly 25 MB per 1,000 messages, a domain, and an app password\nper mailbox. **What to expect:** works with anything that sends an\n`Authorization` header — Claude Code, Cursor, `mcp-remote`. It will *not* work\nwith the claude.ai or ChatGPT web connectors, which negotiate OAuth and cannot\nsend a static token. If you want those, use the next one.\n\n### \"My family/team should each have their own\" — a few people\n\nGoogle OAuth, so each person signs in as themselves and sees only their own\nmailboxes.\n\n```bash\ndocker compose -f docker-compose.production.yml up -d\n```\n\n**What you need:** everything above, plus a Google Cloud OAuth **Web\napplication** with the two redirect URIs listed under [Deployment\nmodes](#deployment-modes). **What to expect:** an unverified-app warning until\nyou submit the consent screen, and a 100-user cap while unverified — neither\nmatters at this size.\n\n> **Set `REGISTRATION_MODE=allowlist`.** With `open`, any Google account on\n> earth can register on your server and attach mailboxes, and\n> `ALLOWED_GOOGLE_EMAILS` is never read. Tenant isolation still keeps strangers\n> out of *your* mail, but they get an account on your box. This is the easiest\n> thing to get wrong on a public host.\n\n### \"Could this be internal infrastructure?\" — 500-person company\n\nHonestly: not yet, and here is exactly what is missing rather than a maybe.\n\n**What works today.** Run it for one team as a pilot using the Google setup\nabove. Multi-tenancy is real and enforced at the query level — every row is\nowner-scoped, and ownership comes from the authenticated token rather than\nanything a caller supplies. Mailbox credentials are encrypted at rest and never\nreturned. Attachment binaries are never stored.\n\n**What blocks a company-wide rollout.**\n\n| Gap | Why it matters |\n|---|---|\n| Login is Google OIDC only | No Entra ID, Okta, generic OIDC or SAML. If your directory is not Google, nobody can sign in. |\n| No provisioning or deprovisioning | Users self-register; there is no SCIM, no group mapping, and no way to revoke someone's index when they leave. |\n| No audit export | Sends are audited; reads, searches and mailbox writes are not, which most compliance reviews ask about. |\n| Single Postgres, single container | No HA, no read replicas, no horizontal sync workers. Backup and restore is your `pg_dump`. |\n| Attachment text sits in the database | Retention and legal hold are whatever you build. |\n\nIf you are that person: the pilot is genuinely worth running, and the honest\npitch internally is \"a search index over our own mail, on our own hardware, that\nan assistant can use\" — not \"an approved platform\".\n\n### Mailbox credentials, whichever setup you pick\n\nSigning in to this server grants access to nothing. Each mailbox is connected\nseparately:\n\n- **Gmail** — either an app password over IMAP, or the Gmail OAuth flow via\n  `begin_gmail_connection`, which uses the Gmail API instead and survives label\n  changes better.\n- **Everything else** — an app password from the provider's security settings.\n  Never your login password.\n\n## Deployment modes\n\n`EMAILSERVER_AUTH_MODE` picks how callers are authenticated. Mailbox credentials are\nalways separate from this.\n\n| Mode | Who can call it | What you need | Works with |\n|---|---|---|---|\n| `development` | anyone on loopback | nothing | local clients only |\n| `single_user` | one owner with a static bearer token | a token, a domain | Claude Code, Cursor, `mcp-remote`, anything that sends a header |\n| `google` | multiple users via Google OAuth + dynamic client registration | a Google Cloud OAuth client, a domain | Claude.ai and ChatGPT web connectors, plus all of the above |\n\n### Production, single user, no Google project\n\nThe shortest path to a real deployment. Caddy obtains and renews TLS.\n\n```bash\ncat > .env <<'ENV'\nEMAILSERVER_DOMAIN=mail.example.com\nPOSTGRES_PASSWORD=...\nEMAILSERVER_API_TOKEN=...\nCREDENTIAL_ENCRYPTION_KEY=...\nSESSION_SECRET=...\nENV\n\ndocker compose -f docker-compose.single-user.yml up -d\n```\n\nGenerate each secret with `openssl rand -base64 32`. `EMAILSERVER_API_TOKEN` must be\nat least 32 characters; the server refuses to start otherwise.\n\n```bash\nclaude mcp add --transport http mail https://mail.example.com/mcp \\\n  --header \"Authorization: Bearer $EMAILSERVER_API_TOKEN\"\n```\n\n### Production, multiple users, Google OAuth\n\nNeeded if you want Claude.ai or ChatGPT web connectors, which negotiate OAuth and\ncannot send a static header.\n\nCreate a Google OAuth **Web application** and configure:\n\n- Authorized JavaScript origin: `https://mail.example.com`\n- Authorized redirect URIs:\n  - `https://mail.example.com/auth/callback`\n  - `https://mail.example.com/api/v1/accounts/gmail/callback`\n\nDo **not** add the AI vendor's callback to the Google client. The MCP server allows\n`https://claude.ai/api/mcp/auth_callback` and `https://chatgpt.com/connector/oauth/*`\nitself, then redirects the completed authorization back to the client.\n\n```bash\ncat > .env <<'ENV'\nEMAILSERVER_DOMAIN=mail.example.com\nPOSTGRES_PASSWORD=...\nGOOGLE_CLIENT_ID=123.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=...\nJWT_SIGNING_KEY=...\nCREDENTIAL_ENCRYPTION_KEY=...\nSESSION_SECRET=...\nREGISTRATION_MODE=allowlist\nALLOWED_GOOGLE_EMAILS=[\"owner@example.com\"]\nENV\n\ndocker compose -f docker-compose.production.yml up -d\n```\n\nAdd `https://mail.example.com/mcp` in the client. It receives an OAuth challenge,\ndiscovers the authorization metadata, registers its callback, and sends the user\nthrough Google.\n\nTwo things to expect from Google: an unverified-app warning until you submit the\nconsent screen for review, and a 100-user cap while unverified. Neither matters for a\npersonal or family deployment.\n\nUpgrading an existing single-owner installation: set\n`CLAIM_LEGACY_ACCOUNTS_ON_FIRST_LOGIN=true`, allowlist exactly the intended owner, let\nthat user log in once to claim the existing accounts, then set it back to `false`.\n\n## Security model\n\n- Google OpenID Connect identifies an application user by the stable `sub` claim.\n- Each user owns multiple Gmail, Zoho, or generic IMAP/SMTP accounts.\n- Mailbox credentials are separate from login identity and encrypted at rest.\n- MCP derives ownership from the authenticated token; callers never supply an owner ID.\n- Mailbox passwords are write-only tool and API inputs, and are never returned.\n- Original attachment binaries are not stored. A signed URL refetches them on demand.\n- Development mode is unauthenticated and must remain bound to loopback.\n\nSigning in does not grant access to any mailbox. Gmail is connected through a separate\nGmail OAuth consent flow; Zoho and generic IMAP use provider app passwords.\n\n## MCP tools\n\nNineteen tools, each annotated with read-only, destructive and open-world hints.\nIt is a mail client, not a search box: everything you can do in Thunderbird you\ncan do here, over an index instead of a folder listing.\n\n| Tool | |\n|---|---|\n| `list_mail_accounts` | accounts, non-secret settings, exact stored message counts |\n| `add_mail_account` | add an IMAP/SMTP mailbox |\n| `update_mail_account` | change one mailbox's settings |\n| `begin_mail_account_password_setup` | short-lived password-only browser form |\n| `begin_gmail_connection` | five-minute signed URL for Google consent |\n| `search_mail` | exhaustive lexical search |\n| `search_mail_regex` | bounded regex search |\n| `get_mail` | one message, bounded body |\n| `get_thread` | reconstructed thread with confidence |\n| `get_attachment` | metadata, extracted text, expiring download URL |\n| `send_mail` | send or reply, with owned attachments |\n| `list_mail_folders` | folders of one mailbox, with declared roles and indexed counts |\n| `create_mail_folder` | create and subscribe to a folder |\n| `rename_mail_folder` | rename a folder, keeping its mail and children |\n| `delete_mail_folder` | remove a folder, emptying it into Trash first |\n| `mark_mail` | set or clear read and flagged state, in bulk |\n| `move_mail` | move mail to another folder, in bulk |\n| `delete_mail` | move to Trash, in bulk; `permanent` only from Trash |\n| `save_draft` | write a draft into the mailbox's Drafts folder |\n\nStandalone connection tests and manual sync are deliberately outside the MCP\nsurface.\n\n**Bulk.** `mark_mail`, `move_mail` and `delete_mail` select messages the same way\n`search_mail` does — pass `email_ids`, or the same filters to act on everything\nthat matches. One call opens one connection and issues one command per folder, so\nclearing 8,000 newsletters is one call rather than 8,000. Each response reports\n`matched` against `affected`, and sets `truncated` when a limit cut the work\nshort, so a partial batch is never mistaken for a finished one. A call with\nneither ids nor filters is refused rather than treated as \"the whole mailbox\".\n\n**Gmail.** Gmail has labels, not folders, so a single location is projected from\nthem by precedence — `TRASH > SPAM > DRAFT > INBOX > SENT`, and `ARCHIVE` for a\nmessage carrying none of those. That projection is what makes folder-scoped\nsearch, Trash exclusion and writes work identically across providers: a Gmail\nmessage is addressed by its provider id rather than a UID, and moving it means\nadding one label and removing the one it came from. `SENT` and `DRAFT` are\nGmail's to assign and are never removed. Folder creation, renaming and deletion\nare refused there, with the reason.\n\n**Writes.** Every write goes to the mailbox first and is only recorded locally\nonce the server confirms it, so the index never claims a change that did not\nhappen. They hold the same lease the synchronizer uses, keyed on\n`(host, port, username)` rather than on an account row, because two accounts can\nname one physical mailbox and an untagged `EXPUNGE` landing during a folder\ncensus renumbers the sequence numbers that census is reading. Writes address the\nlive copy of a message rather than one sitting in Trash. `delete_mail` moves to\nTrash and needs a second, explicit call to destroy anything.\n\n**Passwords.** `add_mail_account` and `update_mail_account` take an optional\nwrite-only password. When a client will not transmit secrets, omit it and open the\nreturned setup URL, or call `begin_mail_account_password_setup`; the linked form asks\nonly for the password. A failed connection test keeps both the configuration and the\nencrypted credential, so settings can be corrected without re-entering it.\n\n**Search.** `search_mail` and `search_mail_regex` return `total_count`, `raw_count`,\n`returned_count`, `has_more`, and a signed `next_cursor`. Reuse the same filters with\n`next_cursor` until `has_more` is false for an exhaustive result. Deduplication\ndefaults to `exact`, which groups equal RFC `Message-ID` values while retaining every\nsource account and message ID; `mirror` also groups normalized body copies; `none`\nreturns every stored row. Responses additionally report matching fields,\nparticipant-domain facets, and per-account sync coverage.\n\n**Stemming.** `match` defaults to `stemmed`, which finds inflected forms of a\nword: on a real 52,000-message mailbox `invoices` goes from 104 hits to 1,016 and\n`Verträge` from 133 to 1,168. The cost is precision — `meeting` also matches\n`meet` — so pass `match=\"exact\"` for order numbers, identifiers and surnames,\nwhich a stemmer would widen. Both modes are indexed; every response reports which\none ran.\n\n**Read state.** `is_unread`, `is_flagged` and `is_answered` filter on flags\nmirrored from the provider. They are tri-state: a message whose provider never\nreported flags matches neither `true` nor `false`, and search returns a\n`FLAG_STATE_UNKNOWN` warning saying how many messages that removed, rather than\nquietly reporting them as read. Gmail publishes no answered label, so\n`is_answered` is always unknown for Gmail OAuth accounts. Flags are refreshed by\nthe periodic reconciler, so they lag the mailbox by up to\n`EMAILSERVER_DELETION_RECONCILE_INTERVAL`.\n\n`get_mail` and `get_thread` return bounded plain text by default; HTML must be\nrequested explicitly. `send_mail` accepts owned `attachment_ids` and refetches each\noriginal binary from its provider before sending.\n\n## HTTP API\n\n`GET /api/v1/docs` serves the OpenAPI browser. Every account, message, attachment,\nsync and send lookup is owner-scoped.\n\n```text\nGET    /api/v1/me\nGET    /api/v1/accounts\nPOST   /api/v1/accounts\nGET    /api/v1/accounts/{id}\nPATCH  /api/v1/accounts/{id}\nDELETE /api/v1/accounts/{id}\nPOST   /api/v1/accounts/{id}/test\nPOST   /api/v1/accounts/{id}/sync\nGET    /api/v1/accounts/gmail/connect\nGET    /api/v1/emails/search\nGET    /api/v1/emails/search/regex\nGET    /api/v1/emails/{id}\nGET    /api/v1/attachments/{id}\nPOST   /api/v1/send\n```\n\n## How synchronization works\n\nThe parts that make search trustworthy rather than best-effort:\n\n- Alembic applies versioned, data-preserving migrations on startup.\n- Message identity is the normalised RFC `Message-ID`, which travels with the\n  message. Location lives separately in `message_placements`, one row per folder,\n  so moving a message upstream relocates it instead of deleting and re-creating\n  it. Gmail API messages keep the provider's own id, which already survives a\n  label change.\n- IMAP cursors persist UID, UIDVALIDITY and folder. Backfills run oldest-first and are\n  bounded per cycle. A cursor advances only after its batch commits, and a UIDVALIDITY\n  change resets just that folder.\n- Gmail OAuth accounts use `messages.list`/`get` for resumable backfill and\n  `history.list` for incremental change. An expired history ID triggers a\n  generation-marked full sync before upstream deletions are reconciled.\n- Periodic metadata-only reconciliation mirrors flags and upstream deletions, with a\n  durable checkpoint so a restart does not force a full rescan.\n- Account work is bounded by a global concurrency limit and protected by expiring\n  database leases, so two workers cannot sync one mailbox and a crashed worker cannot\n  hold a permanent lock.\n- PostgreSQL GIN indexes back lexical body and attachment search. The same text\n  is indexed once per configured language and stored as one combined vector, so\n  a German invoice and an English newsletter are both stemmed correctly in the\n  same mailbox; identical lexemes collapse, so the union costs about as much as\n  the unstemmed index it sits beside. `EMAILSERVER_SEARCH_TEXT_CONFIGS` selects\n  the languages and defaults to `[\"simple\", \"english\", \"german\"]`. Changing it\n  needs a matching index, which the migration only builds once:\n\n  ```sql\n  CREATE INDEX CONCURRENTLY ix_email_logs_search_fts_simple_english_french\n    ON email_logs USING gin ((\n        to_tsvector('simple',  coalesce(sender,'')||' '||coalesce(recipient,'')||' '||coalesce(subject,'')||' '||coalesce(body_plain,''))\n     || to_tsvector('english', coalesce(sender,'')||' '||coalesce(recipient,'')||' '||coalesce(subject,'')||' '||coalesce(body_plain,''))\n     || to_tsvector('french',  coalesce(sender,'')||' '||coalesce(recipient,'')||' '||coalesce(subject,'')||' '||coalesce(body_plain,''))\n    ));\n  ```\n\n  Without it search still returns the same answer, by scanning every stored body.\n- Provider flags are normalised at sync time into `is_unread`, `is_flagged` and\n  `is_answered`. IMAP reports that a message *was read* and the Gmail API reports\n  that it *was not*, in two different encodings; neither is filterable as stored.\n  A message the provider never reported flags for stays null rather than\n  defaulting to read.\n- Attachment text is extracted at sync time. Image attachments are OCR'd in\n  every language installed in the image, because tesseract given no language\n  assumes English and mangles accented scripts: German umlauts come back as\n  `dirfen Fuboden` instead of `dürfen Fußboden`, so the text is indexed but can\n  never be found by searching the words on the page. The image ships\n  `eng deu fra ita spa nld por`; add or trim with\n  `docker build --build-arg TESSERACT_LANGS=\"eng deu jpn\"`, and pin a subset at\n  runtime with `EMAILSERVER_OCR_LANGUAGES=deu+eng` to trade coverage for speed.\n- A `Date` header that parses but is implausible, such as a year of 2611, is\n  discarded rather than stored, because search sorts and paginates on that\n  column. Gmail falls back to the provider timestamp; IMAP leaves it null.\n  `scripts/repair_email_dates.py` clears values written before this check.\n- Regex search is separate and bounded by scope, pattern, result and statement time.\n- Sends support idempotency keys and append an owner-scoped audit record.\n\n## Development\n\nThe test suite is self-contained. It uses in-memory SQLite and a temporary data\ndirectory, so it needs no PostgreSQL, no Docker and no network.\n\n```bash\npython -m venv .venv && . .venv/bin/activate\npip install -r requirements.txt\npytest -q\nruff check .\n```\n\nIt covers tenant isolation, tool exposure and annotations, static-token and OAuth\nauthentication, encryption, token tampering, attachment limits, IMAP cursor behavior,\nGmail history behavior, and OAuth challenge metadata.\n\n`scripts/verify_postgres_search.py` is a different thing: a read-only smoke test that\nruns against a **populated** deployment to confirm exhaustive search, dedup, facets and\nregex behave on real data.\n\n```bash\ndocker compose exec email-server python -m scripts.verify_postgres_search\n```\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n\n## Contributing and security\n\n- [CONTRIBUTING.md](CONTRIBUTING.md) — how to run it, and what this project cares\n  about enough to argue with you over in review.\n- [SECURITY.md](SECURITY.md) — what is protected, what is not, and where to report\n  a vulnerability privately.\n- [CHANGELOG.md](CHANGELOG.md) — what changed between releases.\n",
  "bytes": 25816,
  "sha": "4c47d8d502764e91e855e61b9fc228698b4480d7af727223cdac4d3847f5ead8",
  "repo_slug": "alexanderkrauck/mailindex-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_alexanderkrauck_mailindex_mcp_c48b7862/readme"
}