• v0.51.0 42e6ccb0ab

    joeyr released this 2026-08-26 19:51:18 -05:00 | 63 commits to main since this release

    A message_id is 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_message and unsubscribe_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_message had the answer since 0.16.0 and it was scoped to the wrong
    question.
    expected_subject made 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: true and 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_message is 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, on delete_message and
      unsubscribe_message, on both mail tenants.
      The subject the caller believes
      the message_id has, 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 on expunge_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's expected_recipients are: 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_subject method 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 refusal slug 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 reading refusal: subject_mismatch knows to re-read the listing
      without parsing English; a caller reading no refusal at all knows the
      operation was attempted and the mailbox failed, which is a different thing to
      tell the user. refusal: null would be read as neither.

      Carried on MessageMismatch as code, defaulting to subject_mismatch so
      every pre-0.51.0 raise site stays meaningful untouched.

    • A new log phase, mail/guard. For the reason imap/write is separate
      from imap/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 what get_message would have returned anyway.

    Changed

    • The comparison no longer folds case, on every tool that carries it,
      expunge_message included — which had been folding since 0.16.0. Whitespace
      is still normalised and is not a relaxation: decode_header collapses 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_messages copies 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 an Re: prefix. Re: X and X are different rows in a
      listing, and a mailbox reliably contains both.

    • move_message carries the check internally and does not offer it. The
      argument is plumbing for delete_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. The move_message tool'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_subject is 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_message and forward_message are the obvious next candidates and
      are deliberately not done here. Anything keyed only by message_id with a
      real consequence belongs behind the same guard; move_message names both
      ends and is reversible in one call, and forward_message was left for the
      release that can verify it against a live send rather than a fake.

    • probes/m2_verify.py has been stale since 0.30.0 and now says so at the
      top.
      Its sections 5 and 7 drive delete_message with permanent=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_enabled inside 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 \Deleted is the check in that file with the
      worst silent failure, and nothing has covered it since.

    Downloads