mailwarden
v0.6.0
Published
A reliable, native Gmail MCP server with full mailbox control — search, labels, archive, trash, attachments, and snooze.
Maintainers
Readme
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:unreadin some operator combinations —searchre-verifies every hit against its live labels and discards the index's false positives. Paginated viapageToken/nextPageToken. - 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. - 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.
Tools
| 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) |
| get_profile | Connected account's address + total message/thread counts — confirm which mailbox is wired up before acting |
| triage_digest | Structured overview of a mailbox slice for decisions: top senders, label and age buckets, unread + attachment counts — instead of a raw thread list |
| create_label | Create a user label (idempotent; nested via Parent/Child) and return its id |
| modify_labels | Add/remove labels by name or id — an unknown name in add is auto-created (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), a date+time (2026-06-20 9am), or a preset (tomorrow, tomorrow 9am, weekend, next week, a weekday name, in N days, in N hours) |
| 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 |
| list_filters | All Gmail filters (criteria + label actions); surfaces any forward address on existing filters for auditing |
| create_filter | Create a server-side auto-triage rule (criteria → label actions only; no forwarding — see below). Optionally applyToExisting to also sweep matching mail already in the mailbox |
| delete_filter | Delete a filter by id |
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/<key>, where the key is either YYYY-MM-DD (due all day) or YYYY-MM-DDTHHMM (due at that local minute). The until argument 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, or in 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_snoozed finds 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: set
MAILWARDEN_AUTO_SWEEP=1(hourly sweep while the server runs).
Filters (persistent auto-triage rules)
create_filter sets 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 arriving after it's created. Pass
applyToExisting: 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 loose-index caveat asbulk_modify, and the one-off pass excludes Spam/Trash). This requires at least one positive criterion (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 is reported inapplied, never raised — the rule still stands. - No forwarding — see Security & privacy.
- Requires the
gmail.settings.basicscope; re-run--authonce if you authorized an older version. Not available in read-only mode.
Security & privacy
- No telemetry. Nothing phones home — no analytics, no crash reporting, no tracking.
- No open ports by default. stdio only. The optional
--httplistener binds to127.0.0.1(not the LAN) and refuses to start without aMAILWARDEN_TOKENbearer token — setMAILWARDEN_ALLOW_NO_TOKEN=1to override on a trusted, isolated network. On a loopback bind it also validates theHostheader (DNS-rebinding defense). For remote hosting, setMAILWARDEN_HOSTand front it with TLS. - 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.
create_filterfollows the same rule: it can label, archive, trash, star or mark mail, but never creates a forwarding filter (which would be an exfiltration path).list_filtersstill surfaces any forwarding filter already on the account, so you can spot one. - Tool tiers (progressive disclosure + least scope).
MAILWARDEN_TOOLSadvertises only the tiers you name —read(the read tools),manage(mailbox mutations, snooze, downloads),filters(server-side filter CRUD, the only tier whose tools needgmail.settings.basic). Default is all three; e.g.read,managegives a full triage surface without filter management. The OAuth scopes requested at--authare derived from the enabled tiers — areaddeployment asks only forgmail.readonly, andgmail.settings.basicis requested only when thefilterstier is on. And the filter tools are hidden automatically when the stored token doesn't carrygmail.settings.basic(e.g. a token authorized before you enabled the tier) — re-run--authto grant it. Older tokens without a recorded scope are advertised as before, with the runtime insufficient-scope message as the fallback. - Read-only mode. Set
MAILWARDEN_READONLY=1(shorthand forMAILWARDEN_TOOLS=read) and only the read tools (search,get_thread,list_labels,list_snoozed,get_profile,triage_digest) are registered — nothing that can change the mailbox or write files is even advertised to clients (the filter tools, which need the broadergmail.settings.basicscope, are excluded too). Recommended for shared/HTTP deployments that only triage. - Fenced downloads. With
MAILWARDEN_DOWNLOAD_DIRset, 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/. - Optional token encryption at rest.
token.jsonholds a refresh token; on disk it is protected only bymode 0o600(a no-op on Windows). SetMAILWARDEN_TOKEN_PASSPHRASEto a passphrase and the token is stored AES-256-GCM-encrypted (scrypt-derived key), so a copy of the file — a backup, a synced folder, another machine — is useless without the passphrase. Re-runmailwarden --authonce after setting it to encrypt the existing token. Note the boundary: this defends against file theft, not against malware running as your user (which can read the passphrase from the environment too).
Quick start
claude mcp add mailwarden -- npx -y mailwardenThat'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.jsonin~/.mailwarden/(or setMAILWARDEN_CREDENTIALS=/path/to/credentials.json). - Authorize once — opens a browser, stores a refresh token in
~/.mailwarden/token.json:
Scopes requested:npx -y mailwarden --authgmail.modify(read + label/archive/trash) andgmail.settings.basic(filter management — grants no send capability). If you authorized a version before filters existed, re-run--authonce to grant the added scope.
Connect
Claude Code (local stdio):
claude mcp add mailwarden -- npx -y mailwardenClaude Desktop — add to claude_desktop_config.json:
{
"mcpServers": {
"mailwarden": { "command": "npx", "args": ["-y", "mailwarden"] }
}
}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/mcpThen 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 --authConfig (env)
| Var | Meaning |
|---|---|
| MAILWARDEN_DIR | config dir (default ~/.mailwarden) |
| MAILWARDEN_CREDENTIALS | path to credentials.json |
| MAILWARDEN_TOKEN_PASSPHRASE | passphrase → encrypt token.json at rest (AES-256-GCM); re-run --auth after setting |
| MAILWARDEN_AUTO_SWEEP | 1 → snooze sweep at startup + hourly while running (writes labels — needs the manage/gmail.modify scope; a read-only grant can't sweep) |
| 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/get_profile/triage_digest). Shorthand for MAILWARDEN_TOOLS=read |
| MAILWARDEN_TOOLS | comma-separated tool tiers to advertise: read, manage, filters (default: all). Also derives the OAuth scopes requested at --auth. E.g. read,manage drops the filter tools and their gmail.settings.basic scope |
| PORT | HTTP port (default 8787) |
| MAILWARDEN_HOST | HTTP bind address (default 127.0.0.1; set e.g. 0.0.0.0 for remote hosting) |
| MAILWARDEN_TOKEN | bearer token for the HTTP endpoint — required for --http unless overridden |
| MAILWARDEN_ALLOW_NO_TOKEN | 1 → allow --http without a token (trusted/isolated networks only) |
| MAILWARDEN_ALLOWED_HOSTS | extra comma-separated host:port values accepted by the loopback Host allowlist |
Status
Working and used in daily mailbox automation. Core Gmail tools + snooze implemented against googleapis, covered by a vitest suite (239 tests — npm run coverage). Current version: see the npm badge above, the changelog, or releases. PRs welcome.
License
MIT © C.Sitte Softwaretechnik
