You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
get_thread can return part of a thread, and bodies without their HTML. Four deployments
were asked on 25 October what gets in their way, and three named the same tool: a seven-message
GitHub thread came back as 67,376 characters, most of it HTML and quoted history, to read two new
comments; weekly report mails had to be read in slices by a helper agent; and the only way to see
the attachment list without the bodies was not to see it at all (full: false omits it, because
Gmail's metadata format never carries it). Gmail has no partial fetch and bills threads.get the
same whatever is kept, so the shaping happens after the call: bodies: "text" drops htmlBody
and keeps plaintextBody (an HTML-only message is reduced to text and marked bodyFromHtml: true,
so a rendering is never mistaken for what the sender typed); bodies: "none" drops both and keeps
headers, authentication and the measured attachment list; lastN keeps the newest N messages and afterMessageId those after one already read, and the two compose. Every answer now carries messageCount (the whole thread) and omittedMessages, so a partial view cannot pass for the
whole, and bodies is echoed when it is not full, as metadataOnly already was. An afterMessageId the thread does not contain is invalid_input naming the ids it has: a silent
"all" would hand back the history the caller asked to skip, a silent "nothing" would read as no
news. full: false is unchanged and bodies has no effect on it; the description now steers to bodies: "none" when the attachment list matters, since the two cost the same. The selection and
trimming rules are pure (src/threadview.ts) and held by their own tests.
search names the last message of each thread, not only the first.date, from and subject were always the first message's — the thread's origin — while Gmail's snippet is the
last one's, so a thread that got a reply this morning showed yesterday's date beside this morning's
words, and whether anything was new could only be read off messageCount against a count
remembered from the last run (reported 2026-10-25 from a live mailbox). Each summary now also
carries lastMessageDate and lastMessageId; on a single-message thread they name the same
message as date. The id is what get_thread's new afterMessageId takes, so "what came after the
one I saw" is one call. The first-message fields keep their meaning: a newsletter stays a newsletter
after the user replies to it, and signals still read the origin.
From the first trials of that build (26 October), three more fields. lastMessageFrom and lastMessageSent (the SENT label on the last message) answer "has the other side replied?",
which lastMessageDate could not, because it also counts the user's own replies. unread states
as a boolean what callers read off UNREAD in labelIds; the read gate is their most frequent
decision, and it comes from the live labels search already re-verifies against.
And attachments: name, declared type and size of what is attached, at most ten per thread with attachmentCount the total. hasAttachments could not tell a PDF invoice from two HTML copies
of the terms of service, so every check before archiving took a get_thread. search already
fetches each hit in full format, so the list costs no call; attachment ids stay out because
they run to a few hundred characters each, and get_thread with bodies: "none" has them.
get_thread with bodies: "new" leaves out the quoted history of a reply. From the first trial
of lastN (26 October): with only the newest message requested, about three quarters of its text
was the conversation just skipped, quoted back underneath. new is text minus the quote, and a
message that was cut says quotedStripped: true. The rule is conservative, because a wrong cut
drops what someone wrote: only the TRAILING quoted block goes, with its attribution line ("On ...
wrote:", "Am ... schrieb ...:" and the same in French, Spanish, Italian and Dutch, also when wrapped
over several lines), or an Outlook history from its "Original Message" line or header block. An
inline answer between quoted lines keeps every answer. A forwarded message is content and is never
cut, though forwards use the same header blocks. A message that is nothing but a quote is kept
whole. HTML-only mail is cut where the clients mark the quote (gmail_quote, blockquote type="cite", Outlook's divRplyFwdMsg, yahoo_quoted, moz-cite-prefix) before it is rendered.
A corpus of reply shapes from Gmail, Outlook, Apple Mail, Thunderbird and Yahoo holds the rules. Three wrong cuts found by grok-mailwarden (review, 26 October) were reproduced and fixed
before release. Outlook writes a forward exactly like a reply, with the same header block and
no "Forwarded message" line, so "FYI" above a forwarded statement came back as the whole message;
a message whose subject starts with a forward prefix (Fwd, FW, WG, TR, RV, ENC and others, after
tags like "[EXT]"; the outermost prefix counts, so "AW: WG:" is still a reply) is now never cut.
HTML-only mail was cut at the first quote marker, which dropped a bottom post, answers inside
Gmail's opened quote and Apple Mail answers between cited blocks; now it is cut only when nothing
visible follows the quote or sits between its parts (Outlook HTML, which does not close its
history, is cut from the marker as before). And a sentence of one's own ending in "schrieb ...:"
went with a quote below it; an attribution must now name a date, a time or an address.
Known limit: an answer written BELOW an Outlook plain-text header block is cut with it, because
nothing marks where the old message ends. A second round of the same review found the HTML fix itself expensive: removing the quoted
parts searched from the start again after every removal, so one Gmail quote of twenty thousand
attribution and blockquote pairs (1.8 MB) held get_thread for 19 seconds. The HTML is now read
in one forward walk over its tags that skips comments and CDATA; a quote that is never closed,
or a <blockquote> hidden in a comment, no longer makes the answer below it disappear, and an
HTML body over 512 KiB is not cut in its markup. The plain-text cut, which also runs on text
rendered from HTML, looked for the line after every rule from the top of the body: 17 seconds
for 1.8 MB of rule lines, found by the release check and fixed before release. Known limit: an answer written inside Yahoo's yahoo_quoted cannot be told from the quote and goes with it. The same round added forward
prefixes (PD, TOV, Пересл, 转发, 轉寄, 転送, a full-width colon) and the mail-system tags
"EXTERNAL" and "EXT:" in front of them; read_attachment keeps a UTF-8 file with one broken
byte as UTF-8, counts a broken deflate stream against its budget and takes an entry pattern of
at most 512 characters; and no cut name or dialog line ends in half an emoji.
archive, mark_read, mark_unread and modify_labels take a list of threads. Pass threadIds (up to 100) instead of threadId. bulk_modify works from a query, but what a triage
run holds after the user's approval is a list, and archiving seven approved threads took seven
calls (reported 26 October). Labels are resolved once per call, so a new name in add is created
once. The answer reports each thread: changed, failed with code and message (a thread that is
gone fails alone, the others still run), and notAttempted when the run stopped at a rate limit,
a dead token or a missing scope, which would have hit every remaining thread the same way. ok is
true only when all were changed. One threadId keeps the old answer, { ok: true } or an error.
get_profile says which server is answering.serverVersion, serverPath (the directory
the running code was loaded from: an npm install, an npx cache or a clone's dist/) and account (the named mailwarden account or (default)). From the same 25 October round: one
operator ran a dist/ nine days older than the source beside it after a git pull, another
could not tell whether the npm install or the clone was serving, and in both cases the symptom
was a documented tool that seemed not to exist. A client's config says what it was told to
start; only the running process knows what started, and this is the cheapest call that can say. Only a release build reports the bare version. The first cut reported the package version
alone, and the first two sessions to try it (26 October) found that a clone's dist/ six
commits past 0.23.0 and the npm install of 0.23.0 both said 0.23.0: the field could not
answer the question it was added for. npm run build now records the commit in dist/build-info.json, and serverVersion becomes 0.23.0+a5ff4a7 between releases, +a5ff4a7.dirty when src/, package.json, the lockfile or tsconfig.json had uncommitted
or untracked changes (an untracked source file is compiled into dist/ all the same), and +unknown when there is no usable record (a source run, a bare tsc). A build object carries commit, dirty, release and builtAt. A release is HEAD at v<version> with clean build
inputs; the package smoke test checks the record is in the tarball.
bulk_modify previews can be confirmed exactly. A dry run that matches anything returns a previewToken, and the server holds the matched message set for 30 minutes. A write that passes the
token back with the same query, add and remove acts on exactly that set: mail that started matching
after the preview is left alone and counted in fromPreview.newMatches, messages that stopped
matching are changed anyway and counted in fromPreview.noLongerMatching, and a reworded query or a
different label change is refused before anything is written. A token works once, at most twenty are
held, and a restart forgets them all; the set lives in process memory as message ids only. Without a
token bulk_modify behaves as before. The pattern is aaronsb/google-workspace-mcp's, whose v4.8.0
gates its query relabel the same way.
Two phishing signals, read off the From header like the others.displayNameSpoof fires when the
display name shows an address or a URL on another domain than the one the mail came from
("service@paypal.com" <x@evil.example>), decided after RFC 2047 decoding because that is the name a
reader sees; a brand name that merely is a domain (Booking.com) does not count. lookalikeDomain
fires when one label of the From domain mixes Latin with Cyrillic, Greek or Armenian letters, Punycode
read as what it displays; a domain wholly in one script does not. Both are counted in triage_digest. gmail-tidy-mcp raises similar warnings. A link-text-versus-target check was left out on purpose: it
needs the HTML body, which triage_digest never fetches, and tracking redirects make nearly every
newsletter look like a mismatch.
preview_filter (filters tier, read-only): checks a filter before create_filter makes it — how
many messages its criteria match today, which existing filters carry exactly the same criteria, which
share a from/to/subject/query value with it (named per field, so a forwarding filter it
overlaps with shows up too), which label names would be created, and whether the action trashes
every future match. It runs the same validation as create_filter, which now shares it, and does not
count a rule with no positive criterion. The overlap is literal on purpose: whether two different
searches intersect is not guessed at. The idea is aaronsb/google-workspace-mcp's previewFilter.
read_attachment (read tier): the text inside an attachment, without saving it — plain text, a gzip
file, or the text entries of a ZIP archive, which is how DMARC aggregate reports arrive. The format is
decided by the bytes, not by the declared name or MIME type; binary formats are refused with a pointer
to download_attachment. Limits are checked before they are spent and declared sizes are not
trusted: zlib stops at the remaining budget, so a zip bomb is reported as too large instead of
unpacked. Encrypted, nested-archive and unsupported entries are listed, not opened. The read tier
grows to eleven tools, thirty-one in all. gmail-tidy-mcp reads text, .gz and .zip attachments
inline too; this is our own reader, with no new dependency. Choosing what to read, from the first trial (26 October). A TripLog export held the 3 KB CSV
that was wanted and a 97 KB HTML report that was not; the answer came to over a hundred thousand
characters, the client moved it to a file, and the session unpacked the archive by hand after all.
Now list: true returns each entry's name, size and text length without any text; entry reads
only the matching entries (an exact name or a pattern with * and ?, matched against the path
and the bare file name), and the rest are counted in notSelected, named in notSelectedNames
(asked for in the second trial, so that a skipped entry needs no second call) and never unpacked.
The character limit is now a budget for the WHOLE answer, maxChars, default 30,000 and at most
100,000, where it used to be 100,000 per entry. The default was 50,000 for a few hours: in the
second trial such an answer of HTML came to 50.6 KB as JSON and Claude Code moved it to a file
again, so the default leaves room for escapes and multi-byte letters; an entry it cuts reports truncated, its full
length in chars and a nextOffset, and offset with that entry selected continues it. entry
on an attachment that is not an archive, a pattern that matches nothing, and an offset that is
past the end or not aimed at exactly one entry are invalid_input rather than empty answers. Hardened against hostile archives after a review by grok-mailwarden (26 October), each
point reproduced before it was fixed: the entry pattern was a regular expression that
backtracked cubically on a long near-miss name (353 ms at 1,024 characters here, 130 s at 8,192
in the review), which blocked the whole server; it is now a linear match. Entry names, which
the sender picks and which may be 65,535 bytes long, are cut to 256 characters in the answer
and marked nameTruncated with their nameLength, because the budget counts text only and a
hundred long names made an answer of millions of characters. Central headers that point at a
local header already read are corrupt rather than inflated again, an inflate stopped at the
entry cap counts against the archive budget, and a "directory" that carries data is listed as corrupt instead of being hidden from list. Text that is not valid UTF-8, typically a CSV that
Excel exports on Windows, is read as windows-1252 and marked guessedCharset; before, every
umlaut in it turned into a replacement character without a word.
MAILWARDEN_CONFIRM: a human yes before a filter or an unsubscribe. Set to ask, create_filter, unsubscribe and bulk_unsubscribe first ask the person at the client through MCP elicitation —
the filter rule, or the senders and hosts to be contacted, spelled out (the first host of each request, with a line that a redirect, at most three, is followed and checked the same way; the criteria and label names, which come from the model, folded to one line without hidden characters); one question for a whole
bulk run, none for a dry run. The answer never passes through the model, so a prompt-injected mail
cannot give it. require also refuses on a client that cannot show the dialog. Off by default:
a client without a dialog answers with a cancel, and unattended runs would fail. A no is the new
error code declined_by_user, with nothing done. The pattern is klodr/gmail-mcp's, which asks
through elicitation before pairing a new recipient.
The sender name shown in an unsubscribe question is folded to one line of at most 80 characters
and stripped of hidden characters: the dialog is the one channel the model does not pass, and a
display name decoded from RFC 2047 could carry paragraphs of its own (review grok-mailwarden,
26 October).
Changed
get_thread rejects full: false together with bodies, and full: false is deprecated. full: false is Gmail's metadata format and has no body to trim, so a bodies value next to it
was silently ignored: a caller asking for { full: false, bodies: "text" } got metadataOnly
and no text, with nothing saying why (reported 26 October). Any such combination is now invalid_input before a request is made, and the message names both ways out. bodies lost its
schema default so the server can tell an explicit value from an absent one. full: false keeps
working on its own, but the description now marks it deprecated: bodies: "none" costs the same
quota and keeps the real attachment list, which full: false cannot.
Text reduced from HTML no longer carries the mail's layout whitespace. A report mail built
from nested tables came back from get_thread with bodies: "text" as mostly tab runs and
empty lines around two dozen lines of content (reported 26 October). The old cleanup matched [ \t]+\n and \n{3,}, and neither sees a line that ends in \r\n, which is how such mails are
saved. Each rendered line is now trimmed, runs of spaces, tabs and no-break spaces inside it
collapse to one, invisible preheader padding is dropped, and at most one blank line is kept. A
browser collapses that whitespace too, so nothing a reader would have seen is lost. It applies
only to text reduced from HTML (bodyFromHtml: true, and the HTML fallback of export_thread);
a real text/plain part keeps its spacing, because an aligned table there is what the sender typed.
The comparison table has a column for gmail-tidy (gmail-tidy-mcp), read at 6df48c69 on
25 October 2026: the server whose stated position is closest to this one (no send tool, no trash,
filters that cannot forward), but draft-only rather than without compose, and ahead of mailwarden
on token encryption, which it does by default with the key in the OS keystore. The same round
re-read the four columns that had moved. aaronsb (v4.8.0) moves on two rows: since 16 October
2026 it relabels everything a query matches behind a one-use preview token, and it manages filters
behind the same kind of preview, forwarding included unless its draft-only policy is set. taylorwilsdon (v2.2.0) gains a +-union form of --permissions, named in its least-privilege
cell; klodr (3.0.0) now refuses a forwarding filter outright; agentcomms (0.28.0) moves on no row.
The newsletter and automated signals recognise three more spellings of machine mail. Precedence: junk now counts as bulk mail (newsletter), as vacation responders have long
treated it. A +detail sub-address (RFC 5233) is dropped before the From local-part is
matched, so noreply+abc@ and bounces+1234@ read as automated; alice+noreply@ still does
not. A dot is accepted as a separator next to hyphen and underscore (no.reply@, do.not.reply@). Matching stays exact otherwise: messages-noreply@ and no.reply.nora@
stay silent.
docs/SETUP.md: marking an existing OAuth client as an AI agent, as the console actually
shows it: the checkbox is on the client's edit page, its label stays in English in a localised
console, and the change can take from five minutes to several hours to take effect.
Removed
The Smithery install instructions in the README. Smithery shuts down its CLI, toolbox, gateway
and accounts on 31 October 2026 and stays on only as a public directory, so npx @smithery/cli install and the toolbox route described there stop working. The README keeps the
directory link and points to npx and the .mcpb bundle instead.
assets/icon/favicon.ico. The Claude plugin directory's validator cannot inspect an ICO
file and held the plugin for review over it. Nothing in the plugin used it; render.py no
longer writes it, and favicon-32.png stays for web pages.
Fixed
Mail whose part declares ISO-8859-1 or windows-1252 no longer reads as mojibake. Reported
from a real run on 26 October: an Outlook reply showed "übersenden" and "Grüßen" in plaintextBody, while Gmail's own snippet of the same message was right. Measured on that
message: the part said charset="iso-8859-1", but the bytes Gmail returned for format=full
were already UTF-8. Gmail had converted the text and kept the header. Since July every part was
decoded by its label, which decoded such text a second time. Now bytes that are valid UTF-8
and not plain ASCII are read as UTF-8. Bytes that are not valid UTF-8 still go by the label, so
real windows-1252 text keeps working, and so does an ASCII-only part, which keeps 7-bit charsets
such as ISO-2022-JP right. The fix covers every tool that reads bodies, including get_thread
in all body modes, export_thread and the inline-image check behind the attachment lists.
The euro sign, typographic quotes and dashes of windows-1252 mail on Node 20. Node 20's TextDecoder reads the windows-1252 label, and every label that means the same (iso-8859-1,
latin1, us-ascii), as plain ISO-8859-1, so the bytes 0x80 to 0x9f came out as invisible control
characters instead of "€", "„", "“" and "–". Found in CI on 26 October; Node 22 decodes them
right. It affected bodies whose bytes Gmail had not converted and encoded-word headers such as
a Subject, whose bytes Gmail passes on as the sender wrote them. Both now go through src/charset.ts,
which fills in those characters on any Node.
One mail of unclosed tags could hold the server for seconds. The HTML-to-text rendering that export_thread uses since 0.23.0, and get_thread with bodies: "text" or "new" since this
release, scanned to the end of the body at every tag, comment or <script> that never closes.
Measured on 26 October: 9 seconds for 480 KB of unclosed tags, 14 for unclosed <br and <li,
with every other tool waiting meanwhile. Found while fixing the second review round of grok-mailwarden, which named the pattern. Every scan now stops at the next < or at what
closes it, and blocks are removed in one forward walk; a test holds each shape under half a second.
A block closes only where a browser closes it, at </script followed by whitespace, / or >:
the first version of the walk also closed at </scriptx> and handed the model text that a
browser hides as script (third review round, never released). A <head> that never closes
no longer ends the walk, so a script after it is still removed (fourth round, never released).
Known limit, as in 0.23.0: a double-escaped script (<script><!--<script></script>) is closed
at its first </script>, where a browser keeps it open.
A <script> or <style> that never closes no longer shows its content as text. A browser
hides everything after it; the HTML-to-text rendering since 0.23.0 kept it, so a model read
script text that no person sees in the mail. It now drops the rest of the body, as a browser
does. A <head> that never closes still keeps what follows, because a browser closes it at
the body. Found in the third review round of grok-mailwarden. The same holds for an HTML
comment that never closes (found by the release check).
Security
hono 4.13.7 to 4.13.13 (moderate, GHSA-5r4p-p66f-jhc7). Dependabot flagged it on the
25 October push. serveStatic decoded the request path a second time, so middleware mounted on a
static prefix could be skipped. hono arrives through the SDK's HTTP transport and mailwarden never
calls serveStatic, so there was no path here. The overrides floor moves from ^4.12.34 to ^4.13.11 so a later lockfile cannot fall back below the fix. In the same step the dev tree moves vitest and @vitest/coverage-v8 to 4.1.12, which brings @vitest/mocker 4.1.12 and vite 8.3.4
and closes the four dev-only vite/@vitest/mocker alerts. The lockfile was resolved with --before=2026-10-16, so nothing in it is younger than nine days. Not closed: node-forge (dev
only, no fix published).
zod 3.25.76 to 4.6.7 (high, GHSA-mrhx-c7xc-m3w5). Validating an oversized collection
against a length constraint could exhaust memory. The fix exists only in zod 4, a new major
version. mailwarden uses zod for every tool's input and output schema, but only its basic
building blocks, none of which changed between 3 and 4: no code change was needed, and the MCP
SDK accepts both majors. The tool schemas a client sees were compared under both versions via tools/list. The differences are cosmetic: top-level inputSchemas no longer carry additionalProperties: false (unknown arguments are stripped at runtime exactly as before),
integers without explicit bounds now state the safe-integer range, and preview_filter
inlines a nested schema instead of a $ref. Exposure here was low: tool arguments come from
the MCP client, not from the network.
Three advisories in the SDK's HTTP transport, all reachable only with --http:body-parser
2.3.0 to 2.4.0 (high, GHSA-27r3-jxmh-p828, denial of service through trailing bytes after a
compressed body), fast-uri 3.1.8 to 3.1.9 (moderate, four advisories on URI parsing, through ajv), ip-address 10.7.3 to 10.7.4 (moderate, GHSA-rxp7-6hxx-x8q5, quadratic-time zone
parsing, through express-rate-limit). The lockfile was resolved with --before=2026-10-23: body-parser 2.4.0 is the only fix for its advisory and three days old, everything else is
older, and ip-address deliberately stays on 10.7.4 rather than the 10.7.5 published the same
day. npm audit --omit=dev now reports nothing. Not closed, dev only: node-forge (no fix) and
a nested zod 3 that @anthropic-ai/mcpb still pins.