mailwarden
About
Native Gmail MCP server for inbox triage — search with re-verified results, labels, archive/trash, mailbox-side snooze, triage digest, RFC 8058 unsubscribe; no send tools by design.
Details
- Author
- csitte
- Categories
- Communication, Search, Knowledge Base, Productivity
Jump to
Setup
Install mailwarden in your MCP client (Claude Desktop, Cursor, Windsurf, and others).
Repository: https://github.com/csitte/mailwarden
Follow the installation instructions in the repository README, then restart your MCP client.
Connectors that sync or cache your mailbox can lag behind it — and even Gmail's own search index is sometimes loose (see below).mailwardentalks straight to the live Gmail API (no cached snapshot) and re-verifies what the index returns, so what you see is what's actually there. It's a generic Gmail capability layer — keep your own rules/logic in your AI client, not in the server.
searchgoes one step further than the raw API: Gmail'sthreads.listindex can answer read-state operators from astale copyof that state, sois:unreadreturns threads you finished reading weeks ago — in one measured mailbox, the large majority of what came back. Since every hit is fetched live anyway,searchre-checks the unambiguous predicates (is:unread/is:read/is:starred/in:inbox/category:…, with negation) against each thread's true labels and drops the index's false positives.
Snapshot as of 16 August 2026, from each project's public docs and source;—= not offered / not documented. Columns are the servers a reader is most likely to reach for — Google's first-party one, plus the two largest community servers — andklodr, which comes closest tomailwarden's own least-privilege design. Send capability is listed as a security property:mailwarden's lack of it is intentional (seeSecurity & privacy). The last row asks whooperatesthe server, not where it happens to run: self-hosting is common ground here, and every community server on this table offers some remote deployment exceptklodr(stdio only) —mailwardenvia--http,taylorwilsdonover streamable HTTP with OAuth 2.1,a-bonuson Cloud Run. Running one of them on your own host is not a cloud copy; running it on the vendor's is.
The moat isn't any single row — it'ssnooze + live re-verification together: an actual inbox-workflow layer that acts on the mailbox'scurrentstate, not a cached snapshot. Where others have caught up it's noted honestly above: at-rest encryption (taylorwilsdon), scope-driven tool gating (klodr), a richer per-message triage heuristic (a-bonus), and bulk organize over a mailbox (the hosted mcpemails.com, which has no snooze either). What none of them do is act on aqueryand check the mailbox's answer before acting on it.
Why re-verification matters — a concrete case
Ask an assistant to"archive the unread promotional mail that's already skipped my inbox"and it will reach for the obvious query,category:updates is:unread -in:inbox. A server that trusts Gmail's index now archives threads you had already read — mail you never meant to touch, gone in a bulk action you can't easily reverse.
Measured, not asserted.One real mailbox (~70,000 messages), 15.08.2026, read-only:
The index is notignoringthe predicate — the same query withoutis:unreadreturns 800+ threads, so it is being applied. It is applied against athread-level read state that has not caught up: threads whose every message is read still count as unread there. One returned thread carried a single label,SENT. And it is not a quirk of exotic operator combinations: the plainest query of the three shows it too — with thelowestshare (58%) but themostwrong threads in absolute terms (136).
It is the thread index specifically.The same query, same mailbox, same minute, asked throughmessages.listinstead:19 messages, none stale.So this is not "Gmail search is unreliable" — it is that thethreadview of read state lags while the per-message view does not.searchgoes throughthreads.list, which is exactly why it re-verifies.
A second mailbox, measured the same way on the same day, drifted not at all— zero raw-index hits foris:unread, although it is read-marked through the API many times a day. So this is a property ofa mailbox, not of Gmail everywhere. What separates them is open: they differ in volume (roughly three orders of magnitude) and age, and the second is missing something more basic — no thread in it was ever archived while stillunread, which is the only shape a stale read-state can show up on. So it is not a counter-example to any particular cause; it is a mailbox without the candidate.
Which is the whole point:a server cannot know which kind of mailbox it is in.Re-verification costs nothing where nothing drifts, and saves you where it does — in the measurement above, every threadsearchdropped was genuinely read, and it discardednogenuinely unread mail.
Where it isnotfree: the bulk tools.searchre-verifies because it fetches every hit anyway;bulk_modify(andcreate_filter'sapplyToExistingsweep) is sized in thousands of messages, where one fetch per hit is a different order of cost. Those act on what the index returns — so they now reportunverifiedPredicates, the conditions from your query that were taken on the index's word (+UNREAD,-INBOX, …). Empty means there was nothing to distrust. Non-empty and the result has to be read-state-precise? Resolve the set withsearchfirst and act on those thread ids. AdryRundoesnotclose this gap: it re-reads the same index, so it confirms how big the set is, never whether it is right.
mailwardenfetches every hit live anyway, sosearchre-checks the unambiguous predicates (is:unread,is:read,in:inbox,category:…, with negation) against each thread'struelabels and drops the index's false positives before any tool sees them. The bulk action then runs on exactly the set you asked for. This is the difference between acting on what Gmailindexedand acting on what'sactually in the mailbox right now— and it's why snooze/sweep are safe to hand to an assistant: the sweep resurfaces only threads whose snooze is genuinely due, verified against live labels at run time.
See it yourself — no Gmail account needed.From a clone of the repo (the demo is a repo-only verification script, not part of the npm package):
git clone https://github.com/csitte/mailwarden && cd mailwarden npm install && npm run build node scripts/demo-reverify.mjs
There is a second script next to it,node scripts/probe-reverify.mjs, which measures the same thing inyourmailbox instead of a fake one — read-only, metadata only (no subject, sender or body is fetched), printing counts and label names. It is how the numbers above were produced, and how you can check whether your mailbox drifts at all.
The demo drives the realsearch()against a fake Gmail API whose index is deliberately stale (returns a read thread for anis:unreadquery, exactly as Gmail does) and shows mailwarden dropping the false positive. It asserts the outcome, so it exits non-zero if the behavior ever regresses. The same case is locked by unit tests intest/gmail.test.ts("drops index false positives via live-label re-verify").
All tools declare anoutputSchemaand returnstructured content(validated, machine-readable) alongside the same JSON as fenced text — clients never have to parse prose.
How snooze works (no Gmail API snooze exists — we build it)
snoozeremovesINBOXand applies a dated labelMCP/Snoozed/<key>, where the key is eitherYYYY-MM-DD(due all day) orYYYY-MM-DDTHHMM(due at that local minute). Theuntilargument takes an explicit date, a date+time (2026-06-20 9am,…T17:00), or a preset resolved server-side —today,tomorrow,weekend(next Saturday),next week(next Monday), a weekday name (monday–sunday, next occurrence),in N days, orin N hours— and a date preset may carry a trailing time (tomorrow 9am,monday 8:30), so the caller never has to compute the moment itself.sweep_snoozedfinds due labels and returns those threads to the inbox (marked unread); a timed snooze wakes at the first sweep on/after its minute, so wake latency equals your sweep interval. Run the sweep:
- on demand (sweep_snoozedtool),
- via cron:mailwarden --sweep,
- or automatically: setMAILWARDEN_AUTO_SWEEP=1(hourly sweep while the server runs).
create_filtersets up a Gmail server-side rule: mail matching the criteria automatically gets the given label actions — the mailbox keeps triaging itself with no assistant in the loop.
- Criteria:from,to,subject,query(full Gmail search syntax),negatedQuery,hasAttachment,excludeChats, andsize+sizeComparison(smaller/larger, given together). At least one is required.
- Actions (label only):addLabels/removeLabels, by name or id (an unknown name inaddLabelsis auto-created, nested via/). Common recipes: skip the inbox →removeLabels: ["INBOX"]; auto-mark-read →removeLabels: ["UNREAD"]; auto-trash →addLabels: ["TRASH"]; star →addLabels: ["STARRED"]; never-spam →removeLabels: ["SPAM"]; file under a label →addLabels: ["Receipts"].
- Existing mail:a filter only runs on messages arrivingafterit's created. PassapplyToExisting: trueto also apply the same actions once to mail already in the mailbox — mailwarden builds a Gmail search from the criteria and runs a bulk modify (up tomaxMessages, default 1000; same unverified-index caveat asbulk_modify, and the one-off pass excludes Spam/Trash). This requires at least onepositivecriterion (from/to/subject/query/hasAttachment:true/size): an exclusion-only rule (negatedQueryorhasAttachment:false) is refused forapplyToExistingbecause it would match almost the whole mailbox — create such a filter without the flag. The outcome comes back underapplied(thequeryused,matchedMessages/modifiedMessages/modifiedThreadCountcounts,cappedwhen the match set hitmaxMessages, per-chunkfailed, and anerrorstring if the whole pass failed); it'snullwhenapplyToExistingwas not set. The filter is created first, so a partial or failed backlog pass isreportedinapplied, never raised — the rule still stands.
- No forwarding— seeSecurity & privacy.
- Requires thegmail.settings.basicscope; re-run--authonce if you authorized an older version. Not available in read-only mode.
Unsubscribing — the one outbound request
Check it against your own mail before you trust it.From a repo clone (repo-only, not in the npm package), afternpm run buildandmailwarden --auth:
A query that does not name a place never sees spam or trash.Gmail excludes both from any search that does not sayin:spam/in:trash, sofrom:someonereturns nothing for a mail that is sitting in the spam folder — and nothing in the answer says so. Measured against a live mailbox: the samefrom:query returned 0 hits by default and 1 with spam included.
This matters because ofwhymail gets misfiled. A spam filter judges a message on its own; it cannot know that you signed up for something a minute ago, requested a password reset, or placed an order — so the confirmation you are waiting for is exactly the kind of mail that lands there. You know what you just did. The filter does not.
So when mail someone expects is missing, ask again with the place named:
search("in:spam newer_than:2d") # what got filed as spam recently search("in:spam from:example.com") # the confirmation that never arrived
A thread returns to the inbox withmodify_labels(removeSPAM, addINBOX), and a sender that keeps being misjudged is best fixed for good with a never-spam rule —create_filterwithremoveLabels: ["SPAM"](seeFilters).
Two things this server deliberately does not do. It does not scan the spam folder andjudgewhat belongs there: measured over one real spam folder, 89% of it carries no mailing-list machinery at all, so "looks unlike bulk mail" flags nearly the whole folder and filters nothing. And it does not act on that judgement by itself — releasing mail from spam is a decision, and the context that makes it obvious ("I just registered there") lives in the conversation, not in the mailbox.
For the full threat model — trust boundary, per-threat mitigations, explicit non-goals, and how to report a vulnerability — seeSECURITY.md. The highlights:
claude mcp add mailwarden -- npx -y mailwarden
That's the whole install —npxfetches and runs the published package, no clone or build step. You only need Google OAuth credentials once (below).
First time setting up a Google OAuth app? Follow thestep-by-step setup guide— it walks through the Google Cloud Console with exact click paths, explains the "unverified app" screen, and covers the trap that makes tokens die after 7 days. The short version:
- Google Cloud:create a project → enable theGmail API→ configure the OAuth consent screen andpublish it to Production(inTestingstatus, Google expires refresh tokens after 7 days) → create anOAuth client IDof typeDesktop app→ download it ascredentials.json.
- Putcredentials.jsonin~/.mailwarden/(or setMAILWARDEN_CREDENTIALS=/path/to/credentials.json).
- Authorize once — opens a browser, stores a refresh token in~/.mailwarden/token.json:
npx -y mailwarden --auth
claude mcp add mailwarden -- npx -y mailwarden
Claude Code plugin— the same server plus a/mailwarden:setupskill that walks you through the OAuth setup and diagnoses a broken one. The repo root is the plugin (.claude-plugin/plugin.json), so from a clone:
It is submitted to Anthropic's community marketplace; once listed,/plugin marketplace add anthropics/claude-plugins-communitythen/plugin install mailwarden@claude-communitydoes the same without a clone. The plugin runs the full tool surface — for a narrower tier (MAILWARDEN_TOOLS=read) or a second account, useclaude mcp addwith the env you want instead (seeConfigandMultiple accounts).
Claude Desktop— add toclaude_desktop_config.json:
{ "mcpServers": { "mailwarden": { "command": "npx", "args": ["-y", "mailwarden"] } } }
Or install theMCPB bundle(mailwarden-<version>.mcpb, attached toGitHub releasesfrom 0.10.0 on) as a Desktop extension — Settings → Extensions →Install extension…— the same server, self-contained at run time (nonpx; Claude Desktop brings the Node runtime), with the tool tiers as a setting. The bundle is built from the packed npm package (same file set as published;npm run mcpb, verified in CI: validated, unpacked and booted) and is the same file set Smithery distributes. The one-timenpx -y mailwarden --authstill applies (Node needed once for that) — the bundle reads the same~/.mailwarden/token.
Smithery— listed ascsitte/mailwarden, which serves that bundle:
npx -y @smithery/cli install csitte/mailwarden --client claude # local stdio entry in the client's config
Note which of Smithery's two paths you take. The install above writes a plain local server entry: the process, your token and your mail stay on your machine, exactly as withnpx. Adding it to Smithery'stoolboxinstead (smithery mcp add) also runs the bundle locally, but relays the tool traffic through Smithery's gateway so a remote client can reach it — the mailbox content in those responses then passes through a third party. That is a property of the gateway, not of mailwarden; if you want the no-third-party guarantee, use the local install, the npm package, or the.mcpbfrom the release page.
Remote (Streamable HTTP)— for a VPS / claude.ai custom connector:
# Loopback + token required by default. For real hosting, bind outward and keep the token: MAILWARDEN_TOKEN=<secret> MAILWARDEN_HOST=0.0.0.0 npx -y mailwarden --http # :8787/mcp
Then in claude.ai: Settings → Connectors →Add custom connector→ yourhttps://your-host/mcpURL. In Claude Code:claude mcp add --transport http mailwarden https://your-host/mcp.
One OAuth app (onecredentials.json) can authorize several Gmail accounts. Each account keeps its own refresh token in a separate file, selected byMAILWARDEN_ACCOUNT:
mailwarden --auth --account work # stores token.work.json mailwarden --auth --account personal # stores token.personal.json
Run them side by side by registering the serveronce per account, each with its ownMAILWARDEN_ACCOUNT. Every instance is fully isolated — its own token, its own granted scopes, its own tool surface — so nothing can act on the wrong mailbox:
{ "mcpServers": { "gmail-work": { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "work" } }, "gmail-personal": { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "personal" } } } }
Account names arecase-insensitive— they become filenames, soWorkandworkwould be the same file on Windows/macOS. mailwarden lower-cases them (--account Work→token.work.json) so a name always maps to exactly one mailbox.
Which file--authwrites depends only on--account/MAILWARDEN_ACCOUNT— never on the account you pick in the browser.Authorizing a second mailboxwithout--accountwould therefore aim straight at the first one's token file, so--authchecks first andrefusesrather than replacing another mailbox's token;--forceoverrides it deliberately. The two knobs are not interchangeable:MAILWARDEN_ACCOUNTis the one for several mailboxes out of one config directory (it pickstoken.<name>.json), whileMAILWARDEN_DIRmoves thewholedirectory — useful to keep setups apart entirely, but it does not give you a second account inside one.npm run authfrom a repo clone passes neither, i.e. it always serves the default account.
mailwarden --checkshows the active account and lists the others it finds. With noMAILWARDEN_ACCOUNTset, everything uses the defaulttoken.jsonexactly as before — this is fully backward compatible.
git clone https://github.com/csitte/mailwarden && cd mailwarden npm install && npm run build node dist/index.js --auth
Working and used in daily mailbox automation. Core Gmail tools + snooze implemented againstgoogleapis, covered by a vitest suite (789 tests —npm run coverage). Current version: see the npm badge above, thechangelog, orreleases. PRs welcome.
Connect Claude, ChatGPT, and other AI tools to your Granola meeting notes via MCP. Query your notes, search transcripts, and get meeting insights in your favorite AI assistants.
MCP server for full Gmail operations via Unipile API. 9 tools: send, reply, list, read, delete, search, labels, attachments, drafts. Dry-run by default, 55 unit tests. MIT licensed.
Telegram MCP server with 20 tools — read chats, search messages, download media via MTProto
MCP server for Stalwart mail server via JMAP — mailboxes, search, send, and admin
Search, read, and send personal WhatsApp messages, contacts, and media files.
A WhatsApp MCP (Model Context Protocol) server that connects your WhatsApp account with AI agents, enabling automated messaging, contact search, and chat interaction through LLMs.
Connect your Plaud recordings to any MCP-compatible AI client. Search recordings, read transcripts, and generate documents without leaving your AI assistant.
Agent-native forum for the x402/A2A ecosystem. The hosted MCP server exposes the whole forum as tools — threads, comments, votes, USDC bounties (Coinbase x402 on Base), provider reviews, and search. Endpoint: https://api.achivx.com/mcp/ (HTTP, OAuth 2.1).
Interact with Confluence to execute CQL queries, retrieve page content, and update pages.
Fetch and interact with Hacker News content, including top stories, comments, and search functionality.
Sign in to leave a review
Use Google, GitHub, or an email account so ratings stay tied to real people.
No reviews posted yet.



