mailwarden

A reliable, native Gmail MCP server β full mailbox triage for AI assistants, with the feature nobody else ships: snooze.
Highlights
- Snooze β the feature nobody else ships. 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 and survives restarts.
- Search you can trust. Gmail's own search index silently drops
is:unread in some operator
combinations β search re-verifies every hit against its live labels and discards the index's
false positives. Paginated via pageToken/nextPageToken.
- Bulk operations that scale.
bulk_modify archives/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
outputSchema and returns validated
structuredContent alongside fenced JSON text β no parsing guesswork for clients.
- 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. Details under Security & privacy.
- 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 is sometimes loose for read-state operators β is:unread is silently dropped in some operator combinations (e.g. category:updates is:unread -in:inbox returns read mail too). 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.
| Tool | What it does |
|---|
search | Gmail query syntax β thread summaries (from/subject/date/labels/snippet); read-state/category predicates are re-verified against each hit's live labels; paginated via pageToken/nextPageToken |
get_thread | Full thread: headers, plaintext + HTML bodies, attachment metadata |
list_labels | All labels (system + user) |
modify_labels | Add/remove labels (archive = remove INBOX, read = remove UNREAD) |
bulk_modify | Batch label changes for every message matching a query β 1000 messages per API request, partial success reported per chunk (thread-id list capped at 500, modifiedThreadCount has the total) |
archive / mark_read / mark_unread | Convenience wrappers |
trash / untrash | Move to / restore from Trash |
download_attachment | Save an attachment to a local path (never overwrites β collisions get a numeric suffix) |
snooze | Archive now, resurface on/after a date (YYYY-MM-DD) |
unsnooze | Cancel a snooze, return to inbox now |
list_snoozed | All snoozed threads + due dates |
sweep_snoozed | Resurface threads whose snooze is due (run on demand, via cron, or the daemon); batched, with partial-failure reporting |
All tools declare an outputSchema and return structured 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)
snooze removes INBOX and applies a dated label MCP/Snoozed/<YYYY-MM-DD>. sweep_snoozed finds due labels and returns those threads to the inbox (marked unread). Run the sweep:
- on demand (
sweep_snoozed tool),
- via cron:
mailwarden --sweep,
- or automatically: set
MAILWARDEN_AUTO_SWEEP=1 (hourly sweep while the server runs).
Security & privacy
- No telemetry. Nothing phones home β no analytics, no crash reporting, no tracking.
- No open ports by default. stdio only; an HTTP listener exists solely behind an explicit
--http, optionally gated by a MAILWARDEN_TOKEN bearer token.
- No send tools β by design. mailwarden cannot compose, reply, or forward. A prompt-injected
instruction inside an email has no exfiltration path through this server.
- Read-only mode. Set
MAILWARDEN_READONLY=1 and only the read tools (search, get_thread,
list_labels, list_snoozed) are registered β nothing that can change the mailbox or write
files is even advertised to clients. Recommended for shared/HTTP deployments that only triage.
- Fenced downloads. With
MAILWARDEN_DOWNLOAD_DIR set, attachment writes are confined to that
directory (realpath-canonicalized, symlink-aware) and never overwrite an existing file.
- Untrusted-content fencing. Every tool result is wrapped in
<untrusted-tool-output> markers
and stripped of invisible/BiDi-override characters, so clients can tell quoted mail content from
instructions.
- Live API, no copy. No mailbox mirror or search index is stored anywhere. The only local state
is your OAuth token in
~/.mailwarden/.
Quick start
claude mcp add mailwarden -- npx -y mailwarden
That's the whole install β npx fetches and runs the published package, no clone or build step. You only need Google OAuth credentials once (below).
Setup
First time setting up a Google OAuth app? Follow the step-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 the Gmail API β configure the OAuth consent screen and publish it to Production (in Testing status, Google expires refresh tokens after 7 days) β create an OAuth client ID of type Desktop app β download it as
credentials.json.
- Put
credentials.json in ~/.mailwarden/ (or set MAILWARDEN_CREDENTIALS=/path/to/credentials.json).
- Authorize once β opens a browser, stores a refresh token in
~/.mailwarden/token.json:
Scope requested: https://www.googleapis.com/auth/gmail.modify.
Connect
Claude Code (local stdio):
claude mcp add mailwarden -- npx -y mailwarden
Claude Desktop β add to claude_desktop_config.json:
{
"mcpServers": {
"mailwarden": { "command": "npx", "args": ["-y", "mailwarden"] }
}
}
Remote (Streamable HTTP) β for a VPS / claude.ai custom connector:
Then in claude.ai: Settings β Connectors β Add custom connector β your https://your-host/mcp URL. In Claude Code: claude mcp add --transport http mailwarden https://your-host/mcp.
From source
git clone https://github.com/csitte/mailwarden && cd mailwarden
npm install && npm run build
node dist/index.js --auth
Config (env)
| Var | Meaning |
|---|
MAILWARDEN_DIR | config dir (default ~/.mailwarden) |
MAILWARDEN_CREDENTIALS | path to credentials.json |
MAILWARDEN_AUTO_SWEEP | 1 β snooze sweep at startup + hourly while running |
MAILWARDEN_DOWNLOAD_DIR | restrict download_attachment to this directory (strongly recommended for HTTP hosting) |
MAILWARDEN_READONLY | 1 β register only the read tools (search/get_thread/list_labels/list_snoozed) |
PORT | HTTP port (default 8787) |
MAILWARDEN_TOKEN | optional bearer token for the HTTP endpoint |
Status
Working and used in daily mailbox automation. Core Gmail tools + snooze implemented against googleapis, covered by a vitest suite (116 tests, ~99 % statement coverage β npm run coverage). Current version: see the npm badge above, the changelog, or releases. PRs welcome.
License
MIT Β© C.Sitte Softwaretechnik