-
released this
2026-08-26 19:51:18 -05:00 | 63 commits to main since this releaseA
message_idis the one argument in a mail call that nothing corroborates,
and until this release three tools acted on it alone. It is a long opaque
string a model picked out of a listing, and two adjacent rows differ by a
handful of characters at the end.delete_messageandunsubscribe_message
took it and nothing else — so a handle picked from the row above the one meant
was not a mistake either side could see. The server had no second fact to check
it against, and the caller got back exactly what a correct call returns.expunge_messagehad the answer since 0.16.0 and it was scoped to the wrong
question.expected_subjectmade the caller name what it was destroying, and
the rule around it was: guard the irreversible branch, leave the reversible one
cheap. That rule was not wrong about the recoverability. It was wrong about who
could reach it — the undo needs somebody to NOTICE, and the caller that picked
the wrong handle is exactly the caller who cannot. A wrong delete answers
deleted: trueand hands back a handle, and nobody looks for a message they do
not know is missing. On Gmail the recoverability also has a clock on it: the Bin
empties itself after about 30 days, so an unnoticed wrong delete becomes an
unnoticed destruction.And
unsubscribe_messageis the one where the guard matters most, not least.
Nothing in the mailbox changes, so there was nothing to gate it behind — but the
act lands on a stranger's endpoint and there is no handle afterwards to undo it
with. Every hygiene and authentication check that tool runs asks whether THIS
sender may be contacted; run against a handle naming a different message, all of
them are thorough answers to the wrong question. The checks pass on the
newsletter the caller meant while the POST goes to whatever the adjacent row was.Added
-
expected_subject, required, ondelete_messageand
unsubscribe_message, on both mail tenants. The subject the caller believes
themessage_idhas, compared server-side against what the handle actually
resolves to. It cannot be satisfied by trying harder: a caller holding a
transposed handle holds a subject belonging to a different message.Required by the signature, not conditionally by another argument's value.
A guard the caller may omit is not a guard, and optional is what a model
leaves out. -
expected_sender, optional, on those two and onexpunge_message. A
subject is not unique — two notices from the same building on the same day
carry the same line, and against those the subject check passes on either
handle. Compared as a bare address, lowercased, the way
reply_all'sexpected_recipientsare: a display name is presentation and
differs by punctuation between listings, and refusing on that would train
callers out of passing the argument at all.A value with no address in it is refused as a bad argument rather than
reported as a mismatch. Both readings refuse, so the branch buys wording — but
"that message is from X, not 'Accounts Payable'" sends a caller looking for a
message that does not exist. -
mcp_gateway/mail/guard.py, the comparison itself, shared. It was a
_require_subjectmethod on each client, duplicated, and two copies of a
comparison rule is how two tenants come to disagree about what "the same
subject" means. What is left on each client is the ENVELOPE fetch, which is
genuinely per-protocol, and the tenant's own word for where a message lives —
a Gmail message is in a LABEL, and telling a caller to re-read "the folder"
names nothing it can act on. -
A
refusalslug on the response, alongside the sentence:
expected_subject_required,subject_mismatch,sender_mismatch. Present
only when a guard refused, so the field's presence is itself the signal. A
caller readingrefusal: subject_mismatchknows to re-read the listing
without parsing English; a caller reading norefusalat all knows the
operation was attempted and the mailbox failed, which is a different thing to
tell the user.refusal: nullwould be read as neither.Carried on
MessageMismatchascode, defaulting tosubject_mismatchso
every pre-0.51.0 raise site stays meaningful untouched. -
A new log phase,
mail/guard. For the reasonimap/writeis separate
fromimap/fetch: "how often is the guard refusing, and on what" is a
question about the guard, unanswerable if the lines are mixed into a busy
write path. It also spans tools that never write — an unsubscribe refusal is
not an IMAP write and was being filed as one. No subject and no sender reach
a log line, on any of them: the tool response carries both, and there it is
exactly whatget_messagewould have returned anyway.
Changed
-
The comparison no longer folds case, on every tool that carries it,
expunge_messageincluded — which had been folding since 0.16.0. Whitespace
is still normalised and is not a relaxation:decode_headercollapses a
folded header to one line, so the actual side arrives normalised whether
anybody asked or not, and the expected side has to match that or a subject
folded on the wire could never be matched at all.Case is a different argument. A model copying a subject out of
list_messagescopies its case with it, so a case difference means the
subject was typed rather than copied — reconstructed from what the caller
thinks the message says instead of read from what it does, which is precisely
the caller the guard exists for. Nothing else is normalised: not punctuation,
not an emoji, and not anRe:prefix.Re: XandXare different rows in a
listing, and a mailbox reliably contains both. -
move_messagecarries the check internally and does not offer it. The
argument is plumbing fordelete_message, which is what lets it run on the
open connection against the resolved UID, before the COPY or MOVE — a
check on its own connection would be answering about a mailbox that could
still change underneath the write. Themove_messagetool's schema is
unchanged. -
Tool descriptions say why the field is not optional, in those words. The
description is the only thing that tells a calling model to populate it
honestly rather than perfunctorily, and each says what a refusal means: you
addressed the wrong message, so re-read the listing — do not resubmit with
the subject the error named. A caller that corrects its argument from the
refusal has learned how to get past the guard and nothing else; the handle is
still pointing at a message nobody asked about.
Notes
-
This is a breaking tool-contract change. Any caller not passing
expected_subjectis refused. The only caller is Claude over MCP, which
re-reads the schema on connect, so there is nothing to migrate — but the four
tools' signatures changed and that is what this entry is for. -
move_messageandforward_messageare the obvious next candidates and
are deliberately not done here. Anything keyed only bymessage_idwith a
real consequence belongs behind the same guard;move_messagenames both
ends and is reversible in one call, andforward_messagewas left for the
release that can verify it against a live send rather than a fake. -
probes/m2_verify.pyhas been stale since 0.30.0 and now says so at the
top. Its sections 5 and 7 drivedelete_messagewithpermanent=True, an
argument removed five releases ago, so the run dies before section 6. That is
recorded rather than quietly fixed because restoring it means arming
mail_expunge_enabledinside a probe — a decision to take deliberately. What
it no longer proves is the part that matters: UID-scoped destruction against
a neighbour already flagged\Deletedis the check in that file with the
worst silent failure, and nothing has covered it since.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-