Mail and contacts on a self-hosted Stalwart server via JMAP: search, read, OCR, send, drafts.
An MCP server that lets Claude Desktop (or any MCP client that speaks stdio) work with a mailbox on your own Stalwart mail server: search and read mail including attachments and scans, reply in a thread, send, keep drafts, and look up or add contacts.
It talks JMAP with the mailbox's own credentials. Nothing is installed on the server.
Claude Desktop ──stdio──▶ dist/index.cjs (Node, this MCP server)
│ HTTPS · JMAP (RFC 8620 / 8621 / 9610)
▼
https://mail.example.com/jmap (reverse proxy → Stalwart)
There is also a remote mode: the same server run next to Stalwart, added to Claude as a custom connector, so the mailbox works in claude.ai, the mobile apps and every desktop chat — people sign in through Stalwart's own OAuth and the server holds no credentials. See docs/remote.md.
How it fits into a small self-hosted setup — reverse proxy, what to expose, shared mailboxes, branded builds for a family or a team — is described in docs/small-infrastructure.md.
Names carry a prefix, mail_ by default (a branded build can change it).
| Tool | What it does |
|---|---|
mail_list_mailboxes | accounts (own + shared), folders with counts, allowed senders, address books |
mail_search_emails | full text / from / to / subject / folder / date / unread / has attachment, or a whole thread |
mail_get_email | a whole message by id (HTML → text) with a numbered list of attachments |
mail_get_attachment | an attachment's content: text, PDF page by page, OCR of scans and photographed documents, images; saves the file to disk |
mail_send_email | send a new mail or a reply (in_reply_to_id, reply_all), attachments from disk |
mail_create_draft | the same, but only saved to Drafts |
mail_send_draft / mail_delete_draft | send / delete a draft by id |
mail_search_contacts | address books of every account plus senders and recipients from the mail history |
mail_add_contact | new contact (own or shared address book) |
Sending is immediate and cannot be undone, so the tool descriptions tell the model to send only on the user's explicit instruction and to create a draft otherwise.
Download stalwart-mail.mcpb from the
latest release and open it —
Claude Desktop offers to install it. Or build it yourself:
npm install
./pack.sh # → stalwart-mail.mcpb
open stalwart-mail.mcpb # Claude Desktop → Install
Fill in the server address, the mailbox e-mail and password. Optional: the language and a Mistral API key for OCR. The password is kept in the operating system's keychain.
The server is on npm as stalwart-mail-mcp
and in the MCP Registry as
io.github.cybersmurf/stalwart-mail-mcp, so no checkout is needed:
{
"mcpServers": {
"stalwart-mail": {
"command": "npx",
"args": ["-y", "stalwart-mail-mcp"],
"env": {
"STALWART_URL": "https://mail.example.com",
"STALWART_USER": "jane@example.com",
"STALWART_PASSWORD": "…"
}
}
}
}
Claude Code: claude mcp add stalwart-mail --env STALWART_URL=https://mail.example.com --env STALWART_USER=jane@example.com --env STALWART_PASSWORD=… -- npx -y stalwart-mail-mcp
Run the server next to Stalwart (stalwart-mail-mcp --http, or the Docker image) and add its
URL as a custom connector — step by step in docs/remote.md.
| Variable | Meaning |
|---|---|
STALWART_URL | public address of the server, e.g. https://mail.example.com (required) |
STALWART_USER | the mailbox you sign in as (required) |
STALWART_PASSWORD | mailbox or app password — sent as Basic auth |
STALWART_TOKEN | an OAuth access token instead of the password — sent as Bearer |
MAIL_LANG | auto (default: the machine's language, English when unsupported) or a locale code |
MAIL_TOOL_PREFIX | prefix of the tool names, default mail |
MAIL_BRAND | display name of the server, default Stalwart Mail |
MAIL_DOWNLOAD_DIR | where attachments are saved, default ~/Downloads/Mail-Attachments |
MAIL_TIMEZONE | IANA zone for dates in the output, default the machine's zone |
MAIL_OCR_PROVIDER, MAIL_OCR_API_KEY, MAIL_OCR_MODEL, MAIL_OCR_BASE_URL | who reads scans — see OCR providers |
MISTRAL_API_KEY | shortcut: with only this set, scans go to Mistral OCR |
MAIL_ALLOW_SEND | false removes send_email and send_draft |
MAIL_ALLOW_DRAFTS | false removes create_draft and delete_draft |
MAIL_ALLOW_CONTACT_EDIT | false removes add_contact |
MAIL_ALLOW_ATTACHMENTS | false removes get_attachment |
MAIL_SAVE_ATTACHMENTS | false = opened attachments are only read, nothing is written to disk |
Every capability is a switch in the extension settings (or an env variable above), all on by default. A capability that is off is not offered as a tool at all, so it holds regardless of what the client's approval prompts remember:
get_attachment reads the file from a temporary copy and is
annotated read-only; with saving on it writes to the download folder and is annotated as a
writing tool, which clients may treat differently when asking for approval.Approvals themselves ("allow once / always allow") belong to the client, not to this server. In Claude Desktop they are set per tool in the extension's settings; a client may ask again after an update that changes a tool's definition.
mail_get_attachment downloads the file and returns what the model can read:
page_from / page_to for long ones);(OCR). A mixed PDF
sends only its scanned pages;sips);Without a provider, or with ocr: false, a scan comes back as an image of page 1 (macOS) with
a note. OCR is the only thing in this server that sends content anywhere besides your mail
server — to the provider you picked, or nowhere at all with a local model.
MAIL_OCR_PROVIDER | What it is | Needs | Reads PDFs |
|---|---|---|---|
auto (default) | Mistral when MISTRAL_API_KEY is set, a custom server when address and model are set, otherwise off | — | — |
mistral | Mistral OCR (mistral-ocr-latest) | key | directly |
anthropic | Claude through the official SDK (default model claude-opus-5-5) | key | directly |
openai, openrouter, gemini | hosted OpenAI-compatible vision chat APIs | key + model | page images |
ollama, lmstudio | local models on localhost | model | page images |
custom | any other OpenAI-compatible server | MAIL_OCR_BASE_URL + model | page images |
off | no OCR | — | — |
MAIL_OCR_API_KEY, MAIL_OCR_MODEL and MAIL_OCR_BASE_URL complete the choice. Examples:
MAIL_OCR_PROVIDER=ollama MAIL_OCR_MODEL=llama3.2-vision # fully local
MAIL_OCR_PROVIDER=openrouter MAIL_OCR_API_KEY=… MAIL_OCR_MODEL=<a vision model>
MAIL_OCR_PROVIDER=anthropic MAIL_OCR_API_KEY=…
MAIL_OCR_PROVIDER=custom MAIL_OCR_BASE_URL=http://nas.lan:8000/v1 MAIL_OCR_MODEL=…
Providers that take images only get each scanned page as a PNG taken out of the PDF (the scan itself, scaled to 2000 px). A page that is not one big picture cannot be handed to them and is reported as unread; Mistral and Anthropic read any PDF. Pages are sent three at a time.
Things to expect: a local model can need a minute or more per dense page, which may exceed
your client's tool timeout — read long scans in page ranges. General vision models transcribe
well but, like every OCR, can misplace cells in tables with graphics; preview: true adds the
page image so the model can check. With anthropic, a declined request is retried
server-side on a fallback model (fallbacks: "default") on the current Claude models.
node test/live-ocr.mjs runs the provider configured in the environment against a scanned
fixture (or your own file) and prints the result.
Tool titles, descriptions, output and error messages are localized. English is the source, Czech is written by hand, and German, Spanish, French, Italian, Dutch, Polish, Portuguese and Slovak are machine translations that no native speaker has reviewed yet — corrections are welcome.
To add or fix a language edit src/locales/<code>.ts (copy en.ts, keep the {placeholders}
and line breaks) and register it in src/locales/index.ts. A locale may be partial; missing
keys fall back to English. npm run test:offline checks every locale against the English keys.
For a family or a team you can ship an extension where the server address is pre-filled and
people only type their e-mail and password. A preset is a folder with its own manifest.json
(and optionally icon.png); see presets/example.
./pack.sh --preset /path/to/preset # → /path/to/preset/<name>.mcpb
The preset's manifest sets MAIL_TOOL_PREFIX, MAIL_BRAND, MAIL_LANG or
MAIL_DOWNLOAD_DIR through env, and gives server_url a default. Keep the preset's name
stable so Claude Desktop treats new builds as updates.
npm run test:offline # no mailbox needed: fake JMAP + fake OCR, the real server over stdio
MISTRAL_API_KEY=… node test/offline.mjs --live-ocr # also sends the fixtures to the real OCR
STALWART_URL=… STALWART_USER=… STALWART_PASSWORD=… node test/smoke.mjs # real mailbox
STALWART_URL=… STALWART_USER=… STALWART_PASSWORD=… node test/smoke.mjs --send # also sends a mail to yourself
The offline test covers attachments, OCR, the tool prefix, language selection and locale
consistency. The smoke test creates a draft and deletes it; with --send it leaves one test
message in the mailbox.
Email/query with "inMailbox": null is rejected by Stalwart — the filter must be absent or
carry an id.unpdf); the
.cjs extension matters because package.json says "type": "module".MIT
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y stalwart-mail-mcpMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-cybersmurf-stalwart-mail-mcp": {
"command": "npx",
"args": [
"-y",
"stalwart-mail-mcp"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referencestalwart-mail-mcpnpmio.github.cybersmurf/stalwart-mail-mcp works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.