mailwarden
MCP serverSearchmailwarden connects your AI to Gmail so it can triage your email for you. Once added, your AI can search your inbox, organize messages with labels, and clear out clutter by archiving, trashing, unsubscribing, or snoozing email. Its source code is available at github.com/csitte/mailwarden.
Unavailable. This server has no hosted endpoint yet, so Ahel can't serve it.
Add to setup to save this item as a reference. Ahel cannot run it, and signing in will not install it.
What your AI can do with it
- Search your Gmail messages
- Label and organize email
- Archive messages you are done with
- Move unwanted email to trash
- Unsubscribe from mailing lists
- Snooze emails until later
Getting started
- Save this item in Your setup as a reference.
- Read the source or reference documentation for its setup requirements. Saving it here does not connect it to your AI.
- Check this page for availability before trying to install it through Ahel.
From the project's README
As published by csitte/mailwarden in README.md.
A reliable, native Gmail MCP server — full mailbox triage for AI assistants, with the feature no other Gmail MCP server ships: mailbox-side snooze.
Highlights
- Snooze — the only mailbox-side snooze in a Gmail MCP server. Archive a thread now, have it resurface in the inbox on a date. Built on dated labels + a sweep, so it works from any client, is visible in Gmail itself, and survives restarts. (Where another server offers a "snooze", it is a local reminder list — the mail never leaves or re-enters the inbox.)
- Search you can trust. Gmail's
threads.list— the call any thread search goes through — can answeris:unreadfrom a stale thread-level read state: measured in one real mailbox, 86% of the threads it returned held no unread message at all; in a second mailbox, no drift whatsoever. You cannot tell which mailbox you are in without looking, sosearchre-verifies every hit against its live labels. Paginated viapageToken/nextPageToken. (the measurements) - Sender authentication, not sender spelling.
get_threadreports the SPF/DKIM/DMARC results the receiving server recorded, so "is this really from my bank?" is answered from the message's own headers instead of from how the domain looks. It reads the receiving server's report only — a message can carry forged ones of its own — and saysuncheckedwhen nobody checked, because a missing check is not a passing one. - Bulk operations that scale.
bulk_modifyarchives/labels everything matching a query at 1000 messages per API request — with per-chunk partial-success reporting instead of all-or-nothing. The snooze sweep uses the same batch path. - Structured outputs. Every tool declares an
outputSchemaand returns validatedstructuredContentalongside fenced JSON text — no parsing guesswork for clients. Failures are structured as well: acodeand aretryableflag, so a client can tell "try again later" from "re-authorize" without reading prose. - Small attack surface. No send tools (no exfiltration path for prompt-injected mail),
optional read-only mode, no telemetry, no open ports by default, symlink-safe download fencing,
injection-fenced output. And no code path that could send: every Gmail request passes an egress
checkpoint that refuses
messages.send, every draft endpoint, permanent deletion and forwarding settings, whatever a compromised or careless caller asks for. One deliberate exception:unsubscribe/bulk_unsubscribe(manage tier) contact the opt-out endpoint named in a message's own header — the only non-Google host mailwarden ever reaches, and aread-tier deployment makes no outbound request at all. Details under Security & privacy and Unsubscribing. - Correct with real-world mail. RFC 2047 headers decoded (
=?UTF-8?B?…?=→ readable text), bodies decoded in their declared charset (no mojibake for ISO-8859-1/Shift_JIS mail), 429/5xx retried with exponential backoff.
Why
Connectors that sync or cache your mailbox can lag behind it — and even Gmail's own search index is sometimes loose (see below). mailwarden talks 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.
search goes one step further than the raw API: Gmail's threads.list index can answer read-state operators from a stale copy of that state, so is:unread returns threads you finished reading weeks ago — in one measured mailbox, the large majority of what came back. Since every hit is fetched live anyway, search re-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.
Compared to other Gmail MCP servers
Most Gmail MCP servers cover the same read/label/send surface. Three capabilities are still unique to mailwarden (mailbox-side snooze, search re-verification, sender authentication), and one deliberate omission is a security feature, not a gap. Google's own server is also narrower than it looks: draft-only, and no trash, filters or unsubscribe.
| Capability | mailwarden | Google official | taylorwilsdon | a-bonus | aaronsb | klodr |
|---|---|---|---|---|---|---|
| Mailbox-side snooze — archive now, resurface in the inbox on a date/time or preset | ✅ | — | — | — | — | — |
| Search-result re-verification — drops the thread index's false positives against live labels | ✅ | — | — | — | — | — |
| Sender authentication — SPF/DKIM/DMARC as the receiving server recorded them, on every message | ✅ first header only, values token-validated | — | — | — | — | — |
| Sweep / bulk over a query — one action across every thread a search returns | ✅ 1000/req, partial-success | — | ⚠️ batch by explicit ids | — | ⚠️ batch by explicit ids | ⚠️ batch by explicit ids |
| Unsubscribe — per-sender overview + RFC 8058 one-click opt-out, no send scope needed | ✅ | — | ⚠️ header shown, no action | — | — | — |
| Inbox triage overview — one call that buckets what is waiting | ✅ sender/label/age + header signals | — | — | ✅ heuristic flags + stats | ⚠️ unread list, no buckets | — |
| Server-side filters — rules that keep triaging with no assistant in the loop | ✅ never forwarding | — | ✅ | — | — | ✅ |
| No send tools — by design — a prompt-injected mail has no exfiltration path | ✅ no compose at all | ⚠️ draft-only | ❌ sends | ❌ sends | ❌ sends; ⚠️ opt-in draft-only policy | ❌ sends |
| Least-privilege tool tiers — OAuth scopes derived from the tools you enable | ✅ | ⚠️ scope split | ⚠️ --permissions narrows scopes per service; tiers narrow tools only | — | ⚠️ per-account read/readwrite, at call time | ⚠️ inverse: tools gated by granted scopes |
| Token encryption at rest | ✅ AES-256-GCM, opt-in (MAILWARDEN_TOKEN_PASSPHRASE) | n/a (hosted) | ⚠️ file mode 0600; bucket CMEK on GCS | — | — | — |
| No vendor cloud — you operate the server | ✅ | ❌ Google-hosted | ✅ | ✅ | ✅ | ✅ |
Structured outputs — every tool declares an outputSchema | ✅ | — | — | — | — | ⚠️ one tool (download_email), more planned |
Snapshot as of 3 September 2026 — the oldest of the five column checks, and so the only date the table as a whole can claim. Three columns were read that day against that day's state of each project: the two repositories by diff against the revision recorded in docs/comparison-sources.json, Google's by re-reading the tool reference. The taylorwilsdon column is newer — re-read on 7 September 2026 at 54b1c56, where its least-privilege cell was corrected and the rest of the column brought forward to the same revision by diff, which touched no other cell. — = not offered / not documented. Columns are the servers a reader is most likely to reach for — Google's first-party one, plus the three largest community servers still under maintenance — and klodr, which comes closest to mailwarden's own least-privilege design. aaronsb was added on 8 September 2026, and the reason it was missing is worth stating plainly: the selection had been made by capability overlap, so a server an order of magnitude larger than the smallest column sat outside the table while that column stayed in it. It earns its place on the send row rather than on size. It is the only server here besides mailwarden that will refuse to send at all — GWS_SAFETY_POLICY=draft-only-email blocks send, reply, replyAll and forward — and the difference that keeps the row honest is where the promise lives: there, sending is the default and the refusal is an operator's environment variable; here, no tool to send was ever written. Its no-delete policy stands in the same relation to this project's trash-only rule. The most installed Gmail server is absent for that reason and not by oversight: GongRzhe/Gmail-MCP-Server is archived, its last commit dating to August 2025, and it still drew 112,163 npm downloads in the month to 29 August 2026. Reach and currency are different questions, and a comparison of what a server does today can only answer the second. Send capability is listed as a security property: mailwarden's lack of it is intentional (see Security & privacy). The encryption row asks who holds the key: mailwarden encrypts the token itself from a passphrase you set — and does nothing without one, which is why the cell says opt-in rather than showing a bare tick; taylorwilsdon relies on file permissions locally and on the storage bucket's own CMEK when hosted on GCS — protection against a stolen file in the first case, against a stolen disk in the second. The last row asks who operates the server, not where it happens to run: self-hosting is common ground here, and every community server on this table offers some remote deployment except klodr (stdio only) — mailwarden via --http, taylorwilsdon over streamable HTTP with OAuth 2.1, a-bonus on 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's snooze + live re-verification together: an actual inbox-workflow layer that acts on the mailbox's current state, not a cached snapshot. Where others have caught up it's noted honestly above: at-rest encryption (taylorwilsdon), scope-driven tool gating (klodr inversely; taylorwilsdon in our direction, and further than this row said until it was corrected — besides --read-only, which switches the OAuth flow to the read-only scope map, he has had per-service permission levels since 25 February 2026: --permissions gmail:organize builds the requested set from cumulative levels — readonly, organize, drafts, send, full — and his tool registry then disables every tool whose declared scopes fall outside them, deliberately skipping scope-hierarchy expansion so that organize really does exclude gmail.send. What separates that from this row's claim is the direction, not the strength: the level is picked per service rather than derived from the tools you enable, so --tool-tier core --tools gmail still asks for the full Gmail scopes unless a level is named, and neither mode has a guard at the request itself. Our August round read only the tier path and understated him here from 26 August on; re-read in his auth/permissions.py, auth/scopes.py and core/tool_registry.py at 54b1c56 on 7 September 2026 — the day we read, at the revision his repository stood at. The tier half was checked on 26 August 2026 and corrected the same day by csitte.at, who verified it against their own clone rather than taking our word for it), 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 a query and check the mailbox's answer before acting on it.
Running it next to a Workspace server
mailwarden is a Gmail server, not a Workspace suite — if you want Calendar, Drive, Docs and Sheets
from one place, a broad server like taylorwilsdon/google_workspace_mcp covers ground this one never
will, and the two are not mutually exclusive. Adding both is a reasonable setup, and the reason to is
the token, not the tool count: a suite server that can send mail holds a credential that can send
mail, for every mailbox it is pointed at. Giving Gmail to mailwarden instead means the mail half of
your setup has no compose, reply, forward or send tool at all. Where that promise rests differs by
tier, and the distinction matters: on read Google enforces it at the token (gmail.readonly,
which the send endpoints reject), while on manage it rests on the tool surface — Gmail does
accept gmail.modify for sending, so the scope alone is no guarantee. In both cases the egress
guard refuses messages.send and every draft endpoint in the server itself,
so an injected message in your inbox has no tool to reach for and no endpoint to reach.
Practical shape: point the suite server at the services you want and disable its Gmail tools
(--disabled-tools, or a tier that omits them), and run mailwarden alongside for mail. Keep the
tier rule in mind — at most one mailbox per client config should carry
writing tiers.
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. In one real mailbox (~70,000 messages), category:updates is:unread returned 131 threads through threads.list, and only 17 of them held an unread message — 87% stale. The same query, same mailbox, same minute, asked through messages.list instead: 19 messages, none stale. So this is not "Gmail search is unreliable" — the thread view of read state lags while the per-message view does not, and search goes through threads.list. A second mailbox, measured identically on the same day, drifted not at all.
Method, all three queries, the controls, and what the finding is not (it is not the index dropping the predicate, and not a quirk of exotic operator combinations): Gmail's thread index can answer is:unread from a stale read state — a standalone report, every figure traced to a recorded measurement.
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 thread search dropped was genuinely read, and it discarded no genuinely unread mail.
Where it is not free: the bulk tools. search re-verifies because it fetches every hit anyway; bulk_modify (and create_filter's applyToExisting sweep) 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 report unverifiedPredicates, 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 with search first and act on those thread ids. A dryRun does not close this gap: it re-reads the same index, so it confirms how big the set is, never whether it is right.
A cheaper half-measure, honestly labelled. bulk_modify also takes crossCheck: true, which asks Gmail the same question a second way before writing: every derived predicate is re-run as a label filter (labelIds) rather than as a query operator, and any message the two routes disagree about is left untouched and reported. The cost is one extra list call per predicate — flat, independent of how many messages match — where re-verification costs one fetch per hit. What it buys is bounded and worth stating plainly: a disagreement is real evidence, agreement is none at all, because both routes read the same index and an index can be consistently wrong. So unverifiedPredicates still reports what it always did, cross-check or not, and only search re-checks against the mailbox itself. Whether the two routes ever diverge in practice is unmeasured — node scripts/probe-crosscheck.mjs measures exactly that in your own mailbox, read-only and ids only.
mailwarden fetches every hit live anyway, so search re-checks the unambiguous predicates (is:unread, is:read, in:inbox, category:…, with negation) against each thread's true labels 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 Gmail indexed and acting on what's actually 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 in your mailbox 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 real search() against a fake Gmail API whose index is deliberately stale (returns a read thread for an is:unread query, 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 in test/gmail.test.ts ("drops index false positives via live-label re-verify").
Asking again without asking for everything
A recurring check — what came in since I last looked — is the expensive shape for a live server: the
obvious way to answer it is to search the whole slice again and compare. what_changed (read tier)
answers it from Gmail's own event log instead. Hand it the historyId a previous call or get_profile
returned, and it comes back with what arrived, what left, and which labels went on or came off, plus the
next id to keep.
This is not a cache, and the distinction is the whole design. The only thing that persists between calls is one number, and it persists in the caller. mailwarden still stores nothing about the mailbox, keeps no mirror and no index, and every call remains live against the Gmail API — the same rule as everywhere else here.
Two properties worth knowing before relying on it. It reports events, not state: a message marked
unread and then read appears under both, and both are true — for how the mailbox looks now, ask
search. And Gmail keeps roughly a week of history, after which an id is refused; mailwarden turns that
refusal into an error rather than an empty result, because nothing changed and I can no longer tell
you what changed call for opposite reactions and only one of them is safe to act on.
Judging a sender — what authentication answers, and what it doesn't
Every message from get_thread carries an authentication object: the SPF, DKIM and DMARC
results the receiving server recorded, plus the domains each check actually validated.
{
"spf": "pass", "mailedBy": "forwarder.example", // envelope sender SPF checked
"dkim": "pass", "signedBy": "routing.example", // domain whose key signed it
"dmarc": "pass", "headerFrom": "authority.example", // the From domain DMARC evaluated
"authservId": "mx.google.com", // who asserts all of the above
"returnPath": "srs0=…=authority.example=…@forwarder.example"
}
Read dmarc first. It is the only one of the three that ties a passing check to the From
address a human sees. spf: "pass" on its own says an envelope sender was authorised to send —
something a lookalike domain gets in minutes.
The three domains do not have to match, and a mismatch is not a finding. The object above is a
real message from a public authority, forwarded through a custom domain on a mail-routing service
before it reached the mailbox. Every domain differs from the others, and the mail is genuine:
forwarding rewrites the envelope sender (mailedBy becomes the forwarder), the forwarder signs
with its own key (signedBy), and only headerFrom still names the original sender — which is
exactly why DMARC, not SPF, is the check that carries meaning here. Treat the domains as the
explanation of a result, not as a test of their own.
What a pass does not mean. That the mail really came from that domain — not that the domain deserves anything. A phisher holds perfect SPF, DKIM and DMARC on the lookalike domain he registered this morning; authentication tells you who sent it, and the answer can be "exactly who it claims to be, and that is the problem".
What unchecked: true means. The message carried no Authentication-Results header at all —
nobody looked. It is not a failure, and it is not a pass. The header is written by a server that
receives a message, so anything that never arrived from outside — your own sent mail, for
instance — should be expected to have none.
Forged reports. A message can carry Authentication-Results headers of its own — an attacker
writes whatever he likes into the mail he sends. Only the first such header is read, because
each hop prepends its own and the first one is therefore the receiving server's; authservId names
who is asserting the result (for Gmail, mx.google.com) and otherReports counts the ones that
were not read. Values are validated as tokens rather than passed through, so a field that reads
like a verdict cannot carry a sentence. If two results for the same method disagree — a second DKIM
signature that failed — the disagreement shows up in alsoReported instead of being swallowed.
Tools
Shortened here. Read the whole README on GitHub.
Signals
- GitHub stars
- 1
- Last commit
- Sep 2026
- Weekly downloads
- 152
- Weekly_downloads
- 163 weekly_downloads
Advanced
- Delivery
- mailwarden MCP server → your Ahel connector (mcp.ahel.ai) → your AI.
- Item type
- mcp-server
- Key
io-github-csitte-mailwarden- Source
- github.com/csitte/mailwarden