Skip to main content
Glama
README.md
# mailwarden

[![npm](https://img.shields.io/npm/v/mailwarden)](https://www.npmjs.com/package/mailwarden)
[![license](https://img.shields.io/npm/l/mailwarden)](LICENSE)
[![Node](https://img.shields.io/node/v/mailwarden)](package.json)
[![Website](https://img.shields.io/badge/Website-csitte.at%2Fmailwarden-2ea44f)](https://www.csitte.at/mailwarden/)

A 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**.

## 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
  answer `is:unread` from 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, so `search` re-verifies every hit against
  its live labels. Paginated via `pageToken`/`nextPageToken`. ([the measurements](https://github.com/csitte/mailwarden/blob/main/docs/gmail-thread-read-state-drift.md))
- **Sender authentication, not sender spelling.** `get_thread` reports 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 Gmail's own report only — the one
  written under the id `mx.google.com`; a message can carry forged ones of its own — says whether a passing
  DKIM signature belongs to the `From` domain, and says `unchecked` when nobody checked, because a
  missing check is not a passing one.
- **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. Failures are
  structured as well: a `code` and a `retryable` flag, 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`, `drafts.send`, permanent deletion and forwarding
  settings, whatever a compromised or careless caller asks for — and every other draft endpoint too,
  unless you switch on the opt-in [`drafts` tier](#drafts-opt-in--written-here-sent-by-you), which
  saves drafts for you to send yourself. **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 a `read`-tier deployment makes
  no outbound request at all. Details under
  [Security & privacy](#security--privacy) and
  [Unsubscribing](#unsubscribing--the-one-outbound-request).
- **Correct with real-world mail.** RFC 2047 headers decoded (`=?UTF-8?B?…?=` → readable text),
  bodies decoded by their bytes and their declared charset (text Gmail already converted to UTF-8 is read as UTF-8 whatever the header says, anything else in the charset it names; 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. Two capabilities are still unique to `mailwarden` as far as we have looked (mailbox-side snooze, search re-verification), and one deliberate omission is a security feature, not a gap. A third, sender authentication, was unique when it shipped and no longer is: `@agentcomms/gmail-mcp` has read `Authentication-Results` since 18 September 2026, trusting only the report whose authserv-id is `mx.google.com`. It has its own column below since 13 October 2026. Google's own server is also narrower than it looks: draft-only, and no trash, filters or unsubscribe.

<!-- comparison-table-verified: 2026-10-13 -->

| 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) | [aaronsb](https://github.com/aaronsb/google-workspace-mcp) | [klodr](https://github.com/klodr/gmail-mcp) | [agentcomms](https://github.com/crissmoldovan/agent-communications) | [gmail-tidy](https://github.com/imkath/gmail-tidy-mcp) |
|---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
| **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 | ✅ Gmail's own report only, values token-validated, DKIM alignment | — | — | — | — | — | ✅ Gmail's own report only, DKIM alignment | ⚠️ fail values from the first header, issuer unchecked |
| **Sweep / bulk over a query** — one action across every thread a search returns | ✅ 1000/req, partial-success | — | ⚠️ batch by explicit ids; filter `apply` relabels over a query, no re-check | — | ✅ previewed query, up to 10,000, no re-check | ⚠️ batch by explicit ids | ⚠️ batch by explicit ids, exact per-message undo | ⚠️ over a query, capped at 5,000, no preview or re-check |
| **Unsubscribe** — per-sender overview + RFC 8058 one-click opt-out, no send scope needed | ✅ | — | ⚠️ header shown, no action | — | — | — | — | ⚠️ link 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 | — | — | ⚠️ sender stats + newsletter list, no buckets |
| **Server-side filters** — rules that keep triaging with no assistant in the loop | ✅ never forwarding | — | ✅ | — | ✅ previewed; ⚠️ forwarding allowed unless draft-only policy | ✅ never forwarding | — | ✅ never forwarding or trash |
| **No send tools — by design** — a prompt-injected mail has no exfiltration path | ✅ no compose by default; ⚠️ opt-in draft-only tier | ⚠️ draft-only | ❌ sends | ❌ sends | ❌ sends; ⚠️ opt-in draft-only policy | ❌ sends | ❌ sends; ⚠️ per-message approval, in chat by default | ⚠️ draft-only |
| **Least-privilege tool tiers** — OAuth scopes derived from the tools you enable | ✅ | ⚠️ scope split | ⚠️ `--permissions` narrows scopes per service (cumulative or `+`-union); tiers narrow tools only | — | ⚠️ per-account read/readwrite, at call time | ⚠️ inverse: tools gated by granted scopes | ⚠️ tier per mailbox picks the scopes; writes checked against the grant at call time | — |
| **Token encryption at rest** | ✅ AES-256-GCM by default, key in the OS keystore; a passphrase where there is none | n/a (hosted) | ⚠️ file mode `0600`; bucket CMEK on GCS | — | — | — | ✅ OS keychain by default; owner-only file by choice | ✅ AES-256-GCM by default, key in the OS keystore |
| **No vendor cloud — you operate the server** | ✅ | ❌ Google-hosted | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Structured outputs** — every tool declares an `outputSchema` | ✅ | ✅ documented per tool in its reference | — | — | — | ⚠️ two tools (`download_email`, `download_all_attachments`), more planned | ✅ every tool | — |

<sub>Snapshot as of 13 October 2026 — the oldest of the seven column checks (Google's reference, last read that day), and so the only date the table as a whole can claim. Each repository column is brought forward by reading the diff against the revision recorded in [`docs/comparison-sources.json`](https://github.com/csitte/mailwarden/blob/main/docs/comparison-sources.json) whenever `npm run table-sources` reports a move. On 25 October 2026 four of them moved at once. `aaronsb` went to `9c9f8033` (v4.8.0), and two of its cells with it: since 16 October 2026 it applies a label change to everything a query matches, behind a preview that counts the set, keeps the ids on the server and hands back a token valid once for thirty minutes (up to 10,000 messages; the matches are not re-checked), and it creates, updates and deletes filters behind the same kind of preview — forwarding included unless its draft-only policy is set. `taylorwilsdon` went to `fcbf5a2c` (v2.2.0); `--permissions` now also takes a union such as `gmail:readonly+send` that grants only the named levels, which the least-privilege cell now names. `klodr` went to `e5c080c3` (3.0.0); `create_filter` now refuses a forwarding action whatever the scopes, and its filter cell says so. `agentcomms` went to `261cae4f` (0.28.0, with per-recipient rules on what it sends); no cell moves. On 8 November 2026 all six repository columns had moved again — `taylorwilsdon` to `aa91bf75`, `a-bonus` to `22856e1f`, `aaronsb` to `5bf9ef73`, `klodr` to `ed1e7785`, `agentcomms` to `42efa4c8`, `gmail-tidy` to `00921c8f` — and each diff was read with their draft tools in view; no cell moves. Google's tool reference, last read on 13 October 2026, documents an output schema on every one of its eleven tools; it is still Developer Preview, with no send, trash, filter, snooze, unsubscribe or query-driven tool. `—` = 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. `agentcomms` (`@agentcomms/gmail-mcp`, read at `01cee751` on 13 October 2026) was added the same day for two reasons: it is the only other server we know of that reads `Authentication-Results`, and it pins that read to Gmail's own report, which is stricter than what `mailwarden` did until then and the reason `mailwarden` now does the same; and its npm reach overtook `klodr`'s within a month of its first release. Its bulk path is per id, not per query, but every change returns an exact per-message undo — the design `mailwarden`'s `bulk_modify` adopted as `undo: true`. It sends, behind a per-message approval whose default is a yes in the conversation; its own README says the software cannot tell that yes from the agent's, and offers a terminal confirmation (`confirm`) or no sending at all (`never`) instead. `gmail-tidy` (`gmail-tidy-mcp`, read at `6df48c69` on 25 October 2026) was added that day because its stated position is the closest to this one: no send tool and no trash, a build test that fails on either, and filters that can neither forward nor trash. It does write drafts, which puts it on the send row next to Google's server rather than next to this one — its own README says plainly that its token could send. It was ahead of `mailwarden` on one row, and `mailwarden` has since taken the same design over: it encrypts the token by default, with the key in the OS keystore. Its bulk runs over a query, capped at 5,000 messages, with neither a preview nor a re-check, and it reads SPF/DKIM/DMARC failures from the first `Authentication-Results` header without asking which server wrote it. Its npm reach matched `mailwarden`'s within ten days of its first release. `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 was the first server here besides `mailwarden` that can be made to refuse sending at all — `GWS_SAFETY_POLICY=draft-only-email` blocks send, reply, replyAll and forward; `agentcomms` has since joined it with a `never` send policy — 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 filter row asks the same question from the other end, and a third project shows why it is one: `c0webster/hardened-google-workspace-mcp`, a fork of the `taylorwilsdon` column made for no reason but this one, removes `create_gmail_filter` and `delete_gmail_filter` outright because a filter can forward — which is exactly the risk, answered by dropping the capability. `mailwarden` keeps the capability and removes the risk instead: `create_filter` has no way to express a forward, and `list_filters` surfaces the ones that already exist so they can be audited. That fork also shows where a promise of this kind usually leaks, which is why the row above is about the tool surface and the [egress guard](#security--privacy) rather than about scopes: it drops `gmail.send` but keeps `gmail.compose` — commented "for draft creation/editing only (NOT sending)" — and `gmail.modify`. [Google documents the first as "Manage drafts and send emails" and the second as "Read, compose, and send emails"](https://developers.google.com/workspace/gmail/api/auth/scopes), so the promise does not rest on one scope too many but on two, and `gmail.modify` alone would be enough — the same scope this project holds in its own `manage` tier, which is exactly why the row points at the tool surface. Its token can send; only its tool list cannot. 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` keeps a random key in the OS keystore by default, as `gmail-tidy` does, and falls back to a passphrase you set where no keystore is reachable — and to a plaintext file where you set none, which is why the cell names both; `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` and `gmail-tidy` (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>

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`; by default and with the key in the OS keystore, `agentcomms` and `gmail-tidy`, whose design `mailwarden` has since taken over), 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 — `taylorwilsdon`'s filter `apply`, `aaronsb`'s previewed `modifyMatching` and `gmail-tidy`'s `organize_emails` now do the first half, all over `messages.list`, and none of them the second.

### 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 — or, with the opt-in `drafts` tier,
tools that save drafts and none that send them. 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](#security--privacy) refuses `messages.send` and `drafts.send` in the server itself, and every
other draft endpoint unless the `drafts` tier is on, 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](#more-than-two-accounts) 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](https://github.com/csitte/mailwarden/blob/main/docs/gmail-thread-read-state-drift.md)** — 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. Both act on what the index returns — and `bulk_modify` reports `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. What its `previewToken` does close is the other gap, between showing a set and changing one: without it, the write runs the query again and changes whatever matches by then.

**The filter sweep does not report that field, deliberately.** Its query is not yours — `create_filter` builds one from the criteria, parenthesising a `query` criterion and double-quoting every `from`/`to`/`subject` value, and both of those switch the predicate derivation off. The list would come back empty for every filter you can define, and an empty list here would read as "nothing to distrust" when it means "could not tell". So the caveat stays in prose, where it holds without exception: **treat a sweep as acting on the index's answer, whatever it matched.** Use `verify: true` to learn what the sweep actually changed, and `search` when the *set* has to be right before anything is written.

**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):

```bash
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`](https://github.com/csitte/mailwarden/blob/main/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.

```jsonc
{
  "spf": "pass",   "mailedBy":   "forwarder.example",        // envelope sender SPF checked
  "dkim": "pass",  "signedBy":   "authority.example",         // the signature DMARC can pass on
  "alsoSignedBy": ["forwarder.example", "esp.example"],      // further signatures that passed
  "dmarc": "pass", "headerFrom": "authority.example",        // the From domain DMARC evaluated
  "dkimAligned": true,                                       // a passing signer is the From domain
  "authservId": "mx.google.com",                             // who asserts all of the above
  "returnPath": "bounces+…@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 domains do not have to match, and a mismatch is not a finding.** The object above has the
shape of a real receipt, forwarded through a custom domain on a mail-routing service before it
reached the mailbox. Forwarding rewrites the envelope sender (`mailedBy` and `returnPath` become the
forwarder) and adds the forwarder's own DKIM signature, while the sender's signature travels on
intact behind it — so DMARC still passes, and `headerFrom` still names the original sender. That is
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.

**Several signatures: the one that counts is reported.** A message often carries more than one
DKIM signature — the sender's, its mail provider's, a forwarder's. `signedBy` is the passing one
aligned with the `From` domain wherever there is one, because that is the one DMARC passes on;
`alsoSignedBy` names the other domains whose signature also passed, which is where a forwarder
shows. Through 0.25.0 the *first* signature was reported, and a re-signing forwarder puts its own in
front: in a mailbox that receives through Cloudflare Email Routing, 156 of 167 received messages
read as `dkimAligned: false` although each carried an aligned, passing signature further down
(measured 30 October 2026, see `docs/measurements.json`).

**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".

**`dkimAligned` answers the question a signature raises.** Anyone can sign mail with a key for a
domain they own, so `dkim: "pass"` alone says only that *someone* signed. `dkimAligned` says whether
that someone is the `From` domain, or a parent or child of it. For two sibling subdomains it says
nothing: `news.bank.example` and `mail.bank.example` are aligned, `a.co.uk` and `b.co.uk` are not,
and without a public-suffix list the two cases look the same — so the field is absent rather than
wrong. `false` means that no passing signature belongs to the `From` domain — a forwarder that
altered the message (a mailing list adding a footer breaks the sender's signature) leaves only its
own. It is absent when DKIM did not pass. As with the domains, `false` is an explanation, not a
verdict — `dmarc` remains the check to read first.

**What `unchecked: true` means.** The message carried no `Authentication-Results` header from
Gmail — 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 **Gmail's own** report is read: the topmost
one whose authserv-id is `mx.google.com`, the trust boundary RFC 8601 tells a reader to rely on.
A report under any other id is never read, however convincing it looks, and a message that has
none from Gmail is `unchecked` even if it carries a forged one under another id. The limit: mail that
reached the mailbox without passing Gmail's inbound mail server — fetched from another account over
POP, or inserted by an app — may carry no Gmail report at all, and a forged one that *claims*
`mx.google.com` is then indistinguishable from the real thing. Mail Gmail received itself always
carries its own report on top, so there a forgery is never read; `otherReports` counts every report
that was not read. (Through 0.22.1 the first report was read whatever its id — right wherever Gmail
had written one, and wrong exactly where it had not.) 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

| Tool | What it does |
|---|---|
| `search` | Gmail query syntax → thread summaries (from/subject/date of the first message, `lastMessageId`/`lastMessageDate`/`lastMessageFrom` of the last and `lastMessageSent` when that was you, `unread` as a boolean, `attachments` by name, type and size, labels, Gmail's snippet — which is the last message's); 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, junk 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), `displayNameSpoof` (the From display name shows an address or URL on another domain than the one the mail came from — `"service@paypal.com" <x@evil.example>`), `lookalikeDomain` (a label of the From domain mixes Latin with Cyrillic, Greek or Armenian letters, as homograph domains do) — 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) |
| `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, `alsoSignedBy` for further domains whose DKIM signature passed, `otherReports` for reports that were not read, `dkimAligned` for whether a passing DKIM signer is the `From` domain, and `unchecked: true` when the message carried no report from Gmail — 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; it is deprecated in favour of `bodies: "none"` (same quota, real attachment list), and combining it with `bodies` is rejected as `invalid_input`. For a long or HTML-heavy thread, shrink the answer instead: `bodies: "text"` keeps only the plaintext (an HTML-only mail is reduced to text and marked `bodyFromHtml`), `bodies: "new"` is `text` without the quoted history at the end of a reply (the trailing `>` block with its attribution line, or an Outlook *Original Message* block; inline answers, answers below a quote and forwarded messages, also an Outlook forward by its Fwd/FW/WG subject, stay whole; an answer below an Outlook plain-text header block goes with it) and marks a cut message `quotedStripped`, `bodies: "none"` keeps headers, authentication and the real attachment list without any body, `lastN` returns only the newest N messages and `afterMessageId` only those after one already read. Same quota whatever is kept; `messageCount` and `omittedMessages` always say how much of the thread the answer shows |
| `read_attachment` | The text inside an attachment, without saving it: plain text (txt, csv, xml, json, ics, …), a gzip file, or the text entries of a ZIP archive — DMARC aggregate reports, exports, logs. The format is decided by the bytes, never by the declared name or type, and text that is not UTF-8 (an Excel CSV from Windows) is read as windows-1252 and marked `guessedCharset`; binary formats are refused with a pointer to `download_attachment`. Every size is capped before it is spent (5 MiB unpacked per entry, 20 MiB per archive, 50 entries), so a zip bomb stops at the limit. One answer carries at most `maxChars` characters of text across all entries (default 30,000, at most 100,000); a cut entry reports `nextOffset`, and `offset` continues it. For an archive, `list: true` shows names, sizes and text lengths first, and `entry` (a name or a pattern like `*.csv`) reads only what is needed, naming the rest in `notSelectedNames`; binary, encrypted, oversized and nested-archive entries are listed as `skipped`, not opened. Nothing is written to disk, and the text is fenced and stripped of hidden characters like a mail body |
| `list_labels` | All labels (system + user) |
| `get_profile` | Connected account's address, total message/thread counts and the mailbox's current `historyId` — confirm *which* mailbox is wired up before acting. Also *which server* answers: `serverVersion` (bare only for a release build, otherwise with the commit, e.g. `0.23.0+a5ff4a7`), `build`, `serverPath` (the directory the running code was loaded from) and `account`, for the moment a documented tool seems missing and the question is whether the build you think is running actually is |
| `what_changed` | Mailbox events since a `historyId` you hold — arrivals, removals, labels on and off, in one call |
| **`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 |
| `follow_ups` | Who is waiting on whom: threads whose last message is yours and unanswered for a few days (a note to your own address is left out; one other recipient in To or Cc keeps it), and inbox threads whose last message came from a person rather than a newsletter or a no-reply notice. Decided on each thread's last message and its **live** labels, so a thread the index still files under the inbox after it was archived is dropped |
| `list_unsubscribe` | What opt-out options a thread advertises (`List-Unsubscribe`), plus body links when it advertises none — contacts nobody |
| **`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 |
| `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 |
| `modify_labels` | Add/remove labels by **name or id** — an unknown name in `add` is auto-created (archive = remove `INBOX`, read = remove `UNREAD`). Takes `threadId`, or `threadIds` (a list of up to a hundred) to apply one change to an approved list in one call; the answer then reports `changed`, `failed` and `notAttempted` per thread |
| **`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 — and, when anything matched, returns a `previewToken`: the server holds that exact message set for half an hour, and the write that passes the token back with the same query and label change acts on that set and nothing else. Mail that began to match in between is left alone and counted in `fromPreview.newMatches`; a reworded query or a different change is refused before the first write; a token works once. `undo: true` reads each affected thread's labels live before writing and returns `undo`, the exact per-message inverse (up to 500 messages; with `undo: true` nothing is written unless the plan can be built) |
| `undo_bulk_modify` | Replays the `undo` plan a `bulk_modify` returned: each message gets back exactly the labels it lost and loses exactly the ones it gained, so mail that was already in the target state stays as it was — which swapping `add` and `remove` would not do. Takes label ids only, as the plan carries them; a label the bulk change created stays in the mailbox, empty |
| `archive` / `mark_read` / `mark_unread` | Convenience wrappers; like `modify_labels` they take `threadIds` for a list |
| `trash` / `untrash` | Move to / restore from Trash |
| `download_attachment` | Save an attachment to a local path (never overwrites — collisions get a numeric suffix) |
| `export_thread` | Write a whole thread to a local file instead of returning it — Markdown for people (headers and attachment names as code, so a preview never loads an image or follows a link the sender wrote; sender authentication; each body in a fence the mail text cannot close) or JSON as `get_thread` returns it. Same path rules as attachments |
| **`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)) |
| **`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 |
| **`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. `dryRun: true` answers "what is due right now?" (`dueLabels`/`dueThreads`) without waking anything |
| `list_filters` | All Gmail filters (criteria + label actions); surfaces any `forward` address on existing filters for auditing |
| `preview_filter` | Check a filter before creating it: how many messages its criteria match today (`currentMatches`, from the same index an `applyToExisting` sweep would use), which existing filters have exactly the same criteria (`sameCriteria`) and which share a `from`/`to`/`subject`/`query` value with it (`overlapping`, with the shared fields named), which `addLabels` names would be created, and whether the action trashes every future match. Read-only; same validation as `create_filter` |
| `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 |
| `create_draft` | **`drafts` tier, off by default.** Save a reply (recipients, subject and threading taken from the thread) or a new message as a Gmail draft, optionally with attachments — files from `MAILWARDEN_ATTACH_DIR`, or attachments of a message in the reply's thread. Never sent — you send it from Gmail. `from` picks one of the account's send-as addresses; `quote` quotes the answered message below a reply; the sender's signature from Gmail's settings is added unless `signature: false`; `forward: true` forwards a mail of the thread, with its attachments, to the people you name. A new message, any address the thread did not already contain, any file from the disk and any sender other than the main address is confirmed in a client dialog; the exceptions are a draft to your own address alone with `MAILWARDEN_DRAFT_SELF_WITHOUT_DIALOG=1`, and every draft with `MAILWARDEN_DRAFT_CONFIRM=off` ([details](#drafts-opt-in--written-here-sent-by-you)) |
| `update_draft` | **`drafts` tier.** Replace the text, and optionally the subject, recipients or attachments, of a draft `create_draft` wrote; any other draft is refused, and so is one changed in Gmail since (it takes the draft's last `messageId`). Attachments not mentioned, and the sender, are kept; `quote` quotes the answered message again (`forward` forwards the mail again), and the signature is added again unless `signature: false`. A new address, a new file or a new alias sender is confirmed like one in `create_draft` |

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.

A **failure** is structured too: `isError` plus a fenced JSON body with a `code`
(`not_authorized`, `needs_reauth`, `insufficient_scope`, `forbidden_operation`, `not_found`,
`rate_limited`, `upstream_unavailable`, `network_error`, `invalid_input`, `declined_by_user`, `internal_error`) and a
`retryable` flag, alongside the sentence a human reads. So "wait and try again" versus "re-run
`mailwarden --auth`" is something a client can decide, not something it has to infer from wording
that may be reworded next release. (No `structuredContent` on errors: that is validated against the
tool's outputSchema, which describes a success.) A `rate_limited` failure also carries
`retryAfterSeconds`, because there the wait *is* the remedy. An `insufficient_scope` failure names
the gap rather than the possibilities — which scope is missing, what it covers, and what the saved
authorization grants instead — by reading the token's own scopes when it lands, and the server
prints the same line on stderr at startup. It explains the refusal rather than preventing it:
the call still goes to Google, and Google is what turns it down. That matters most with a passphrase-encrypted token: the tier gate
at registration cannot decrypt it, so the surface is advertised in full and the gap would otherwise
surface as a bare 403 mid-task. A token encrypted with a keystore key keeps its scopes readable and
is gated like a plaintext one.

### Quotas: the one failure that fixes itself

Gmail bills each call against a per-user budget of **6,000 units per minute**, and the units are
not uniform — fetching a thread is the expensive part, in whatever format it is fetched:

| Tool | Units per call |
|---|---|
| `search` | 10 per list page, plus 40 for every candidate thread it fetches — about 1,000 at the default size, up to about 4,000 when the query carries a read-state or category condition |
| `get_thread` (full and metadata alike) | 40 |
| `trash`, `download_attachment`, `read_attachment` | 20 |
| `export_thread` | 40 |
| `archive`, `mark_read`, `untrash` | 10 per thread |
| `modify_labels`, `snooze` | 10, plus 1 for each label looked up by name and 5 for each label it has to create |
| `bulk_modify` | 5 per 500 matching messages, plus 50 per 1,000 modified; `verify: true` adds 40 per thread, `crossCheck` 5 per condition and 500 matches, `undo: true` 40 per thread; a write with `previewToken` lists once more to report `fromPreview` |
| `undo_bulk_modify` | 50 per step, plus 40 per thread with `verify: true` |
| `follow_ups` | 1 for the profile, 10 per list page, plus 40 per thread scanned — up to 50 threads at the defaults, about 2,000 |
| `list_labels`, `get_profile` | 1 |

`mark_unread` costs what `mark_read` does.

Two consequences worth knowing before you build on this. **Parallel agents share one budget** —
they reach Gmail through a single server process as a single user, so four of them reading threads
at once spend four times as fast. And **`bulk_modify` is cheaper than looping from six threads
on**: up to 500 matching messages it costs 55 units, the price of five and a half `archive` calls,
and it grows by the thousand messages, not by the thread. `verify: true` reverses that — reading
the labels back costs 40 per thread, more than the loop it replaced.

If you do exceed it, mailwarden fails the call with `rate_limited` and `retryAfterSeconds: 60`.
**That failure is temporary and waiting genuinely fixes it** — the budget is counted per minute.
mailwarden absorbs one such wait (about 20 seconds) itself before reporting; past that it hands the
decision back rather than holding your tool call open. That holds for the tools that fetch many
threads in one call too: `search` fails with `rate_limited` rather than returning a list the quota
cut short, and `bulk_modify` with `verify` and `list_subscriptions` stop fetching and report what
they did not reach as `unverifiable` and `unknown` (for `bulk_modify` the change is already made,
and failing would hide it). Note that Gmail reports this as a `403` as readily as a `429`, so a
client keying on the status alone will mistake it for a permission problem — key on the `code`.

### 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_snoozed` tool),
- 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`, and `size` + `sizeComparison` (`smaller`/`larger`, given together).
  At least one is required.
- **Actions (label only):** `addLabels` / `removeLabels`, by name or id (an unknown name in
  `addLabels` is 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: true` to 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 to `maxMessages`,
  default 1000; same unverified-index caveat as `bulk_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 (`negatedQuery` or `hasAttachment:false`) is refused for `applyToExisting`
  because it would match almost the whole mailbox — create such a filter without the flag.
  The outcome comes back under `applied` (the `query` used, `matchedMessages`/`submittedMessages`/`submittedThreadCount`
  counts, `capped` when the match set hit `maxMessages`, per-chunk `failed`, and an `error` string if the whole
  pass failed); it's `null` when `applyToExisting` was not set. `submittedMessages` is what was handed to the API,
  not what changed — pass `verify: true` alongside `applyToExisting` to read the labels back and get
  `applied.verified` `{applied, notApplied, unverifiable}`, the same check as `bulk_modify`'s `verify`
  (one extra read per affected thread, so off by default). The filter is created first, so a partial or
  failed backlog pass is *reported* in `applied`, never raised — the rule still stands.
- **No forwarding** — see [Security & privacy](#security--privacy).
- Requires the `gmail.settings.basic` scope; re-run `--auth` once if you authorized an older version.
  Not available in read-only mode.

### Unsubscribing — the one outbound request

`list_unsubscribe` (read tier) reports what the sender offers, without contacting anyone. It reads the
newest message that actually carries a `List-Unsubscribe` header — a reply threaded onto a newsletter
sits at the end and advertises nothing, which would otherwise read as "this list has no opt-out".
When a thread advertises no opt-out header at all, `list_unsubscribe` looks in the message body and
reports the unsubscribe links it finds there as `bodyCandidates` — plenty of senders put the link only
in the footer, and answering "no opt-out options" for them is true about the headers and wrong about
the mail. Those links are **reported, never fetched**, they cannot be handed to `unsubscribe`, and
`hasUnsubscribe` stays false for them, because that flag has always described the headers. The search
costs one full thread fetch and happens only in that case.
`list_subscriptions` (read tier) does the same across a whole slice, grouped by sender, so you can see
*who* keeps writing and which of them can actually be left — one header fetch per sender rather than
per thread. `unsubscribe` and `bulk_unsubscribe` (manage tier) act on it — and that is the **only**
place mailwarden ever talks to a host that isn't Google, so the rules are tight:

- **There is no URL parameter.** The endpoint comes from the message's own header and nowhere else.
  A URL argument would let a prompt-injected mail turn the tool into an exfiltration channel
  (mailbox content in a query string); the header cannot carry data the model chose. This is also why
  a body link is only ever reported: it is a URL the sender wrote into the text, and fetching one would
  reintroduce exactly the request this rule exists to prevent — by a path that never passes the guard.
  Another Gmail server, `navbuildz/gmail-mcp-server`, does fetch them, following redirects, when the
  header is missing.
- **Only RFC 8058 one-click** is performed — the sender must have opted in via `List-Unsubscribe-Post`.
  A plain `https:` link is meant for a human in a browser and is handed back, not fetched.
- **`mailto:` opt-outs are never performed.** They would require sending mail, which mailwarden cannot
  do. The address is reported so you can act on it yourself.
- **Fixed request, discarded response.** The POST body is always `List-Unsubscribe=One-Click` and is
  never derived from anything; the response body is cancelled unread. What returns to the model is the
  status code and the URL actually called — no content from the endpoint, so it cannot answer with
  instructions. (A 301/302/303 redirect is followed as a GET, i.e. with no body at all.)
- **One request per sender, sequentially, inside one budget.** `bulk_unsubscribe` takes thread ids
  (never a query — a query-driven bulk would fire off a request per matched sender before anyone had
  looked). Threads from a sender whose request already went out are reported with `duplicateOf` and
  cost no second request: two threads from one list share an opt-out, and calling it twice only
  confirms your address twice. A sender is only recorded once a request actually *reached* an
  endpoint, so a refusal or a dropped connection still leaves the next thread its own try — and if
  the skipped thread advertises a *different* endpoint, the reason says so, since one sender can run
  several lists. **That memory spans calls** for as long as the server runs, and `unsubscribe` shares
  it: a call that times out is safe to repeat, and asking twice for the same newsletter contacts the
  sender once. Pass `force: true` to `unsubscribe` for a deliberate second attempt — after an endpoint
  answered 500, say. It is kept in memory only: persisting it would mean a second kind of local state
  beside the token, which this server deliberately does not keep, so a restart forgets.
  Capped at 25 threads and 60 seconds per call; whatever the budget doesn't cover comes
  back as `skippedOutOfTime` rather than silently undone. None of it can be reversed, which is why all
  three limits exist.
- **SSRF guards.** https only, default port only, no credentials in the URL, and every hop — including
  redirects, followed at most 3 times — must resolve exclusively to globally reachable addresses. The
  check parses each address to its bytes and matches it against the IANA special-purpose registry, so
  every spelling of the same address gets the same verdict (`::1` and `0:0:0:0:0:0:0:1` alike); an
  address that does not parse is refused. DNS resolution and all hops share one 10-second budget. Not
  rebinding-proof (`fetch` resolves again when it connects) — see [SECURITY.md](SECURITY.md); what
  survives that gap is a blind POST whose response is never read.

**Check it against your own mail before you trust it.** From a repo clone (repo-only, not in the
npm package), after `npm run build` and `mailwarden --auth`:

```bash
node scripts/probe-unsubscribe.mjs --vet          # category:promotions, 25 threads
node scripts/probe-unsubscribe.mjs "from:substack.com" --max 50 --vet
```

It prints each real `List-Unsubscribe` header next to what the parser made of it, and `--vet` also
runs the endpoint through the URL vetting and the address guard — so you see both whether the parser
understood the header *and* whether the guards would have let that opt-out through. Strictly
read-only: no request is ever made to a sender, and nothing in the mailbox changes.

What it can't undo: the request tells the sender your address is live. A sender that ignores its own
opt-out is beyond any client's reach — pair `unsubscribe` with `create_filter` or `trash` for those.
Not offering an automatable option is reported as `unsubscribed:false` with the alternatives, not as an
error. A `read`-only deployment gets `list_unsubscribe` and `list_subscriptions`, and never makes the
request at all.

## Drafts (opt-in) — written here, sent by you

The `drafts` tier saves mail as Gmail drafts and does nothing else with it: the draft waits in
Gmail's Drafts until you open it and press Send. It is **off unless you name it** —
`MAILWARDEN_TOOLS=read,manage,filters,drafts`, or `read,drafts` for a deployment that only reads
and drafts — and with it off the server has no tool that writes a message and the egress guard
refuses every draft endpoint, exactly as before. It needs `gmail.modify`, so `read,drafts` gives up
the `gmail.readonly` token of a `read` deployment; re-run `--auth` with the tier enabled.

- **`create_draft`, reply:** pass a `threadId`. The recipients come from the message being answered
  (`Reply-To`, else `From`; the original `To` when that message is yours; `replyAll` adds the other
  `To` and `Cc`), the subject becomes `Re: …` (one that already says reply, "AW:" or "SV:" for
  example, stays), and `In-Reply-To`/`References` keep it in the thread.
  `cc`/`bcc` add people.
- **Quote** (`quote: true`, replies only, both tools): the answered message goes below the text,
  the way a mail client quotes it — a line such as "On Thu, 5 Nov 2026 at 9:56 Ann
  <ann@example.org> wrote:" and the message. The draft then carries the content twice, as
  `multipart/alternative`: as plain text with every line prefixed `> `, and as HTML in the markup
  Gmail itself writes for a quote (`gmail_quote`), so Gmail's editor and the reader see a quote
  block like one of Gmail's own. What the markup does not do is fold the quote behind "...": in
  two tests (sent and received in Gmail, a text quote and the HTML quote of the same Outlook mail)
  Gmail folded only the lines that repeated an earlier mail of the thread, both times from the
  signature on, so a quote may stay partly open. Quoting the mail's own HTML, images included,
  might fold more; it is exactly what is kept out: the HTML holds nothing but that text, escaped —
  no markup, image, link or style of the quoted mail is carried over. Runs of spaces and
  indentation stay as the text has them. The line names the sender as the mail's `From` does, and
  its language and time
  come from the computer mailwarden runs on (German there gives "Am Do., 5. Nov. 2026 um 09:56 Uhr
  schrieb …"; any language other than German and English gets the English wording). A mail with
  only HTML is reduced to text first; Outlook's placeholders for inline images (`[cid:…]`) are left
  out. A mail with no readable text is answered without the quote, and the result says so
  (`quoted: false`). `update_draft` replaces the whole text, quote included, so it takes
  `quote: true` again. Off by default.
- **Signature** (`signature`, both tools, on by default): the signature Gmail's settings hold for the
  sender — the alias's own when the draft goes from an alias — goes below the text, after a `-- `
  line, and in a reply above the quote, where Gmail puts it. The draft then also carries an HTML
  rendering, with the signature as Gmail stores it: links and formatting included, since you wrote it
  in Gmail's settings and no mailwarden tool can change it. `signature: false` leaves it out; an
  account without a signature gets none, and a signature that is only an image is left out, because
  the text part would carry nothing of it. A text that already ends with the signature, say one read
  back from the draft, is not signed twice. The result says `signed`. A draft without signature, quote
  or forward is plain text only, as before.
- **`create_draft`, new message:** omit `threadId`, pass `to` and `subject`.
- **`create_draft`, forward:** pass a `threadId`, `forward: true` and `to` (and optionally the
  `messageId` of the mail; default the newest message that is neither a draft nor an emoji
  reaction). The subject becomes `Fwd: …` (a subject that already says forward, "WG:" or "TR:" for
  example, stays), and below the text and the signature comes the forwarded mail's
  From/Date/Subject/To/Cc block and its text (the text part, or the HTML reduced to text when there
  is none; never its markup, images or styles); in German
  for a German locale, in English otherwise. Its attachments come along unless
  `forwardAttachments: false` (inline images are not attachments; a mail in the trash or spam
  forwards without its attachments). The draft goes into the mail's
  thread, as Gmail files a forward. A recipient the mail never went to is new and asked about, and
  so is every attachment going to them. `update_draft` takes `forward: true` to forward the mail
  again below a new text, like `quote`.
- **`update_draft`:** replaces the text of a draft `create_draft` wrote. Such drafts carry the label
  `MCP/Composed` (`MCP/Drafts` before 0.31.0; a draft still carrying that name is accepted and comes
  out under the new one); a draft without either is refused, and no label tool can add either
  label, so a draft you wrote yourself cannot be handed to it. It also takes the draft's
  `messageId` from the last `create_draft` or `update_draft`: every save gives a draft a new one,
  Gmail's editor too, and Gmail keeps the label when you edit the draft there — so a draft you
  changed in Gmail since is refused, with its current `messageId`, instead of overwritten.
- **Sender** (`from`, both tools): your main address or one of the account's verified Gmail send-as
  addresses, such as a band's or a firm's; any other is refused, and a new one is added and verified in
  Gmail's settings, not here. Without `from` a new draft goes from your main address and
  `update_draft` keeps the sender the draft has. The result always names `from`; a reply to a mail that
  was sent to one of your aliases offers that alias as `suggestedFrom`, which is only used when it is
  passed as `from`. The list is read live from Gmail on each call, with the permission the tier
  already has: no new grant, no new sign-in. A reply-all leaves every one of your send-as addresses
  out, so a band does not write to itself.
- **Attachments** (`attachments`, at most 10 files and 25 MB together) come from exactly two places.
  A **file** only from the folder named in `MAILWARDEN_ATTACH_DIR` — a path relative to it or an
  absolute path inside it; `..`, symlinks and junctions that lead out, hard links and (on Windows)
  file streams are refused, and without the setting no file can be attached at all. Point it at a
  folder of its own that holds only what may go out; a drive root, the home directory or a folder that
  contains it — also when reached through a link — and on Windows any path that does not start with a
  drive letter are refused at startup. mailwarden never writes into
  that folder — `download_attachment` and `export_thread` refuse it — so only you put files there. An **attachment of a mail**
  only from a message in the reply's own thread — not a draft, not in the trash or spam — and without
  the dialog only to people that message went between (a sent forward sits in the same thread, so the
  thread alone says too little), named by `fromMessage` and `filename` (Gmail issues
  a new `attachmentId` on every read, so the id cannot name it), and never for a new message. In
  `update_draft`, leaving `attachments` out keeps every file the draft carries, a list replaces them
  (`{ "kept": "<filename>" }` keeps one), `[]` removes them. The result lists every attachment of the
  saved draft with name, type, size and where it came from; the dialog names a file by its real path
  in the folder, also when `filename` gives it another name in the mail. The draft is saved through the same
  `drafts.create`/`drafts.update` calls as before — Gmail took drafts with up to 35 MB of attachments
  that way when measured on 4 November 2026 — so the egress guard gained no endpoint; the upload
  endpoint stays refused. Large files take time: a 24 MB attachment took about 40 seconds to save.
  Gmail's 25 MB limit counts the files, not the encoded message: a draft with 24 MB of attachment
  (33 MB as a message) was sent and delivered in a test the same day. A server outside Gmail may still
  refuse a message that large, since some count its encoded size; above about 18 MB of files, expect
  that a recipient might not take it.
- **Who is asked.** Any address the thread did not already contain — every recipient of a new
  message, a `cc` on a reply that names a stranger, an address added in an update — is confirmed in
  the client's own dialog (MCP elicitation), whatever `MAILWARDEN_CONFIRM` says, and refused on a
  client that cannot show one. A reply that stays within its thread follows `MAILWARDEN_CONFIRM`.
  A narrow opt-in relaxes this for clients that have no usable dialog: `MAILWARDEN_DRAFT_SELF_WITHOUT_DIALOG=1`
  saves a draft without asking when every new recipient is your own address (the one the signed-in
  account reports, compared case-insensitively; no aliases, no `+tag`, no dot folding). Nobody is
  asked, even on a client that advertises dialogs: some, such as the Grok Bot app, advertise MCP
  elicitation and silently decline it, which would leave the switch without effect. An explicit
  `MAILWARDEN_CONFIRM=require` is still honoured, and
  one other address in `to`, `cc` or `bcc` puts the draft back under the rule above. A draft to
  yourself cannot reach anyone else, so an injected mail gains nothing by steering one there.
  A `Reply-To` address counts as part of the thread only if it is also in `From`, `To` or `Cc` —
  the sender alone picks it, so a reply to a different `Reply-To` is confirmed too.
  **A file from the disk is confirmed like a new address**, also in a reply to someone already in
  the thread: it reaches a place it was not before, and the sender of an injected mail is always a
  known recipient of their own thread. `MAILWARDEN_DRAFT_SELF_WITHOUT_DIALOG=1` covers a file only
  when every recipient is your own address. An attachment taken from the thread follows the rule for
  its recipients only when every recipient of the draft is someone that mail went between; otherwise
  it is confirmed like a file. One already in the draft is not asked about again.
  **A sender other than your main address is confirmed the same way**, in a reply to the thread's own
  people too, so an injected mail cannot get a draft prepared in a band's or a firm's name unseen;
  `MAILWARDEN_DRAFT_SELF_WITHOUT_DIALOG=1` covers it only when every recipient is your main address.
  A sender the draft already had is not asked about again.
  **All of this can be turned off: `MAILWARDEN_DRAFT_CONFIRM=off`** saves every draft without a dialog —
  new recipients, files, a mail's attachments and an alias sender alike — for an operator who reads each
  draft in Gmail before pressing Send and wants that review to be the check. Nothing is sent either
  way, but the protection against an injected mail steering a draft is then that review alone (see
  [SECURITY.md](SECURITY.md), threat 10). The tool descriptions tell the model so, and to name the
  recipients, sender and files of every draft. `MAILWARDEN_CONFIRM=require` still asks.
  A dialog nobody answers gives up after ten minutes with a retryable `declined_by_user` that says
  it timed out; nothing is written.
  The dialog shows an alias sender first, then lists the recipients and marks the new ones, then each
  attachment with its size and origin. A new recipient comes with what is known about it: whether
  the account wrote to it before (one search of the sent mail per address, for the first ten; a
  failed search says nothing rather than "never"), whether only the answered mail's `Reply-To` names
  it, whether its domain is one or two letters off a domain of the thread or the account
  (`examp1e.com`, `example.co`; one letter for a domain under ten characters), and whether the
  domain is internationalised (`xn--…`). The result repeats these as `recipientNotes`, so with the
  dialog off the model can still pass them on. Facts, not verdicts: nothing is refused because of
  them.
- **What it does not do.** No sending, no files from anywhere but `MAILWARDEN_ATTACH_DIR`, no
  attachment of a mail outside the reply's thread, no display names (addresses are saved bare), no
  HTML of your own — the body is plain text; only a quote, a forward or the signature adds an HTML
  rendering of that same text (plus the signature's own HTML from Gmail's settings), so Gmail shows
  the quote as a quote block and the signature as its signature. Drafts are not deleted: Gmail's `drafts.delete` deletes for good and stays
  refused. The label `MCP/Composed` stays on the message after you send it (seen in Gmail), which is
  why it is named for what holds in both states: the label tools can remove it, and nothing else
  reads it on a sent mail.

What it leaves to you is stated plainly in [SECURITY.md](SECURITY.md) (threats 10 and 11): a draft can carry
what an injected mail asked for — the protection is you reading it before you press Send.

## Looking in spam

**A query that does not name a place never sees spam or trash.** Gmail excludes both from any
search that does not say `in:spam` / `in:trash`, so `from:someone` returns nothing for a mail that
is sitting in the spam folder — and nothing in the answer says so. Measured against a live mailbox:
the same `from:` query returned 0 hits by default and 1 with spam included.

This matters because of *why* mail gets misfiled. A spam filter judges a message on its own; it
cannot know that you signed up for something a minute ago, requested a password reset, or placed an
order — so the confirmation you are waiting for is exactly the kind of mail that lands there. You
know what you just did. The filter does not.

So when mail someone expects is missing, ask again with the place named:

```text
search("in:spam newer_than:2d")          # what got filed as spam recently
search("in:spam from:example.com")       # the confirmation that never arrived
```

A thread returns to the inbox with `modify_labels` (remove `SPAM`, add `INBOX`), and a sender that
keeps being misjudged is best fixed for good with a never-spam rule — `create_filter` with
`removeLabels: ["SPAM"]` (see [Filters](#filters-persistent-auto-triage-rules)).

Two things this server deliberately does not do. It does not scan the spam folder and *judge* what
belongs there: measured over one real spam folder, 89% of it carries no mailing-list machinery at
all, so "looks unlike bulk mail" flags nearly the whole folder and filters nothing. And it does not
act on that judgement by itself — releasing mail from spam is a decision, and the context that makes
it obvious ("I just registered there") lives in the conversation, not in the mailbox.

## Security & privacy

> For the full threat model — trust boundary, per-threat mitigations, explicit non-goals, and how to
> report a vulnerability — see **[SECURITY.md](SECURITY.md)**. The highlights:

- **No telemetry.** Nothing phones home — no analytics, no crash reporting, no tracking.
- **No open ports by default.** stdio only. The optional `--http` listener binds to `127.0.0.1`
  (not the LAN) and **refuses to start without a `MAILWARDEN_TOKEN`** bearer token — set
  `MAILWARDEN_ALLOW_NO_TOKEN=1` to override on a trusted, isolated network. On a loopback bind it
  also validates the `Host` header (DNS-rebinding defense). For remote hosting, set `MAILWARDEN_HOST`
  and front it with TLS.
- **No send tools — by design.** mailwarden cannot send anything (no reply, no forward, no new message), and by default it cannot
  compose either; only the opt-in `drafts` tier writes drafts, which you send yourself. A prompt-injected
  instruction inside an email has no exfiltration path through this server. `create_filter` follows
  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_filters` still surfaces any forwarding filter
  already on the account, so you can spot one. The `drafts` tier has no way to send what it saves —
  see [Drafts](#drafts-opt-in--written-here-sent-by-you).
  This holds because no such tool exists and none can be
  registered at runtime; for the stronger variant, where *Google* refuses to send rather than
  mailwarden declining to, see **Read-only mode** below. **Read the promise as narrow, because it
  is:** it closes the route out *through this server*, not the assistant's other routes — a client
  that can fetch a URL, write to a synced folder or run code still has everything an injected
  message needs, and this server is what puts that message in front of it. See
  [non-goals](SECURITY.md#explicit-non-goals-what-mailwarden-does-not-defend-against).
- **One outbound host, no model-chosen URL.** The `unsubscribe` tool is the only code path that
  contacts a non-Google host. Its endpoint is read from the message's `List-Unsubscribe` header —
  never from a tool argument — the request body is fixed and the response body is discarded, so it
  cannot become a data channel. https/default-port only, redirects re-validated, and any hop resolving
  to a private, loopback, link-local or metadata address is refused. See
  [Unsubscribing](#unsubscribing--the-one-outbound-request).
- **Tool tiers (progressive disclosure + least scope).** `MAILWARDEN_TOOLS` advertises only the tiers
  you name — `read` (the read tools), `manage` (mailbox mutations, snooze, downloads), `filters`
  (server-side filter CRUD, the only tier whose tools need `gmail.settings.basic`), and `drafts`
  (save and revise drafts, never send). Default is `read,manage,filters`; `drafts` is on only when
  named. E.g. `read,manage` gives a full triage surface without filter management. The **OAuth scopes
  requested at `--auth` are derived from the enabled tiers** — a `read` deployment asks only for
  `gmail.readonly`, and `gmail.settings.basic` is requested only when the `filters` tier is on. And the
  filter tools are **hidden automatically** when the stored token doesn't carry `gmail.settings.basic`
  (e.g. a token authorized before you enabled the tier) — re-run `--auth` to 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 for `MAILWARDEN_TOOLS=read`) and only the
  read tools (`search`, `get_thread`, `read_attachment`, `list_labels`, `list_snoozed`, `get_profile`, `what_changed`, `triage_digest`,
  `follow_ups`, `list_unsubscribe`, `list_subscriptions`)
  are registered — nothing that can change the mailbox or write
  files is even advertised to clients (the filter tools, which need the broader `gmail.settings.basic`
  scope, are excluded too). Recommended for shared/HTTP deployments that only triage.
  It is also **the only tier whose no-send property Google enforces**: it holds a `gmail.readonly`
  token, which Gmail's send endpoints reject outright. `manage` needs `gmail.modify`, and Gmail
  *does* accept that scope for sending — mailwarden simply exposes no tool that would. So a `read`
  deployment could not send even if this binary were replaced; a `manage` one cannot send because
  there is nothing to call. (There is no send-free write scope to switch to — see
  [SECURITY.md](SECURITY.md), threat 1.)
- **Egress guard.** "Nothing to call" is no longer only a statement about the tool list. Every
  authenticated Gmail request passes one checkpoint that allows exactly the endpoints mailwarden
  uses and refuses the rest — with `messages.send`, `drafts.send`, every other draft endpoint
  (unless the `drafts` tier opens exactly `drafts.create`, `drafts.update` and `drafts.get`), `messages.import`/`insert`,
  permanent deletion and every non-filter `settings` endpoint (the `drafts` tier reads one: the send-as
  list, nothing that changes it) named in a deny list checked first, so
  a later edit to the allowlist cannot reopen them by accident — including through the
  `/upload/gmail/v1/...` route Google's client takes when a method is handed `media`. A request whose
  host was rewritten (`GOOGLE_CLOUD_UNIVERSE_DOMAIN`, a `rootUrl` option) is refused before the token
  leaves the process. Every method in Gmail's discovery document is tested against the guard. It
  guards *this server*, not the
  token: a stolen `gmail.modify` token can still send from elsewhere.
- **Fenced downloads.** With `MAILWARDEN_DOWNLOAD_DIR` set, file writes — attachments and thread
  exports — are confined to that directory (realpath-canonicalized, symlink-aware) and never
  overwrite an existing file. Without it there is nothing to resolve the client-supplied path
  against, so `download_attachment` and `export_thread` can write anywhere this process can — which matters for `--http`, where the client is remote.
  Starting `--http` without the fence therefore prints a warning naming the exposure (it stays a
  warning, not a refusal: unlike a missing bearer token this needs an *authorized* client, and
  existing deployments depend on the current behaviour). A `read`-tier deployment is silent — it
  never registers the tool.
- **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. The strip also covers Unicode tag characters and the variation selectors supplement
  (invisible ASCII smuggling), and it applies to `structuredContent` as well as the text copy — a
  client reading the machine-readable half gets the same sanitized content.
- **Live API, no copy.** No mailbox mirror or search index is stored anywhere. The only local state
  is your OAuth token in `~/.mailwarden/`.
- **Token encryption at rest, by default.** `token.json` holds a refresh token; on disk it is
  protected only by `mode 0o600` (a no-op on Windows). `mailwarden --auth` therefore stores it
  AES-256-GCM-encrypted under a random key it keeps in the OS keystore — Windows Credential Manager,
  the macOS Keychain, the Secret Service (GNOME Keyring, KWallet) on Linux — so a *copy* of the file
  (a backup, a synced folder, another machine) is useless on its own. Where no keystore is reachable
  (a Linux host without a desktop session, a platform without a prebuilt binding), set
  `MAILWARDEN_TOKEN_PASSPHRASE` and the key is derived from that passphrase instead (scrypt); without
  either, the token is stored in plaintext as before, and `--auth` and `--check` say so. A passphrase,
  once set, takes precedence over the keystore. An existing plaintext token keeps working and is
  encrypted at the next `--auth` — never rewritten behind your back. Note the boundary: this defends
  against file theft, **not** against malware running as your user, which can ask the keystore for the
  key, or read the passphrase from the environment, exactly as mailwarden does.

## Quick start

```bash
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](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:

1. **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`.
2. Put `credentials.json` in `~/.mailwarden/` (or set `MAILWARDEN_CREDENTIALS=/path/to/credentials.json`).
3. Authorize once — opens a browser, stores a refresh token in `~/.mailwarden/token.json`:
   ```bash
   npx -y mailwarden --auth
   ```
   Scopes requested: `gmail.modify` (read + label/archive/trash) and `gmail.settings.basic`
   (filter management only). If you authorized a version before filters existed, re-run `--auth`
   once to grant the added scope. To hold a token that Gmail itself refuses to send with, authorize
   with `MAILWARDEN_TOOLS=read` — see **Read-only mode** above.
4. **Verify the setup** any time with the built-in doctor:
   ```bash
   npx -y mailwarden --check
   ```
   It checks `credentials.json`, whether a token exists (and if it's encrypted), whether the
   granted scopes cover your enabled tiers, and makes one live Gmail call to prove the token
   still works — printing a concrete fix for anything that's wrong, and exiting non-zero if so
   (handy in CI/health checks). Diagnoses the common traps: no/`wrong` credentials file, never
   authorized, an encrypted token with no `MAILWARDEN_TOKEN_PASSPHRASE` or whose key is not in this machine's keystore, a missing scope, or the
   7-day "Testing"-consent token expiry.

## Connect

**Claude Code** (local stdio):
```bash
claude mcp add mailwarden -- npx -y mailwarden
```

**Claude Code plugin** — the same server plus two skills: `/mailwarden:setup` walks you through the
OAuth setup and diagnoses a broken one, and `/mailwarden:triage` carries the operating rules an
assistant needs while working a mailbox — above all that `search` re-verifies and `bulk_modify` does
not, so a non-empty `unverifiedPredicates` means resolving the set with `search` before acting on it.
That rule protects the user only if the assistant follows it, and a tool description is read once per
call while a skill is read before the plan. The repo root is the plugin (`.claude-plugin/plugin.json`), so
from a clone:
```bash
claude --plugin-dir /path/to/mailwarden
```
The repo is also its own plugin marketplace (`.claude-plugin/marketplace.json`), so without a clone:
```bash
claude plugin marketplace add csitte/mailwarden
claude plugin install mailwarden@csitte
```
(inside a session: `/plugin marketplace add csitte/mailwarden`, then `/plugin install mailwarden@csitte`;
later releases arrive with `claude plugin update mailwarden@csitte`, auto-update being off by default
for a marketplace you added yourself). It is submitted to Anthropic's plugin directory as well, but since
6 November 2026 the directory accepts a plugin that runs an MCP server on your computer only from a
Verified organization, which this one is not, so that listing waits. The plugin starts the
exact published version (`npx -y mailwarden@<version>`, bumped by the release script together with
`server.json`), because the directory reviews one revision and refuses a launcher that would run whatever
npm serves later. The plugin runs the full
tool surface — for a narrower tier (`MAILWARDEN_TOOLS=read`) or a second account, use `claude mcp add` with
the env you want instead (see [Config](#config-env) and [Multiple accounts](#multiple-accounts)).
That route gets the server without the skills: the npm package ships the server alone, and the skills
live in this repo's `skills/` folder — to keep `triage` with a `claude mcp add` setup, copy
`skills/triage` from a clone into `~/.claude/skills/`.

**Claude Desktop** — add to `claude_desktop_config.json`:
```json
{
  "mcpServers": {
    "mailwarden": { "command": "npx", "args": ["-y", "mailwarden"] }
  }
}
```
Or install the **MCPB bundle** (`mailwarden-<version>.mcpb`, attached to
[GitHub releases](https://github.com/csitte/mailwarden/releases) from 0.10.0 on) as a Desktop extension — Settings →
Extensions → *Install extension…* — the same server, self-contained at run time (no `npx`; Claude
Desktop brings the Node runtime), with the tool tiers as a setting. The bundle is built from the packed
npm package (same file set as published; `npm run mcpb`, verified in CI: validated, unpacked and booted),
plus the OS-keystore binding for each platform it declares, pinned and hash-checked by the lockfile.
The one-time `npx -y mailwarden --auth` still applies
(Node needed once for that) — the bundle reads the same `~/.mailwarden/` token.

**Smithery** — mailwarden is no longer listed. Smithery shut down its CLI, toolbox, gateway and
accounts on 31 October 2026 and stays on only as a curated directory of verified servers
([announcement](https://smithery.ai/future)); mailwarden's former entry `csitte/mailwarden` answers
404 (checked 9 November 2026). There is no Smithery install path: install with `npx` or the `.mcpb`
above. 0.23.0 was the last version published on Smithery.

**Remote (Streamable HTTP)** — for a VPS / claude.ai custom connector:
```bash
# Loopback + token required by default. For real hosting, bind outward and keep the token:
MAILWARDEN_TOKEN=<secret> MAILWARDEN_HOST=0.0.0.0 npx -y mailwarden --http   # :8787/mcp
```
Then in claude.ai: Settings → Connectors → *Add custom connector* → your `https://your-host/mcp` URL. In Claude Code: `claude mcp add --transport http mailwarden https://your-host/mcp`.

## Multiple accounts

One OAuth app (one `credentials.json`) can authorize several Gmail accounts. Each account keeps its
own refresh token in a separate file, selected by `MAILWARDEN_ACCOUNT`:

```bash
mailwarden --auth --account work        # stores token.work.json
mailwarden --auth --account personal    # stores token.personal.json
```

Run them side by side by registering the server **once per account**, each with its own
`MAILWARDEN_ACCOUNT`. Every instance carries its own token, its own granted scopes and its own tool
surface, and a tool call acts on the account of the entry that carries it and on no other:

```json
{
  "mcpServers": {
    "gmail-work":     { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "work" } },
    "gmail-personal": { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "personal" } }
  }
}
```

Account names are **case-insensitive** — they become filenames, so `Work` and `work` would be the
same file on Windows/macOS. mailwarden lower-cases them (`--account Work` → `token.work.json`) so a
name always maps to exactly one mailbox.

**Which file `--auth` writes depends only on `--account` / `MAILWARDEN_ACCOUNT` — never on the
account you pick in the browser.** Authorizing a second mailbox *without* `--account` would
therefore aim straight at the first one's token file, so `--auth` checks first and **refuses**
rather than replacing another mailbox's token; `--force` overrides it deliberately. The two knobs
are not interchangeable: `MAILWARDEN_ACCOUNT` is the one for several mailboxes out of one config
directory (it picks `token.<name>.json`), while `MAILWARDEN_DIR` moves the *whole* directory —
useful to keep setups apart entirely, but it does not give you a second account inside one.
`npm run auth` from a repo clone passes neither, i.e. it always serves the default account.

`mailwarden --check` shows the active account and lists the others it finds. With no
`MAILWARDEN_ACCOUNT` set, everything uses the default `token.json` exactly as before — this is fully
backward compatible.

### More than two accounts

Two entries are the easy case. Past that, two properties of this design start to matter.

**Each instance brings its own tools.** The tier split is 11 `read` + 16 `manage` + 4 `filters`
(+ 2 `drafts`, opt-in), so a default instance advertises 31 tools and four of them advertise 124. Clients that search their tool
surface on demand absorb that; clients that hold every definition in context do not.

**Several accounts in one client share one model context.** The account boundary binds a *call* to
one mailbox — it does not stop text read from one mailbox from prompting a call against another,
because all of those tool surfaces are in front of the same model. That is a limit of the boundary,
not a defect in it; see threat 8 in [SECURITY.md](SECURITY.md).

One move answers both: **give exactly one mailbox write tools and leave the rest on `read`.**

```json
{
  "mcpServers": {
    "gmail-main":   { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "main" } },
    "gmail-work":   { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "work",   "MAILWARDEN_TOOLS": "read" } },
    "gmail-club":   { "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "club",   "MAILWARDEN_TOOLS": "read" } },
    "gmail-archive":{ "command": "npx", "args": ["-y", "mailwarden"], "env": { "MAILWARDEN_ACCOUNT": "archive","MAILWARDEN_TOOLS": "read" } }
  }
}
```

Three things follow at once: the `read` entries only ever ask for `gmail.readonly`, the one scope in
which no-send is enforced by Google rather than by mailwarden's tool surface; the four instances add
up to 64 tools rather than 124; and an instruction injected into any of them finds no write tool for
another mailbox to reach for. When one of the read-only mailboxes does need cleaning up, hand that
entry `manage` for as long as the work takes instead of permanently.

Separate clients — or separate sessions — remove the shared context entirely, at the price of never
having two mailboxes in view at once. Worth it when several mailboxes genuinely need write tools;
otherwise the tier split is the cheaper boundary.

**No tool reads across mailboxes.** `search`, `triage_digest` and `list_subscriptions` each serve
the one account their instance was configured with, so a question like "which newsletter writes to
all four" is four calls whose answers the caller combines. In a setup this size it is worth calling
`get_profile` before the first action that changes anything — it names the mailbox actually on the
other end.

## From source

```bash
git clone https://github.com/csitte/mailwarden && cd mailwarden
npm install && npm run build
node dist/index.js --auth
```

A clone deliberately arrives without a `CLAUDE.md`: the working instructions for this repository
are an internal document and are not tracked here. Nothing in the build, the tests or
`npm run smoke` needs them. The rules a contributor — or a coding agent — actually has to know are
in [CONTRIBUTING.md](https://github.com/csitte/mailwarden/blob/main/CONTRIBUTING.md) (design rules
that are not up for grabs, the build/test loop) and [SECURITY.md](SECURITY.md) (the threat model
those rules come from).

## Config (env)

| Var | Meaning |
|---|---|
| `MAILWARDEN_DIR` | config dir (default `~/.mailwarden`) |
| `MAILWARDEN_CREDENTIALS` | path to `credentials.json` |
| `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) |
| `MAILWARDEN_TOKEN_PASSPHRASE` | passphrase → encrypt `token.json` at rest with a key derived from it (AES-256-GCM) instead of a keystore key; takes precedence over the keystore; re-run `--auth` after setting |
| `MAILWARDEN_KEYSTORE` | `auto` (default): encrypt a new token under a key in the OS keystore wherever one is reachable. `off`: never store a key in the keystore — a new token is then stored with the passphrase, or in plaintext (re-authorizing over a keystore token still removes its now-unused key). `require`: like `auto`, but never plaintext — where no keystore takes the key (and no passphrase is set), `--auth` stops and writes nothing, and `--check` warns. Governs writing only: a token already stored with a keystore key still needs that key |
| `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` and `export_thread` to this directory. Unset, the client picks any path this process can write to — `--http` warns at startup unless the `manage` tier is off |
| `MAILWARDEN_ATTACH_DIR` | the one folder the `drafts` tier may attach files from (absolute path; a path inside it or relative to it is accepted, nothing outside). Unset, no file can be attached — attachments from the thread still work. A drive root, the home directory or a folder containing it (also through a link), and on Windows any path without a drive letter, are refused at startup. mailwarden never writes into it: `download_attachment` and `export_thread` refuse a path there, and a `MAILWARDEN_DOWNLOAD_DIR` that is the same folder or lies inside it stops the start |
| `MAILWARDEN_CONFIRM` | ask the person at the client before `create_filter`, `unsubscribe`, `bulk_unsubscribe` and a reply draft within its thread act (a draft to any address the thread did not contain is always asked about, unless `MAILWARDEN_DRAFT_CONFIRM=off`), through an MCP elicitation dialog — the answer never passes through the model, so a prompt-injected mail cannot supply it. `ask`: ask where the client supports elicitation, go ahead where it does not. `require`: refuse where it does not. `off` (default): never ask — unattended runs would otherwise fail on a dialog nobody sees. A no is the error `declined_by_user`, and nothing was done. Dry runs are never asked about |
| `MAILWARDEN_DRAFT_SELF_WITHOUT_DIALOG` | `1` → the `drafts` tier saves a draft without the confirmation dialog when every new recipient is the signed-in account's own address (the address Gmail reports for the profile; case-insensitive, exact — no aliases or `+tag`), so a client without a usable dialog (none, or one that advertises MCP elicitation and declines it) can draft notes-to-self. Nobody is asked, whatever the client can show; `MAILWARDEN_CONFIRM=require` is still honoured; any other recipient is refused as before. Off by default; only the value `1` enables it. Has no effect outside the `drafts` tier and sends nothing |
| `MAILWARDEN_DRAFT_CONFIRM` | `off` → the `drafts` tier asks nobody: no dialog for a new recipient, a file from the disk, a mail's attachment or a sender other than the main address, on any client. For an operator whose own review of each draft in Gmail before sending is the check. `MAILWARDEN_CONFIRM=require` is still honoured. Unset (the default) keeps every dialog; any other value stops the start. Has no effect outside the `drafts` tier and sends nothing |
| `MAILWARDEN_READONLY` | `1` → register only the read tools (search/get_thread/read_attachment/list_labels/list_snoozed/get_profile/what_changed/triage_digest/follow_ups/list_unsubscribe/list_subscriptions). Shorthand for `MAILWARDEN_TOOLS=read` |
| `MAILWARDEN_TOOLS` | comma-separated tool tiers to advertise: `read`, `manage`, `filters`, `drafts` (default: `read,manage,filters` — `drafts` only when named). Also derives the OAuth scopes requested at `--auth`. E.g. `read,manage` drops the filter tools and their `gmail.settings.basic` scope; `read,drafts` reads and drafts, and needs `gmail.modify` |
| `MAILWARDEN_DEBUG` | `1` → print full errors with stack traces instead of a one-line message (for bug reports) |
| `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/gmail`, covered by a vitest suite (2336 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.

## License

MIT © C.Sitte Softwaretechnik

TDQS

A4.2/5.0

Scored across 31 tools

Disambiguation5/5

Every tool has a distinct resource+action target, and the descriptions carry explicit USE WHEN / DO NOT USE sections that preempt exactly the confusions that would otherwise exist (list_subscriptions vs triage_digest vs follow_ups; list_unsubscribe vs unsubscribe vs bulk_unsubscribe; archive vs modify_labels remove INBOX; bulk_modify vs bulk_unsubscribe). Even the near-synonym pairs (mark_read/mark_unread, snooze/unsnooze/sweep_snoozed, undo_bulk_modify vs bulk_modify) are cleanly bounded by direction or by requiring an undo plan.

Naming Consistency4/5

Consistent snake_case throughout, with a strong verb_noun pattern for most of the surface (list_labels, get_thread, create_filter, modify_labels, download_attachment, bulk_unsubscribe). A handful of bare verbs/nouns break the pattern (search, archive, trash, snooze, unsubscribe, what_changed, follow_ups), but they remain readable and unambiguous in context.

Tool Count3/5

31 tools is heavy by any measure, though the domain (full mailbox triage, labeling, filters, snooze, attachments, unsubscribe, bulk operations with undo) genuinely spans that much surface. Several clusters could be consolidated — mark_read/mark_unread, archive/trash/untrash, and unsubscribe/bulk_unsubscribe all mirror single-call list variants — so the count is at the upper edge of justified.

Completeness4/5

Coverage is unusually complete: read/search/thread fetch, full label and filter CRUD, archive/trash/restore, snooze lifecycle with sweep, attachment read/download, thread export, incremental history, bulk operations with dry-run, preview tokens and exact undo. Gaps are minor and partly deliberate (no send/reply/forward — explicitly documented as an anti-exfiltration stance; no draft tools, no empty-trash/permanent delete).

Maintenance

ActivityActive
ResponsivenessNo recent community issues