{
  "markdown": "# mailwarden\n\n[![npm](https://img.shields.io/npm/v/mailwarden)](https://www.npmjs.com/package/mailwarden)\n[![license](https://img.shields.io/npm/l/mailwarden)](LICENSE)\n[![Node](https://img.shields.io/node/v/mailwarden)](package.json)\n[![Website](https://img.shields.io/badge/Website-csitte.at%2Fmailwarden-2ea44f)](https://www.csitte.at/mailwarden/)\n[![Smithery](https://img.shields.io/badge/Smithery-csitte%2Fmailwarden-ea580c)](https://smithery.ai/servers/csitte/mailwarden)\n\nA reliable, **native** Gmail [MCP](https://modelcontextprotocol.io) server — full mailbox triage for AI assistants, with the feature no other Gmail MCP server ships: **mailbox-side snooze**.\n\n## Highlights\n\n- **Snooze — the only *mailbox-side* snooze in a Gmail MCP server.** Archive a thread now, have it\n  resurface in the inbox on a date. Built on dated labels + a sweep, so it works from any client,\n  is visible in Gmail itself, and survives restarts. (Where another server offers a \"snooze\", it is\n  a local reminder list — the mail never leaves or re-enters the inbox.)\n- **Search you can trust.** Gmail's `threads.list` — the call any thread search goes through — can\n  answer `is:unread` from a **stale thread-level read state**: measured in one real mailbox, **86% of\n  the threads it returned held no unread message at all**; in a second mailbox, no drift whatsoever.\n  You cannot tell which mailbox you are in without looking, so `search` re-verifies every hit against\n  its live labels. Paginated via `pageToken`/`nextPageToken`. ([the measurements](https://github.com/csitte/mailwarden/blob/main/docs/gmail-thread-read-state-drift.md))\n- **Sender authentication, not sender spelling.** `get_thread` reports the SPF/DKIM/DMARC results\n  the receiving server recorded, so \"is this really from my bank?\" is answered from the message's\n  own headers instead of from how the domain looks. It reads the receiving server's report only —\n  a message can carry forged ones of its own — and says `unchecked` when nobody checked, because a\n  missing check is not a passing one.\n- **Bulk operations that scale.** `bulk_modify` archives/labels everything matching a query at\n  1000 messages per API request — with per-chunk partial-success reporting instead of\n  all-or-nothing. The snooze sweep uses the same batch path.\n- **Structured outputs.** Every tool declares an `outputSchema` and returns validated\n  `structuredContent` alongside fenced JSON text — no parsing guesswork for clients. Failures are\n  structured as well: a `code` and a `retryable` flag, so a client can tell \"try again later\" from\n  \"re-authorize\" without reading prose.\n- **Small attack surface.** No send tools (no exfiltration path for prompt-injected mail),\n  optional read-only mode, no telemetry, no open ports by default, symlink-safe download fencing,\n  injection-fenced output. And no code path that *could* send: every Gmail request passes an egress\n  checkpoint that refuses `messages.send`, every draft endpoint, permanent deletion and forwarding\n  settings, whatever a compromised or careless caller asks for. **One deliberate exception:**\n  `unsubscribe` / `bulk_unsubscribe` (manage tier) contact the opt-out endpoint named in a message's\n  own header — the only non-Google host mailwarden ever reaches, and a `read`-tier deployment makes\n  no outbound request at all. Details under\n  [Security & privacy](#security--privacy) and\n  [Unsubscribing](#unsubscribing--the-one-outbound-request).\n- **Correct with real-world mail.** RFC 2047 headers decoded (`=?UTF-8?B?…?=` → readable text),\n  bodies decoded in their *declared* charset (no mojibake for ISO-8859-1/Shift_JIS mail),\n  429/5xx retried with exponential backoff.\n\n## Why\n\nConnectors 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.\n\n`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.\n\n## Compared to other Gmail MCP servers\n\nMost 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.\n\n<!-- comparison-table-verified: 2026-09-03 -->\n\n| Capability | **mailwarden** | [Google official](https://developers.google.com/workspace/gmail/api/guides/configure-mcp-server) | [taylorwilsdon](https://github.com/taylorwilsdon/google_workspace_mcp) | [a-bonus](https://github.com/a-bonus/google-docs-mcp) | [klodr](https://github.com/klodr/gmail-mcp) |\n|---|:--:|:--:|:--:|:--:|:--:|\n| **Mailbox-side snooze** — archive now, resurface in the inbox on a date/time or preset | ✅ | — | — | — | — |\n| **Search-result re-verification** — drops the thread index's false positives against live labels | ✅ | — | — | — | — |\n| **Sender authentication** — SPF/DKIM/DMARC as the receiving server recorded them, on every message | ✅ first header only, values token-validated | — | — | — | — |\n| **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 |\n| **Unsubscribe** — per-sender overview + RFC 8058 one-click opt-out, no send scope needed | ✅ | — | ⚠️ header shown, no action | — | — |\n| **Inbox triage overview** — one call that buckets what is waiting | ✅ sender/label/age + header signals | — | — | ✅ heuristic flags + stats | — |\n| **Server-side filters** — rules that keep triaging with no assistant in the loop | ✅ never forwarding | — | ✅ | — | ✅ |\n| **No send tools — by design** — a prompt-injected mail has no exfiltration path | ✅ no compose at all | ⚠️ draft-only | ❌ sends | ❌ sends | ❌ sends |\n| **Least-privilege tool tiers** — OAuth scopes derived from the tools you enable | ✅ | ⚠️ scope split | ⚠️ `--permissions` narrows scopes per service; tiers narrow tools only | — | ⚠️ inverse: tools gated by granted scopes |\n| **Token encryption at rest** | ✅ AES-256-GCM, **opt-in** (`MAILWARDEN_TOKEN_PASSPHRASE`) | n/a (hosted) | ⚠️ file mode `0600`; bucket CMEK on GCS | — | — |\n| **No vendor cloud — you operate the server** | ✅ | ❌ Google-hosted | ✅ | ✅ | ✅ |\n| **Structured outputs** — every tool declares an `outputSchema` | ✅ | — | — | — | ⚠️ one tool (`download_email`), more planned |\n\n<sub>Snapshot as of 3 September 2026 — the oldest of the four 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`](https://github.com/csitte/mailwarden/blob/main/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 two largest community servers still under maintenance — and `klodr`, which comes closest to `mailwarden`'s own least-privilege design. 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](#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.</sub>\n\nThe 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.\n\n### Running it next to a Workspace server\n\n`mailwarden` is a Gmail server, not a Workspace suite — if you want Calendar, Drive, Docs and Sheets\nfrom one place, a broad server like `taylorwilsdon/google_workspace_mcp` covers ground this one never\nwill, and the two are not mutually exclusive. Adding both is a reasonable setup, and the reason to is\nthe token, not the tool count: a suite server that can send mail holds a credential that can send\nmail, for every mailbox it is pointed at. Giving Gmail to `mailwarden` instead means the mail half of\nyour setup has no compose, reply, forward or send tool at all. Where that promise rests differs by\ntier, and the distinction matters: on `read` Google enforces it at the token (`gmail.readonly`,\nwhich the send endpoints reject), while on `manage` it rests on the tool surface — Gmail *does*\naccept `gmail.modify` for sending, so the scope alone is no guarantee. In both cases [the egress\nguard](#security--privacy) refuses `messages.send` and every draft endpoint in the server itself,\nso an injected message in your inbox has no tool to reach for and no endpoint to reach.\n\nPractical shape: point the suite server at the services you want and disable its Gmail tools\n(`--disabled-tools`, or a tier that omits them), and run `mailwarden` alongside for mail. Keep the\n[tier rule](#more-than-two-accounts) in mind — at most one mailbox per client config should carry\nwriting tiers.\n\n### Why re-verification matters — a concrete case\n\nAsk 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.\n\n**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.\n\nMethod, 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](https://github.com/csitte/mailwarden/blob/main/docs/gmail-thread-read-state-drift.md)** — a standalone report, every figure traced to a recorded measurement.\n\nWhich 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.\n\n**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.\n\n**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.\n\n`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.\n\n**See it yourself — no Gmail account needed.** From a clone of the repo (the demo is a repo-only\nverification script, not part of the npm package):\n\n```bash\ngit clone https://github.com/csitte/mailwarden && cd mailwarden\nnpm install && npm run build\nnode scripts/demo-reverify.mjs\n```\n\nThere 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.\n\nThe 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`](https://github.com/csitte/mailwarden/blob/main/test/gmail.test.ts) (*\"drops index false positives via live-label re-verify\"*).\n\n### Asking again without asking for everything\n\nA recurring check — *what came in since I last looked* — is the expensive shape for a live server: the\nobvious way to answer it is to search the whole slice again and compare. `what_changed` (read tier)\nanswers it from Gmail's own event log instead. Hand it the `historyId` a previous call or `get_profile`\nreturned, and it comes back with what arrived, what left, and which labels went on or came off, plus the\nnext id to keep.\n\n**This is not a cache, and the distinction is the whole design.** The only thing that persists between\ncalls is one number, and it persists in the *caller*. mailwarden still stores nothing about the mailbox,\nkeeps no mirror and no index, and every call remains live against the Gmail API — the same rule as\neverywhere else here.\n\nTwo properties worth knowing before relying on it. It reports **events, not state**: a message marked\nunread and then read appears under both, and both are true — for how the mailbox looks *now*, ask\n`search`. And Gmail keeps roughly a week of history, after which an id is refused; mailwarden turns that\nrefusal into an error rather than an empty result, because *nothing changed* and *I can no longer tell\nyou what changed* call for opposite reactions and only one of them is safe to act on.\n\n### Judging a sender — what `authentication` answers, and what it doesn't\n\nEvery message from `get_thread` carries an `authentication` object: the SPF, DKIM and DMARC\nresults **the receiving server** recorded, plus the domains each check actually validated.\n\n```jsonc\n{\n  \"spf\": \"pass\",   \"mailedBy\":   \"forwarder.example\",        // envelope sender SPF checked\n  \"dkim\": \"pass\",  \"signedBy\":   \"routing.example\",           // domain whose key signed it\n  \"dmarc\": \"pass\", \"headerFrom\": \"authority.example\",        // the From domain DMARC evaluated\n  \"authservId\": \"mx.google.com\",                             // who asserts all of the above\n  \"returnPath\": \"srs0=…=authority.example=…@forwarder.example\"\n}\n```\n\n**Read `dmarc` first.** It is the only one of the three that ties a passing check to the `From`\naddress a human sees. `spf: \"pass\"` on its own says an envelope sender was authorised to send —\nsomething a lookalike domain gets in minutes.\n\n**The three domains do not have to match, and a mismatch is not a finding.** The object above is a\nreal message from a public authority, forwarded through a custom domain on a mail-routing service\nbefore it reached the mailbox. Every domain differs from the others, and the mail is genuine:\nforwarding rewrites the envelope sender (`mailedBy` becomes the forwarder), the forwarder signs\nwith its own key (`signedBy`), and only `headerFrom` still names the original sender — which is\nexactly why DMARC, not SPF, is the check that carries meaning here. Treat the domains as the\n*explanation* of a result, not as a test of their own.\n\n**What a pass does not mean.** That the mail really came from that domain — not that the domain\ndeserves anything. A phisher holds perfect SPF, DKIM and DMARC on the lookalike domain he\nregistered this morning; authentication tells you *who sent it*, and the answer can be \"exactly who\nit claims to be, and that is the problem\".\n\n**What `unchecked: true` means.** The message carried no `Authentication-Results` header at all —\nnobody looked. It is not a failure, and it is not a pass. The header is written by a server that\n*receives* a message, so anything that never arrived from outside — your own sent mail, for\ninstance — should be expected to have none.\n\n**Forged reports.** A message can carry `Authentication-Results` headers of its own — an attacker\nwrites whatever he likes into the mail he sends. Only the **first** such header is read, because\neach hop prepends its own and the first one is therefore the receiving server's; `authservId` names\nwho is asserting the result (for Gmail, `mx.google.com`) and `otherReports` counts the ones that\nwere not read. Values are validated as tokens rather than passed through, so a field that reads\nlike a verdict cannot carry a sentence. If two results for the same method disagree — a second DKIM\nsignature that failed — the disagreement shows up in `alsoReported` instead of being swallowed.\n\n## Tools\n\n| Tool | What it does |\n|---|---|\n| `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`. Each hit carries `signals` — `newsletter` (List-Id / List-Unsubscribe / Precedence bulk or list), `automated` (Auto-Submitted, auto-reply/suppress headers, no-reply-style senders), `calendar` (text/calendar or .ics part), `replyToMismatch` (Reply-To on another domain than From; a subdomain of the same domain counts as the same) — read off the first message's headers/MIME, no extra call. **Spam and trash are excluded unless the query says `in:spam` / `in:trash`** — see [Looking in spam](#looking-in-spam) |\n| `get_thread` | Full thread: headers, plaintext + HTML bodies, attachment metadata. Every message also carries `authentication` — SPF/DKIM/DMARC as the receiving server reported them, plus the domains each check validated (`signedBy`/`mailedBy`/`headerFrom`), who asserts it (`authservId`), the `returnPath`, `alsoReported` for results that contradict each other, `otherReports` for reports that were not read, and `unchecked: true` when the message carried no report at all — see [Judging a sender](#judging-a-sender--what-authentication-answers-and-what-it-doesnt). `full: false` fetches headers and labels only — it then **omits** `plaintextBody`/`htmlBody`/`attachments` and sets `metadataOnly: true`, rather than reporting them empty for a request that never looked |\n| `list_labels` | All labels (system + user) |\n| `get_profile` | Connected account's address, total message/thread counts and the mailbox's current `historyId` — confirm *which* mailbox is wired up before acting |\n| `what_changed` | Mailbox events since a `historyId` you hold — arrivals, removals, labels on and off, in one call |\n| **`triage_digest`** | Structured overview of a mailbox slice for *decisions*: top senders (each with the signals its threads carry), label and age buckets, unread + attachment counts, and how many threads are newsletters / automated / calendar invites / reply-to mismatches — instead of a raw thread list |\n| `list_unsubscribe` | What opt-out options a thread advertises (`List-Unsubscribe`), plus body links when it advertises none — contacts nobody |\n| **`list_subscriptions`** | A mailbox slice grouped by *sender*: thread/unread counts, the date span each was seen over, and each one's opt-out options — one header fetch per sender, contacts nobody. `sendersFound` reports how many senders there were before `topN` truncated the list |\n| `create_label` | Create a user label (idempotent; nested via `Parent/Child`) and return its id; an optional `backgroundColor`/`textColor` pair colours it, including one that already exists |\n| `modify_labels` | Add/remove labels by **name or id** — an unknown name in `add` is auto-created (archive = remove `INBOX`, read = remove `UNREAD`) |\n| **`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, `submittedThreadCount` has the total). Counts say **`submitted`**, because `messages.batchModify` answers `204` with no body and ignores unknown ids silently; `verify: true` reads the labels back and returns `verified` `{applied, notApplied, unverifiable}` — the only observed outcome on offer. Acts on the **raw index**, so `unverifiedPredicates` names the conditions it could not vouch for (see below). `dryRun: true` resolves the query and reports the matched threads and the labels it would create, touching nothing |\n| `archive` / `mark_read` / `mark_unread` | Convenience wrappers |\n| `trash` / `untrash` | Move to / restore from Trash |\n| `download_attachment` | Save an attachment to a local path (never overwrites — collisions get a numeric suffix) |\n| **`unsubscribe`** | One-click opt-out (RFC 8058) using the endpoint from the message's own header — the only tool that contacts a non-Google host ([details](#unsubscribing--the-one-outbound-request)) |\n| **`bulk_unsubscribe`** | The same for several threads, sequentially and **at most one request per sender** (remembered across calls for as long as the server runs, so a retry contacts nobody twice); partial success reported per thread. `dryRun: true` runs the same header reads and dedupe and reports the endpoint each thread `wouldCall` — contacting nobody |\n| **`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`) |\n| **`unsnooze`** | Cancel a snooze, return to inbox now |\n| **`list_snoozed`** | All snoozed threads + due dates |\n| **`sweep_snoozed`** | Resurface threads whose snooze is due (run on demand, via cron, or the daemon); batched, with partial-failure reporting. `dryRun: true` answers \"what is due right now?\" (`dueLabels`/`dueThreads`) without waking anything |\n| `list_filters` | All Gmail filters (criteria + label actions); surfaces any `forward` address on existing filters for auditing |\n| `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 |\n| `delete_filter` | Delete a filter by id |\n\nAll tools declare an `outputSchema` and return **structured content** (validated, machine-readable)\nalongside the same JSON as fenced text — clients never have to parse prose.\n\nA **failure** is structured too: `isError` plus a fenced JSON body with a `code`\n(`not_authorized`, `needs_reauth`, `insufficient_scope`, `forbidden_operation`, `not_found`,\n`rate_limited`, `upstream_unavailable`, `network_error`, `invalid_input`, `internal_error`) and a\n`retryable` flag, alongside the sentence a human reads. So \"wait and try again\" versus \"re-run\n`mailwarden --auth`\" is something a client can decide, not something it has to infer from wording\nthat may be reworded next release. (No `structuredContent` on errors: that is validated against the\ntool's outputSchema, which describes a success.)\n\n### How snooze works (no Gmail API snooze exists — we build it)\n\n`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:\n- on demand (`sweep_snoozed` tool),\n- via cron: `mailwarden --sweep`,\n- or automatically: set `MAILWARDEN_AUTO_SWEEP=1` (hourly sweep while the server runs).\n\n### Filters (persistent auto-triage rules)\n\n`create_filter` sets up a Gmail server-side rule: mail matching the criteria automatically gets the\ngiven label actions — the mailbox keeps triaging itself with no assistant in the loop.\n\n- **Criteria:** `from`, `to`, `subject`, `query` (full Gmail search syntax), `negatedQuery`,\n  `hasAttachment`, `excludeChats`, and `size` + `sizeComparison` (`smaller`/`larger`, given together).\n  At least one is required.\n- **Actions (label only):** `addLabels` / `removeLabels`, by name or id (an unknown name in\n  `addLabels` is auto-created, nested via `/`). Common recipes: skip the inbox → `removeLabels: [\"INBOX\"]`;\n  auto-mark-read → `removeLabels: [\"UNREAD\"]`; auto-trash → `addLabels: [\"TRASH\"]`;\n  star → `addLabels: [\"STARRED\"]`; never-spam → `removeLabels: [\"SPAM\"]`; file under a label → `addLabels: [\"Receipts\"]`.\n- **Existing mail:** a filter only runs on messages arriving *after* it's created. Pass\n  `applyToExisting: true` to also apply the same actions once to mail already in the mailbox —\n  mailwarden builds a Gmail search from the criteria and runs a bulk modify (up to `maxMessages`,\n  default 1000; same unverified-index caveat as `bulk_modify`, and the one-off pass excludes Spam/Trash).\n  This requires at least one *positive* criterion (`from`/`to`/`subject`/`query`/`hasAttachment:true`/`size`):\n  an exclusion-only rule (`negatedQuery` or `hasAttachment:false`) is refused for `applyToExisting`\n  because it would match almost the whole mailbox — create such a filter without the flag.\n  The outcome comes back under `applied` (the `query` used, `matchedMessages`/`submittedMessages`/`submittedThreadCount`\n  counts, `capped` when the match set hit `maxMessages`, per-chunk `failed`, and an `error` string if the whole\n  pass failed); it's `null` when `applyToExisting` was not set. The backlog pass does not verify what landed —\n  `bulk_modify`'s `verify` does; re-run it with the same query when the sweep's outcome has to be certain. The filter is created first, so a partial or\n  failed backlog pass is *reported* in `applied`, never raised — the rule still stands.\n- **No forwarding** — see [Security & privacy](#security--privacy).\n- Requires the `gmail.settings.basic` scope; re-run `--auth` once if you authorized an older version.\n  Not available in read-only mode.\n\n### Unsubscribing — the one outbound request\n\n`list_unsubscribe` (read tier) reports what the sender offers, without contacting anyone. It reads the\nnewest message that actually carries a `List-Unsubscribe` header — a reply threaded onto a newsletter\nsits at the end and advertises nothing, which would otherwise read as \"this list has no opt-out\".\nWhen a thread advertises no opt-out header at all, `list_unsubscribe` looks in the message body and\nreports the unsubscribe links it finds there as `bodyCandidates` — plenty of senders put the link only\nin the footer, and answering \"no opt-out options\" for them is true about the headers and wrong about\nthe mail. Those links are **reported, never fetched**, they cannot be handed to `unsubscribe`, and\n`hasUnsubscribe` stays false for them, because that flag has always described the headers. The search\ncosts one full thread fetch and happens only in that case.\n`list_subscriptions` (read tier) does the same across a whole slice, grouped by sender, so you can see\n*who* keeps writing and which of them can actually be left — one header fetch per sender rather than\nper thread. `unsubscribe` and `bulk_unsubscribe` (manage tier) act on it — and that is the **only**\nplace mailwarden ever talks to a host that isn't Google, so the rules are tight:\n\n- **There is no URL parameter.** The endpoint comes from the message's own header and nowhere else.\n  A URL argument would let a prompt-injected mail turn the tool into an exfiltration channel\n  (mailbox content in a query string); the header cannot carry data the model chose. This is also why\n  a body link is only ever reported: it is a URL the sender wrote into the text, and fetching one would\n  reintroduce exactly the request this rule exists to prevent — by a path that never passes the guard.\n  Another Gmail server, `navbuildz/gmail-mcp-server`, does fetch them, following redirects, when the\n  header is missing.\n- **Only RFC 8058 one-click** is performed — the sender must have opted in via `List-Unsubscribe-Post`.\n  A plain `https:` link is meant for a human in a browser and is handed back, not fetched.\n- **`mailto:` opt-outs are never performed.** They would require sending mail, which mailwarden cannot\n  do. The address is reported so you can act on it yourself.\n- **Fixed request, discarded response.** The POST body is always `List-Unsubscribe=One-Click` and is\n  never derived from anything; the response body is cancelled unread. What returns to the model is the\n  status code and the URL actually called — no content from the endpoint, so it cannot answer with\n  instructions. (A 301/302/303 redirect is followed as a GET, i.e. with no body at all.)\n- **One request per sender, sequentially, inside one budget.** `bulk_unsubscribe` takes thread ids\n  (never a query — a query-driven bulk would fire off a request per matched sender before anyone had\n  looked). Threads from a sender whose request already went out are reported with `duplicateOf` and\n  cost no second request: two threads from one list share an opt-out, and calling it twice only\n  confirms your address twice. A sender is only recorded once a request actually *reached* an\n  endpoint, so a refusal or a dropped connection still leaves the next thread its own try — and if\n  the skipped thread advertises a *different* endpoint, the reason says so, since one sender can run\n  several lists. **That memory spans calls** for as long as the server runs, and `unsubscribe` shares\n  it: a call that times out is safe to repeat, and asking twice for the same newsletter contacts the\n  sender once. Pass `force: true` to `unsubscribe` for a deliberate second attempt — after an endpoint\n  answered 500, say. It is kept in memory only: persisting it would mean a second kind of local state\n  beside the token, which this server deliberately does not keep, so a restart forgets.\n  Capped at 25 threads and 60 seconds per call; whatever the budget doesn't cover comes\n  back as `skippedOutOfTime` rather than silently undone. None of it can be reversed, which is why all\n  three limits exist.\n- **SSRF guards.** https only, default port only, no credentials in the URL, and every hop — including\n  redirects, followed at most 3 times — must resolve exclusively to globally reachable addresses. The\n  check parses each address to its bytes and matches it against the IANA special-purpose registry, so\n  every spelling of the same address gets the same verdict (`::1` and `0:0:0:0:0:0:0:1` alike); an\n  address that does not parse is refused. DNS resolution and all hops share one 10-second budget. Not\n  rebinding-proof (`fetch` resolves again when it connects) — see [SECURITY.md](SECURITY.md); what\n  survives that gap is a blind POST whose response is never read.\n\n**Check it against your own mail before you trust it.** From a repo clone (repo-only, not in the\nnpm package), after `npm run build` and `mailwarden --auth`:\n\n```bash\nnode scripts/probe-unsubscribe.mjs --vet          # category:promotions, 25 threads\nnode scripts/probe-unsubscribe.mjs \"from:substack.com\" --max 50 --vet\n```\n\nIt prints each real `List-Unsubscribe` header next to what the parser made of it, and `--vet` also\nruns the endpoint through the URL vetting and the address guard — so you see both whether the parser\nunderstood the header *and* whether the guards would have let that opt-out through. Strictly\nread-only: no request is ever made to a sender, and nothing in the mailbox changes.\n\nWhat it can't undo: the request tells the sender your address is live. A sender that ignores its own\nopt-out is beyond any client's reach — pair `unsubscribe` with `create_filter` or `trash` for those.\nNot offering an automatable option is reported as `unsubscribed:false` with the alternatives, not as an\nerror. A `read`-only deployment gets `list_unsubscribe` and `list_subscriptions`, and never makes the\nrequest at all.\n\n## Looking in spam\n\n**A query that does not name a place never sees spam or trash.** Gmail excludes both from any\nsearch that does not say `in:spam` / `in:trash`, so `from:someone` returns nothing for a mail that\nis sitting in the spam folder — and nothing in the answer says so. Measured against a live mailbox:\nthe same `from:` query returned 0 hits by default and 1 with spam included.\n\nThis matters because of *why* mail gets misfiled. A spam filter judges a message on its own; it\ncannot know that you signed up for something a minute ago, requested a password reset, or placed an\norder — so the confirmation you are waiting for is exactly the kind of mail that lands there. You\nknow what you just did. The filter does not.\n\nSo when mail someone expects is missing, ask again with the place named:\n\n```text\nsearch(\"in:spam newer_than:2d\")          # what got filed as spam recently\nsearch(\"in:spam from:example.com\")       # the confirmation that never arrived\n```\n\nA thread returns to the inbox with `modify_labels` (remove `SPAM`, add `INBOX`), and a sender that\nkeeps being misjudged is best fixed for good with a never-spam rule — `create_filter` with\n`removeLabels: [\"SPAM\"]` (see [Filters](#filters-persistent-auto-triage-rules)).\n\nTwo things this server deliberately does not do. It does not scan the spam folder and *judge* what\nbelongs there: measured over one real spam folder, 89% of it carries no mailing-list machinery at\nall, so \"looks unlike bulk mail\" flags nearly the whole folder and filters nothing. And it does not\nact on that judgement by itself — releasing mail from spam is a decision, and the context that makes\nit obvious (\"I just registered there\") lives in the conversation, not in the mailbox.\n\n## Security & privacy\n\n> For the full threat model — trust boundary, per-threat mitigations, explicit non-goals, and how to\n> report a vulnerability — see **[SECURITY.md](SECURITY.md)**. The highlights:\n\n- **No telemetry.** Nothing phones home — no analytics, no crash reporting, no tracking.\n- **No open ports by default.** stdio only. The optional `--http` listener binds to `127.0.0.1`\n  (not the LAN) and **refuses to start without a `MAILWARDEN_TOKEN`** bearer token — set\n  `MAILWARDEN_ALLOW_NO_TOKEN=1` to override on a trusted, isolated network. On a loopback bind it\n  also validates the `Host` header (DNS-rebinding defense). For remote hosting, set `MAILWARDEN_HOST`\n  and front it with TLS.\n- **No send tools — by design.** mailwarden cannot compose, reply, or forward. A prompt-injected\n  instruction inside an email has no exfiltration path through this server. `create_filter` follows\n  the same rule: it can label, archive, trash, star or mark mail, but **never** creates a *forwarding*\n  filter (which would be an exfiltration path). `list_filters` still surfaces any forwarding filter\n  already on the account, so you can spot one. This holds because no such tool exists and none can be\n  registered at runtime; for the stronger variant, where *Google* refuses to send rather than\n  mailwarden declining to, see **Read-only mode** below.\n- **One outbound host, no model-chosen URL.** The `unsubscribe` tool is the only code path that\n  contacts a non-Google host. Its endpoint is read from the message's `List-Unsubscribe` header —\n  never from a tool argument — the request body is fixed and the response body is discarded, so it\n  cannot become a data channel. https/default-port only, redirects re-validated, and any hop resolving\n  to a private, loopback, link-local or metadata address is refused. See\n  [Unsubscribing](#unsubscribing--the-one-outbound-request).\n- **Tool tiers (progressive disclosure + least scope).** `MAILWARDEN_TOOLS` advertises only the tiers\n  you name — `read` (the read tools), `manage` (mailbox mutations, snooze, downloads), `filters`\n  (server-side filter CRUD, the only tier whose tools need `gmail.settings.basic`). Default is all\n  three; e.g. `read,manage` gives a full triage surface without filter management. The **OAuth scopes\n  requested at `--auth` are derived from the enabled tiers** — a `read` deployment asks only for\n  `gmail.readonly`, and `gmail.settings.basic` is requested only when the `filters` tier is on. And the\n  filter tools are **hidden automatically** when the stored token doesn't carry `gmail.settings.basic`\n  (e.g. a token authorized before you enabled the tier) — re-run `--auth` to grant it. Older tokens\n  without a recorded scope are advertised as before, with the runtime insufficient-scope message as the\n  fallback.\n- **Read-only mode.** Set `MAILWARDEN_READONLY=1` (shorthand for `MAILWARDEN_TOOLS=read`) and only the\n  read tools (`search`, `get_thread`, `list_labels`, `list_snoozed`, `get_profile`, `what_changed`, `triage_digest`,\n  `list_unsubscribe`, `list_subscriptions`)\n  are registered — nothing that can change the mailbox or write\n  files is even advertised to clients (the filter tools, which need the broader `gmail.settings.basic`\n  scope, are excluded too). Recommended for shared/HTTP deployments that only triage.\n  It is also **the only tier whose no-send property Google enforces**: it holds a `gmail.readonly`\n  token, which Gmail's send endpoints reject outright. `manage` needs `gmail.modify`, and Gmail\n  *does* accept that scope for sending — mailwarden simply exposes no tool that would. So a `read`\n  deployment could not send even if this binary were replaced; a `manage` one cannot send because\n  there is nothing to call. (There is no send-free write scope to switch to — see\n  [SECURITY.md](SECURITY.md), threat 1.)\n- **Egress guard.** \"Nothing to call\" is no longer only a statement about the tool list. Every\n  authenticated Gmail request passes one checkpoint that allows exactly the endpoints mailwarden\n  uses and refuses the rest — with `messages.send`, `drafts.*`, `messages.import`/`insert`,\n  permanent deletion and every non-filter `settings` endpoint named in a deny list checked first, so\n  a later edit to the allowlist cannot reopen them by accident — including through the\n  `/upload/gmail/v1/...` route `googleapis` takes when a method is handed `media`. A request whose\n  host was rewritten (`GOOGLE_CLOUD_UNIVERSE_DOMAIN`, a `rootUrl` option) is refused before the token\n  leaves the process. Every method in Gmail's discovery document is tested against the guard. It\n  guards *this server*, not the\n  token: a stolen `gmail.modify` token can still send from elsewhere.\n- **Fenced downloads.** With `MAILWARDEN_DOWNLOAD_DIR` set, attachment writes are confined to that\n  directory (realpath-canonicalized, symlink-aware) and never overwrite an existing file. Without\n  it there is nothing to resolve the client-supplied path against, so `download_attachment` can\n  write anywhere this process can — which matters for `--http`, where the client is remote.\n  Starting `--http` without the fence therefore prints a warning naming the exposure (it stays a\n  warning, not a refusal: unlike a missing bearer token this needs an *authorized* client, and\n  existing deployments depend on the current behaviour). A `read`-tier deployment is silent — it\n  never registers the tool.\n- **Untrusted-content fencing.** Every tool result is wrapped in `<untrusted-tool-output>` markers\n  and stripped of invisible/BiDi-override characters, so clients can tell quoted mail content from\n  instructions. The strip also covers Unicode tag characters and the variation selectors supplement\n  (invisible ASCII smuggling), and it applies to `structuredContent` as well as the text copy — a\n  client reading the machine-readable half gets the same sanitized content.\n- **Live API, no copy.** No mailbox mirror or search index is stored anywhere. The only local state\n  is your OAuth token in `~/.mailwarden/`.\n- **Optional token encryption at rest.** `token.json` holds a refresh token; on disk it is protected\n  only by `mode 0o600` (a no-op on Windows). Set `MAILWARDEN_TOKEN_PASSPHRASE` to a passphrase and the token\n  is stored AES-256-GCM-encrypted (scrypt-derived key), so a *copy* of the file — a backup, a synced\n  folder, another machine — is useless without the passphrase. Re-run `mailwarden --auth` once after\n  setting it to encrypt the existing token. Note the boundary: this defends against file theft, **not**\n  against malware running as your user (which can read the passphrase from the environment too).\n\n## Quick start\n\n```bash\nclaude mcp add mailwarden -- npx -y mailwarden\n```\n\nThat's the whole install — `npx` fetches and runs the published package, no clone or build step. You only need Google OAuth credentials once (below).\n\n## Setup\n\nFirst time setting up a Google OAuth app? Follow the **[step-by-step setup guide](https://github.com/csitte/mailwarden/blob/main/docs/SETUP.md)** — 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:\n\n1. **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`.\n2. Put `credentials.json` in `~/.mailwarden/` (or set `MAILWARDEN_CREDENTIALS=/path/to/credentials.json`).\n3. Authorize once — opens a browser, stores a refresh token in `~/.mailwarden/token.json`:\n   ```bash\n   npx -y mailwarden --auth\n   ```\n   Scopes requested: `gmail.modify` (read + label/archive/trash) and `gmail.settings.basic`\n   (filter management only). If you authorized a version before filters existed, re-run `--auth`\n   once to grant the added scope. To hold a token that Gmail itself refuses to send with, authorize\n   with `MAILWARDEN_TOOLS=read` — see **Read-only mode** above.\n4. **Verify the setup** any time with the built-in doctor:\n   ```bash\n   npx -y mailwarden --check\n   ```\n   It checks `credentials.json`, whether a token exists (and if it's encrypted), whether the\n   granted scopes cover your enabled tiers, and makes one live Gmail call to prove the token\n   still works — printing a concrete fix for anything that's wrong, and exiting non-zero if so\n   (handy in CI/health checks). Diagnoses the common traps: no/`wrong` credentials file, never\n   authorized, an encrypted token with no `MAILWARDEN_TOKEN_PASSPHRASE`, a missing scope, or the\n   7-day \"Testing\"-consent token expiry.\n\n## Connect\n\n**Claude Code** (local stdio):\n```bash\nclaude mcp add mailwarden -- npx -y mailwarden\n```\n\n**Claude Code plugin** — the same server plus two skills: `/mailwarden:setup` walks you through the\nOAuth setup and diagnoses a broken one, and `/mailwarden:triage` carries the operating rules an\nassistant needs while working a mailbox — above all that `search` re-verifies and `bulk_modify` does\nnot, so a non-empty `unverifiedPredicates` means resolving the set with `search` before acting on it.\nThat rule protects the user only if the assistant follows it, and a tool description is read once per\ncall while a skill is read before the plan. The repo root is the plugin (`.claude-plugin/plugin.json`), so\nfrom a clone:\n```bash\nclaude --plugin-dir /path/to/mailwarden\n```\nIt is submitted to Anthropic's community marketplace; once listed, `/plugin marketplace add anthropics/claude-plugins-community`\nthen `/plugin install mailwarden@claude-community` does the same without a clone. The plugin runs the full\ntool surface — for a narrower tier (`MAILWARDEN_TOOLS=read`) or a second account, use `claude mcp add` with\nthe env you want instead (see [Config](#config-env) and [Multiple accounts](#multiple-accounts)).\n\n**Claude Desktop** — add to `claude_desktop_config.json`:\n```json\n{\n  \"mcpServers\": {\n    \"mailwarden\": { \"command\": \"npx\", \"args\": [\"-y\", \"mailwarden\"] }\n  }\n}\n```\nOr install the **MCPB bundle** (`mailwarden-<version>.mcpb`, attached to\n[GitHub releases](https://github.com/csitte/mailwarden/releases) from 0.10.0 on) as a Desktop extension — Settings →\nExtensions → *Install extension…* — the same server, self-contained at run time (no `npx`; Claude\nDesktop brings the Node runtime), with the tool tiers as a setting. The bundle is built from the packed\nnpm package (same file set as published; `npm run mcpb`, verified in CI: validated, unpacked and booted)\nand is the same file set Smithery distributes. The one-time `npx -y mailwarden --auth` still applies\n(Node needed once for that) — the bundle reads the same `~/.mailwarden/` token.\n\n**Smithery** — listed as [`csitte/mailwarden`](https://smithery.ai/servers/csitte/mailwarden), which serves\nthat bundle:\n```bash\nnpx -y @smithery/cli install csitte/mailwarden --client claude   # local stdio entry in the client's config\n```\nNote which of Smithery's two paths you take. The install above writes a plain local server entry: the\nprocess, your token and your mail stay on your machine, exactly as with `npx`. Adding it to Smithery's\n**toolbox** instead (`smithery mcp add`) also runs the bundle locally, but relays the tool traffic\nthrough Smithery's gateway so a remote client can reach it — the mailbox content in those responses then\npasses through a third party. That is a property of the gateway, not of mailwarden; if you want the\nno-third-party guarantee, use the local install, the npm package, or the `.mcpb` from the release page.\n\n**Remote (Streamable HTTP)** — for a VPS / claude.ai custom connector:\n```bash\n# Loopback + token required by default. For real hosting, bind outward and keep the token:\nMAILWARDEN_TOKEN=<secret> MAILWARDEN_HOST=0.0.0.0 npx -y mailwarden --http   # :8787/mcp\n```\nThen 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`.\n\n## Multiple accounts\n\nOne OAuth app (one `credentials.json`) can authorize several Gmail accounts. Each account keeps its\nown refresh token in a separate file, selected by `MAILWARDEN_ACCOUNT`:\n\n```bash\nmailwarden --auth --account work        # stores token.work.json\nmailwarden --auth --account personal    # stores token.personal.json\n```\n\nRun them side by side by registering the server **once per account**, each with its own\n`MAILWARDEN_ACCOUNT`. Every instance carries its own token, its own granted scopes and its own tool\nsurface, and a tool call acts on the account of the entry that carries it and on no other:\n\n```json\n{\n  \"mcpServers\": {\n    \"gmail-work\":     { \"command\": \"npx\", \"args\": [\"-y\", \"mailwarden\"], \"env\": { \"MAILWARDEN_ACCOUNT\": \"work\" } },\n    \"gmail-personal\": { \"command\": \"npx\", \"args\": [\"-y\", \"mailwarden\"], \"env\": { \"MAILWARDEN_ACCOUNT\": \"personal\" } }\n  }\n}\n```\n\nAccount names are **case-insensitive** — they become filenames, so `Work` and `work` would be the\nsame file on Windows/macOS. mailwarden lower-cases them (`--account Work` → `token.work.json`) so a\nname always maps to exactly one mailbox.\n\n**Which file `--auth` writes depends only on `--account` / `MAILWARDEN_ACCOUNT` — never on the\naccount you pick in the browser.** Authorizing a second mailbox *without* `--account` would\ntherefore aim straight at the first one's token file, so `--auth` checks first and **refuses**\nrather than replacing another mailbox's token; `--force` overrides it deliberately. The two knobs\nare not interchangeable: `MAILWARDEN_ACCOUNT` is the one for several mailboxes out of one config\ndirectory (it picks `token.<name>.json`), while `MAILWARDEN_DIR` moves the *whole* directory —\nuseful to keep setups apart entirely, but it does not give you a second account inside one.\n`npm run auth` from a repo clone passes neither, i.e. it always serves the default account.\n\n`mailwarden --check` shows the active account and lists the others it finds. With no\n`MAILWARDEN_ACCOUNT` set, everything uses the default `token.json` exactly as before — this is fully\nbackward compatible.\n\n### More than two accounts\n\nTwo entries are the easy case. Past that, two properties of this design start to matter.\n\n**Each instance brings its own tools.** The tier split is 8 `read` + 14 `manage` + 3 `filters`, so a\nfull instance advertises 25 tools and four of them advertise 100. Clients that search their tool\nsurface on demand absorb that; clients that hold every definition in context do not.\n\n**Several accounts in one client share one model context.** The account boundary binds a *call* to\none mailbox — it does not stop text read from one mailbox from prompting a call against another,\nbecause all of those tool surfaces are in front of the same model. That is a limit of the boundary,\nnot a defect in it; see threat 8 in [SECURITY.md](SECURITY.md).\n\nOne move answers both: **give exactly one mailbox write tools and leave the rest on `read`.**\n\n```json\n{\n  \"mcpServers\": {\n    \"gmail-main\":   { \"command\": \"npx\", \"args\": [\"-y\", \"mailwarden\"], \"env\": { \"MAILWARDEN_ACCOUNT\": \"main\" } },\n    \"gmail-work\":   { \"command\": \"npx\", \"args\": [\"-y\", \"mailwarden\"], \"env\": { \"MAILWARDEN_ACCOUNT\": \"work\",   \"MAILWARDEN_TOOLS\": \"read\" } },\n    \"gmail-club\":   { \"command\": \"npx\", \"args\": [\"-y\", \"mailwarden\"], \"env\": { \"MAILWARDEN_ACCOUNT\": \"club\",   \"MAILWARDEN_TOOLS\": \"read\" } },\n    \"gmail-archive\":{ \"command\": \"npx\", \"args\": [\"-y\", \"mailwarden\"], \"env\": { \"MAILWARDEN_ACCOUNT\": \"archive\",\"MAILWARDEN_TOOLS\": \"read\" } }\n  }\n}\n```\n\nThree things follow at once: the `read` entries only ever ask for `gmail.readonly`, the one scope in\nwhich no-send is enforced by Google rather than by mailwarden's tool surface; the four instances add\nup to 49 tools rather than 100; and an instruction injected into any of them finds no write tool for\nanother mailbox to reach for. When one of the read-only mailboxes does need cleaning up, hand that\nentry `manage` for as long as the work takes instead of permanently.\n\nSeparate clients — or separate sessions — remove the shared context entirely, at the price of never\nhaving two mailboxes in view at once. Worth it when several mailboxes genuinely need write tools;\notherwise the tier split is the cheaper boundary.\n\n**No tool reads across mailboxes.** `search`, `triage_digest` and `list_subscriptions` each serve\nthe one account their instance was configured with, so a question like \"which newsletter writes to\nall four\" is four calls whose answers the caller combines. In a setup this size it is worth calling\n`get_profile` before the first action that changes anything — it names the mailbox actually on the\nother end.\n\n## From source\n\n```bash\ngit clone https://github.com/csitte/mailwarden && cd mailwarden\nnpm install && npm run build\nnode dist/index.js --auth\n```\n\nA clone deliberately arrives without a `CLAUDE.md`: the working instructions for this repository\nare an internal document and are not tracked here. Nothing in the build, the tests or\n`npm run smoke` needs them. The rules a contributor — or a coding agent — actually has to know are\nin [CONTRIBUTING.md](https://github.com/csitte/mailwarden/blob/main/CONTRIBUTING.md) (design rules\nthat are not up for grabs, the build/test loop) and [SECURITY.md](SECURITY.md) (the threat model\nthose rules come from).\n\n## Config (env)\n\n| Var | Meaning |\n|---|---|\n| `MAILWARDEN_DIR` | config dir (default `~/.mailwarden`) |\n| `MAILWARDEN_CREDENTIALS` | path to `credentials.json` |\n| `MAILWARDEN_ACCOUNT` | select a named account (its token is `token.<name>.json`; names are lower-cased); unset = the default `token.json`. See [Multiple accounts](#multiple-accounts) |\n| `MAILWARDEN_TOKEN_PASSPHRASE` | passphrase → encrypt `token.json` at rest (AES-256-GCM); re-run `--auth` after setting |\n| `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) |\n| `MAILWARDEN_DOWNLOAD_DIR` | restrict `download_attachment` to this directory. Unset, the client picks any path this process can write to — `--http` warns at startup unless the `manage` tier is off |\n| `MAILWARDEN_READONLY` | `1` → register only the read tools (search/get_thread/list_labels/list_snoozed/get_profile/triage_digest/list_unsubscribe/list_subscriptions). Shorthand for `MAILWARDEN_TOOLS=read` |\n| `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 |\n| `MAILWARDEN_DEBUG` | `1` → print full errors with stack traces instead of a one-line message (for bug reports) |\n| `PORT` | HTTP port (default 8787) |\n| `MAILWARDEN_HOST` | HTTP bind address (default `127.0.0.1`; set e.g. `0.0.0.0` for remote hosting) |\n| `MAILWARDEN_TOKEN` | bearer token for the HTTP endpoint — **required** for `--http` unless overridden |\n| `MAILWARDEN_ALLOW_NO_TOKEN` | `1` → allow `--http` without a token (trusted/isolated networks only) |\n| `MAILWARDEN_ALLOWED_HOSTS` | extra comma-separated `host:port` values accepted by the loopback `Host` allowlist |\n\n## Status\n\nWorking and used in daily mailbox automation. Core Gmail tools + snooze implemented against `googleapis`, covered by a vitest suite (1197 tests — `npm run coverage`). Current version: see the npm badge above, the [changelog](https://github.com/csitte/mailwarden/blob/main/CHANGELOG.md), or [releases](https://github.com/csitte/mailwarden/releases). PRs welcome — [CONTRIBUTING.md](https://github.com/csitte/mailwarden/blob/main/CONTRIBUTING.md) covers the build/test loop and the design rules that are not up for grabs.\n\n## License\n\nMIT © C.Sitte Softwaretechnik\n",
  "bytes": 58885,
  "sha": "ccda90ffe69ea3a1e3e66f5052246838d76d8f4b037e4428bf07ea3d7957346b",
  "repo_slug": "csitte/mailwarden",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_csitte_mailwarden_d814f4ed/readme"
}