• v0.25.0 e1fb4127bf

    0.25.0 — folder administration
    All checks were successful
    CI / gate (push) Successful in 1m24s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-20 13:14:15 -05:00 | 36 commits to main since this release

    M4: the mailbox's structure, not the messages in it. create_folder, rename_folder and delete_folder, behind MAIL_FOLDER_ADMIN — off by default, and off means the tools are absent rather than registered and refusing.

    Parallel to MAIL_WRITABLE_FOLDERS rather than nested under it, which is the position CALENDAR_ADMIN already takes: managing a folder is a different question from managing what is inside it, and neither implies the other.

    Where it diverges from the calendar, on purpose

    delete_calendar reaches every collection on the account. rename_folder and delete_folder act only on folders inside MAIL_FOLDERS, because the two allowlists are not the same kind of thing: CALENDARS selects among collections the user picked, while MAIL_FOLDERS is the read boundary for this whole tenant and makes a folder invisible.

    Reaching past it would destroy mail this server was never permitted to read — and worse, the count guard below would answer "how many messages are in the folder you cannot see" for any name a caller cares to try, turning a guard into an oracle. create_folder is necessarily exempt: the name does not exist yet, so there was nothing to have granted.

    delete_folder takes guards, not a force flag

    The plan said force=True. That is not a guard — it is a boolean a model sets while re-reading its own last refusal, and CLAUDE.md had already settled the question: destructive tools make the caller name what they are destroying.

    So it takes expected_name and expected_message_count, the two-step shape delete_calendar established. Calling without the count refuses and reports it, which puts the number in front of a human before the second call acknowledges it. A test pins both arguments into the schema and pins force out of it.

    The messages do not go to trash. They go with the folder — the second operation in this tenant that nothing can undo, after delete_message(permanent=true). INBOX is refused because IMAP forbids it anyway; trash, sent, drafts and junk are refused because this tenant addresses them, and deleting trash destroys what the user throws away in order to be able to undo.

    rename_folder invalidates handles

    Every outstanding message_id naming the old folder stops working, because a handle carries its folder name verbatim. Inherent, not a defect — but the description says so, or the model reuses handles it read moments earlier and reads the resulting "no such folder" as a failure rather than as its own doing.

    Renaming a folder out of MAIL_FOLDERS is refused rather than warned about: this server loses sight of it immediately, including the ability to rename it back.

    Also

    • imap/admin log phase — no folder name logged, only that it happened and, for a delete, how many messages went.
    • folder_admin=on|off on the boot log's grants line, and a preflight check for a gate that is on with nothing it may act on.
    • All three bust the list_folders cache, or the next read serves a stale answer to a model that was just told the call succeeded.

    Not verified

    RENAME has never run against the live account. CREATE and DELETE were exercised during probe 5; RENAME was not, and it is the one operation here carrying a live-verify condition. Unit tests cannot clear it — the fake renames a folder in a dict, and what needs checking is that a stale handle fails cleanly rather than resolving to a different message under a colliding UIDVALIDITY. Probe 10 is written up in docs/icloud-mail-plan.md.

    The same sentence was true of move_message for four releases. Treat this as working-but-unproven.

    969 tests green.

    Downloads
  • v0.24.1 6d414435ba

    0.24.1 — a boot line you can read
    All checks were successful
    CI / gate (push) Successful in 1m24s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-20 12:55:47 -05:00 | 37 commits to main since this release

    The boot summary had grown to nineteen fields on one line. It is the line this project tells you to check after every deploy — it is how version= and tenant= get confirmed — so it is read often, and at that width it wraps three times in Portainer's viewer and gets skimmed instead.

    The split

    One line per group, behind a short mcp-gateway starting line carrying only version and tenant:

    mcp-gateway starting | version=0.24.1 tenant=icloudmail
    account | tenant=icloudmail imap=imap.mail.me.com:993 mail_address_domain=icloud.com timezone=America/Chicago
    serving | bind=0.0.0.0:8787 mcp_path=/icloudmail allowed_hosts=icloudmail.mcp.jrendar.org
    grants  | mail_folders=3 allowlisted mail_writable_folders=3 writable trash_folder=(default) unsubscribe=on
    auth    | auth_mode=oauth resource_url=... forgejo_url=... allowed_subjects=joeyr fastmcp_home=/var/lib/fastmcp
    limits  | max_messages=200 mail_body_max_bytes=262144 attachment_max_bytes=5242880 imap_timeout=60
    

    Ordered by what gets read first when a deploy looks wrong: which account, then whether it is serving on the right hostname, then what it may do, and only then the auth wiring and the numbers. grants is the line the split exists for; on the flat line it sat between a timeout and a URL.

    The grouping is a partition of summary(), not a filter, and a test pins that on every tenant: a field is on exactly one line, and a field on no line fails the suite. Without it a setting added later silently stops printing — and the settings most likely to be added here are grants, where "it never printed" and "it is off" look identical from a log.

    unsubscribe=on|off

    Not hypothetical, because 0.24.0 had just done it. unsubscribe_message shipped with no boot field at all, so whether the tool was registered was answerable only by reading the connector's tool list from a client — and when it was missing there, nothing in the log said why. It is the grant with the widest reach on this tenant, being the sole outbound request to a host the operator did not choose, and every other grant was already confirmable from the boot log.

    MAIL_UNSUBSCRIBE_ENABLED is documented

    0.24.0 missed that too. Every other setting is documented in .env.example at length; this one existed only in code and in a deployment file that is not in this repo — which put the full rationale (the allowlist of non-blocking hygiene flags, dmarc=pass, DKIM coverage of the unsubscribe headers, and what one-click does not cover) nowhere a reader of this project would find it.

    No behaviour change to any tool. 940 tests pass.

    Downloads
  • v0.24.0 1237d386cb

    v0.24.0
    All checks were successful
    CI / gate (push) Successful in 1m25s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-20 00:44:37 -05:00 | 38 commits to main since this release

    The server could see that a sender offers a one-click unsubscribe and could not use
    it.
    get_message has reported unsubscribe: {one_click: true} since 0.19.0, so the
    answer to "unsubscribe me from this" was "tap the button in Mail" — which is one tap, and
    fine. The reason to build the tool anyway is the gate, not the convenience: hitting an
    unsubscribe endpoint on a hostile message confirms to the sender that the mailbox is live
    and read. A person tapping Unsubscribe cannot tell whether the sender authenticated. This
    server already computes that, so it can decline to be the thing that answers a phish.

    Added

    • unsubscribe_message, performing the RFC 8058 one-click POST for one message,
      behind MAIL_UNSUBSCRIBE_ENABLED. Off by default, and off means the tool is not
      registered rather than registered and refusing — the rule MAIL_WRITABLE_FOLDERS
      already follows, and it matters more here, because the fallback when the tool does not
      exist is one tap in Mail.

      It takes a message_id and has no URL parameter. The URI is read server-side from
      the message's own DKIM-signed header. A tool that POSTs to a URL it is handed is an open
      POST proxy reachable by anything that can talk to this server, including text sitting in
      an email — so the door is closed by there being no parameter to push on, and a test
      pins the argument schema.

      The request follows the RFC's constraints rather than a generic POST: no cookies, no
      authorization, no redirects followed (senders MUST NOT return one, and chasing it would
      re-POST this mailbox's token to a host the signature never covered), multipart/form-data
      per the SHOULD, and exactly one attempt — a retry against a stranger's endpoint is a
      second unsubscribe request, not another go at the first.

    • A gate scored against the live account before it was written, which is the only
      reason it is the right shape. Probe 9 (2026-08-20, 320 messages across every folder, 16
      one-click) ran the designed rules over real mail and refused 11 of the 16, including
      the clean, DKIM-aligned, p=quarantine sender the design named as the case that must
      proceed. Two conditions were wrong:

      • "no DKIM verdict may be unaligned" refuses ordinary bulk mail. An ESP signs with
        its own d= alongside the sender's, so a correctly aligned message carries an
        unaligned verdict too. The property actually wanted is "the From domain is
        authenticated", which is dmarc=pass — already parsed, already trust-keyed, and true
        via SPF alignment even where no DKIM signature aligns.

      • "any hygiene flag refuses" refuses newsletters. reply_to_elsewhere is what
        "send from no-reply, reply to support" produces and recipient_in_unsubscribe is what
        every ESP does by construction. Together they refused a Constant Contact newsletter
        published under p=reject. Both are now warnings; every other flag blocks.

      The flags that survive as blocking are the ones describing deception, and they are
      what caught the phish. p=none warns and never refuses — 11 of the 16 live one-click
      senders publish it, including every obviously legitimate one.

    • A DKIM h= coverage check on the offer itself. RFC 8058 requires both unsubscribe
      headers to be covered by a valid signature, so coverage proves the URI was not injected
      in transit. Read only off a signature whose d= matches a header.d that a trusted
      verdict recorded as passing — reading h= off an arbitrary signature proves nothing.
      Three states, not two: covered, not covered, and no verified signature to read, with the
      last warning rather than refusing. Four of sixteen live messages carry two verified
      signatures; they agreed in every case, so any one covering both is enough and no
      tiebreak rule exists to be disagreed with later.

      What it does not buy, stated because the temptation is real: the phish signs its own
      mail correctly and its unsubscribe header is validly signed too. Integrity, not good
      faith.

    • allow_unauthenticated, scoped to authentication refusals only. It carries a
      dmarc=fail — four of the sixteen live messages are senders that fail DMARC while
      passing SPF, and a user may well recognise them — and it can never carry a hygiene flag
      or an unsigned offer. "Unauthenticated but I know who they are" is a judgement a person
      can make; "this message is impersonating someone" is not, and the argument is
      model-settable, so the text arguing for it could come from the message being
      unsubscribed from.

    • mail/unsubscribe as a log phase. Its own, for the reason imap/write is separate
      from imap/fetch: this is the only request this server makes to a host it did not
      choose, and "did this server contact that sender" must be answerable without reading
      every mail line. The URI never appears — only its hostname. The query string is a bearer
      token identifying this mailbox, which makes it account data by the rule in client.py,
      and it stays out of tool responses for the same reason.

    Fixed

    • A List-Unsubscribe header that is present and empty is no longer reported as
      unreadable.
      Eleven messages in the live INBOX arrive that way — confirmed against a
      full BODY.PEEK[] read, so it is the senders and not the fetch. unparsed: true reads
      to a model as "there is an unsubscribe here I could not read", which has it tell the
      user a facility might exist. The sender advertised nothing, so the true sentence is the
      one absence produces. The three states are unchanged and the middle one still means what
      it says: content that defeated the parser, not the absence of content.

    Not built, deliberately

    • No bulk mode. One message per call. A loop over a folder announces the mailbox as
      live to every sender in the batch, which is the exact outcome the gate exists to avoid.
    • No mailto: unsubscribe. This server cannot send mail — that is M3. It is not a
      small gap: 24 of the 40 live senders offering any facility offer only a mailto, more
      than the 16 this tool covers. Reported by name rather than failing opaquely.
    • No auto-move to junk afterwards. Junking is a separate call, and this stays atomic.
    Downloads
  • v0.23.1 65b1bc956b

    v0.23.1
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-19 23:24:50 -05:00 | 39 commits to main since this release

    Fixed

    • docs/operations.md still described the 0.22.0 live pass as 16 checks and stopped at
      the eighth. It now documents all 21, including the two that matter most for reading the
      result: that a pass under p=none is called a weak claim in both the data and the
      assessment, and that a low provider score is never presented as reassurance.
    Downloads
  • v0.23.0 4b50f4f13b

    v0.23.0
    All checks were successful
    CI / gate (push) Successful in 1m24s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-19 23:22:51 -05:00 | 40 commits to main since this release

    A dmarc=pass was being reported as one fact when it is really two very different
    ones.
    0.22.0 shipped an authentication block that came back all-green on a confirmed
    phishing message. That was accurate — the sender owned their domain and signed their own
    mail — but it read as a clean bill of health, and "technically true" is not the bar for a
    security signal.

    Added

    • The published DMARC policy, beside the verdict it qualifies. pass under
      p=reject means the domain owner asks receivers to bin anything that fails, so they
      have staked their own delivery on it. pass under p=none means they asked receivers
      to do nothing about failures — passing cost anyone controlling the domain almost
      nothing, and disposable and abused domains characteristically publish it. The phish
      published p=none, as did every message in the Junk folder
      , against 25 reject and
      18 quarantine across a 60-message inbox sample.

      It is not in Authentication-Results at all — it comes from iCloud's X-DMARC-Policy,
      a header already being fetched and discarded. The caveat also rides along in
      assessment, because a qualifier the reader has to go looking for is one that gets
      skipped.

    • The provider's own verdictX-Apple-Action and X-ICL-Score, reported as
      provider with an explicit flagged_as_junk. A different question from
      authentication: not "did this domain send it" but "does Apple think it is junk".

      Reported with the honest limit attached. The scores do separate Apple's own junk
      calls (Junk at 4.33; inbox median 3.33, one message of 60 at or above the lowest Junk
      score) — but Apple's filter scored the confirmed phish 3.33, exactly the inbox
      median, and routed it to INBOX.
      So only the flagged direction carries information,
      and score_note says so in the payload rather than in a doc, so that "Apple didn't
      flag it" can never be read back as reassurance.

    Changed

    • probes/m5_verify.py now runs 21 read-only live checks, up from 16, including that
      a p=none pass is described as a weak claim in both the data and the assessment, and
      that a low provider score is never presented as reassurance.
    Downloads
  • v0.22.0 6ce25a44fe

    v0.22.0
    All checks were successful
    CI / gate (push) Successful in 1m35s
    CI / image (push) Successful in 16s
    Stable

    joeyr released this 2026-08-19 23:11:51 -05:00 | 41 commits to main since this release

    The mail tenant can see the junk folder, and can tell you why a message is a phish.
    Prompted by a brand-impersonating message in the live INBOX that nothing on the read
    surface could characterise. Two probes settled the design before any of it was written,
    and both overturned the assumption the work started from.

    iCloud sends no \Junk attribute, so discovery finds nothing and the folder name has
    to be configured — the opposite ordering from what special-use suggests. And iCloud does
    not stamp one combined Authentication-Results header
    : it writes four to six of them,
    one verdict each, every one with its own authserv-id. The usual "trust only the topmost
    instance" rule reads bimi=skipped there and would have reported that every message in
    the mailbox carries no authentication data at all.

    The finding that reordered the work: the message that prompted it passes SPF, DKIM and
    DMARC with every domain aligned.
    The sender owned the domain they sent from and signed
    their own mail correctly, while impersonating an unrelated brand in the display name, the
    body and the links. Authentication results would have given it a clean bill of health, so
    the header-hygiene signals — not the authentication block — are what catch it.

    Added

    • get_message_security, a read tool registered with no write grant required, on the
      argument that seeing a message is a phish must not require the ability to move it. It
      returns hygiene signals, parsed authentication results and an assessment sentence
      together in one response, deliberately not as two tools: a caller handed the
      authentication block alone sees four green verdicts and reports a phish as genuine.

    • icloudmail/hygiene.py — six sender-independent signals, all network-free and
      fixture-tested: invisible and bidi characters in the display name, subject or Reply-To;
      Latin/Cyrillic-or-Greek homoglyphs within a single word; a brand in the display name
      absent from the sending domain; a Re:/Fwd: prefix with no In-Reply-To or
      References; a Reply-To on another domain; and the recipient's own address, plain, hex
      or base64, inside the unsubscribe link. Every flag carries its evidence and the field it
      came from — no score, no is_phishing boolean.

    • icloudmail/authresults.py — SPF, DKIM, DMARC and ARC as the receiving MTA recorded
      them. Nothing is recomputed: SPF cannot be re-evaluated after delivery, and re-verifying
      DKIM would manufacture failures a middlebox already caused. Trust is keyed per method
      to the authserv-id that reported it
      , so a forged header cannot outvote the real one by
      sitting above it, and anything outside the boundary is labelled untrusted_claims rather
      than merged.

    • JUNK_FOLDER (default Junk) and MAIL_AUTHSERV_DOMAIN (default icloud.com),
      with a preflight advisory when the junk folder is not in MAIL_FOLDERS — a mismatch that
      otherwise fails silently, leaving the folder readable but untagged.

    • probes/m5_verify.py, the repeatable read-only live check. 16 checks against the real
      mailbox, all passing, driven through the tool layer rather than the client.

    Changed

    • list_folders tags the configured junk folder role: "junk" when the server
      volunteers no attribute, config first and \Junk second — the same resolution order as
      trash_folder, for the same reason. A real attribute still wins.

    • get_message carries hygiene_flags and a hygiene_note, names only and only when
      something fired, so an ordinary read pays nothing for it. Without the pointer a reader
      never learns the second tool exists, and the signals would be absent from exactly the
      messages they were written for.

    • DKIM verdicts always travel with their signing domain and an aligned boolean. A
      valid signature proves only that the signing domain sent the message, and a phisher
      signs their own mail and passes.

    • DMARC deliberately carries no aligned key. Its header.from is the From domain,
      so comparing them returns true on a forged message too — a security check that cannot
      fail is worse than no field. It reports evaluated_from_matches_header instead, which
      differs only when a message carries two From headers.

    Downloads
  • v0.17.0 5a1cd9bab4

    v0.17.0 — move_message actually moves, and M2 is verified live
    All checks were successful
    CI / gate (push) Successful in 1m18s
    CI / image (push) Successful in 50s
    Stable

    joeyr released this 2026-08-12 15:58:48 -05:00 | 51 commits to main since this release

    move_message never worked, and delete_message without permanent never worked
    either, because it is a move.
    Both failed closed with "the server did not say where it
    landed" for the whole life of M2. Fixed, and M2 is now verified against the live mailbox
    — 27 checks, all passing.

    Fixed

    • The COPYUID was read from the wrong place. IMAPClient.copy() returns None on
      iCloud; imaplib files the response code under untagged_responses["COPYUID"] as
      [b"<validity> <src> <dst>"], already stripped of the keyword. The parser was reading the
      return value, found nothing, and — correctly — refused to expunge rather than guess a
      handle. So the damage was a move that always failed rather than one that lost mail, which
      is the direction that branch was written to fail in.

    • A stale COPYUID can no longer be mistaken for this copy's answer.
      untagged_responses accumulates across commands on one connection, so the key is now
      popped before the COPY as well as after. Left unguarded, a copy that produced no
      response code could read the previous one's and mint a handle naming a different message —
      worse than refusing, and silent.

    Changed

    • The test fake now models where the value actually lives. It previously returned a
      COPYUID string from copy(), which no server does — that is precisely why the suite was
      green while every real move failed. Two regression tests pin the real shape: one that the
      handle comes from untagged_responses, one that a leftover entry is not read as this
      copy's answer.

    Notes

    • This is what the live checklist is for. 799 tests passed against a fake built on the
      same wrong assumption as the code, which is the failure mode this project already had a
      name for: the fakes agree with the code because the same person wrote both. The probe that
      caught it drives the real tool layer against the real mailbox and lives in probes/,
      gitignored, because it names real folders.

    • What the run established, beyond the fix: a moved message's new handle resolves to the
      same message and reports the destination folder; the old handle refuses rather than
      answering; no duplicate is left behind; an omitted update_message argument leaves that
      flag alone; both permanent-delete refusals leave the message intact; a default delete
      lands in the trash folder and can be moved back out; and — the one that is silent and
      unrecoverable when wrong — UID EXPUNGE removed only the message it named, while a
      neighbour flagged \Deleted by another client survived.

    • Still owed for M2: the Trash default confirmed on a phone. The probe checks that the
      handle names the configured trash folder, which is not the same as Apple showing the
      message in the Trash a phone displays.

    Downloads
  • v0.16.0 2580d06307

    v0.16.0 — guard the permanent delete
    All checks were successful
    CI / gate (push) Successful in 1m18s
    CI / image (push) Successful in 50s
    Stable

    joeyr released this 2026-08-12 12:28:12 -05:00 | 54 commits to main since this release

    Two corrections to mail's tool surface, both found by reading it against the calendar's
    rather than by anything failing.

    Added

    • delete_message(permanent=true) now requires expected_subject, checked against what
      the handle actually resolves to, and refuses naming both subjects on a mismatch.

      Every other destructive tool in this project already worked this way — delete_event
      requires expected_title, delete_calendar requires expected_name and an event count —
      and mail's was the only one taking a handle alone. It was also the worst place for the
      omission: a deleted calendar event can be rebuilt from a listing you already have, while
      an expunged message is gone from Apple's servers with no trash, no device copy and no
      recovery. The most dangerous operation here was the least guarded.

      The default delete is deliberately left alone. It lands in trash and returns the
      handle to undo it, so demanding a subject every time would be friction with nothing behind
      it. Guard the irreversible branch, keep the reversible one cheap.

      Comparison is stripped and case-folded, matching delete_event: a caller echoing a
      subject back through a model does not reproduce whitespace exactly, and refusing on a
      trailing space teaches it to stop passing the argument. The refusal is raised before
      anything is flagged, and the tool description says that a mismatch means the wrong message
      was addressed — so re-read the folder rather than retrying with the subject from the error,
      which would delete precisely the message the guard just saved.

    Changed

    • get_unread_countlist_unread_counts. It answers with a collection of folders,
      and get_ is reserved for fetching exactly one named thing. The old name promised a
      number and returned every folder on the account. The optional folder argument stays, and
      now reads the same way list_messages(folder=…) does — a list narrowed by a filter, not a
      lookup.

      The rule this settles, now written in CLAUDE.md: get_ returns one named thing,
      list_ returns a collection.
      The rest of the surface already obeyed it.

    Downloads
  • v0.15.0 7599671901

    v0.15.0 — one update_message
    All checks were successful
    CI / gate (push) Successful in 1m17s
    CI / image (push) Successful in 49s
    Stable

    joeyr released this 2026-08-12 12:18:01 -05:00 | 55 commits to main since this release

    One update_message replaces the three tools 0.14.0 had just created. Mail's write
    surface is nine tools rather than eleven:

    update_message(message_id, read=None, flagged=None)
    

    Both arguments are optional and default to unset rather than to a value. An omitted one
    is left alone — update_message(read=true) on a starred message does not clear the star —
    and passing neither is an error rather than a no-op that reports success.

    0.14.0's split was wrong, and this repo already contained the argument against it.
    That release justified update_message_read / update_message_unread on the theory that a
    model passes booleans backwards. But delete_event next door puts a far more dangerous
    three-way choice — this / following / all, where the wrong pick ends a recurring
    commitment — in a single parameter with no default, and refuses when it is missing.
    update_event takes seven optional fields and refuses when given none. The convention was
    settled; the split was inventing a second answer to a question already decided, and paying
    for it in tool count and in duplicate permission toggles for two operations of identical
    risk.

    0.14.0 was tagged but never deployed, so in practice this is one rename rather than two,
    and one reset of Claude's per-tool permission toggles rather than two.

    Changed

    • mark_read, mark_unread, flag_messageupdate_message. Renamed by 0.14.0,
      merged here. The connector now lists: delete_message, get_attachment, get_message,
      get_unread_count, list_folders, list_messages, move_message, search_messages,
      update_message.

    • The client keeps two methods, set_read and set_flagged, because they are two
      STOREs against two IMAP flags — that is protocol shape, not tool shape. Neither takes a
      default: a write method that assumes which way you meant is the same class of mistake as
      a tool that does.

    • A partial failure reports what actually changed. Two flags are two round trips, so the
      first can land and the second fail. The error envelope carries updated: false and the
      flag that did change, rather than dropping it and describing the message wrongly.

    Downloads
  • v0.14.0 3aad57d0f6

    v0.14.0 — verb-first tool names for mail
    All checks were successful
    CI / gate (push) Successful in 1m17s
    CI / image (push) Successful in 49s
    Stable

    joeyr released this 2026-08-12 12:12:28 -05:00 | 56 commits to main since this release

    Mail's tool names now follow the same verb-first convention the calendar settled on in
    0.10.1.
    Three renames, no behaviour change:

    Was Now
    mark_read update_message_read
    mark_unread update_message_unread
    flag_message update_message_flag

    Breaking in the one way that matters day to day: Claude's per-tool permission toggles
    are keyed to tool names, so the three reset to defaults and have to be set back to ask.
    That cost is why this shipped as its own release rather than riding along with the next
    change — and why the remaining names were left alone rather than churned again later.

    Changed

    • mark_ and flag_ were the strays. The connector lists tools alphabetically, so the
      prefix is the only lever on how they group for the reader. Mail's eleven now sort into
      delete_, get_ ×3, list_ ×2, move_, search_, update_ ×3, and the three write
      tools that change a message's state sit together instead of scattering across the list.

    • The client methods were renamed to matchICloudMailClient.update_message_read and
      .update_message_flag. Deliberate: grepping a tool name off a connector screenshot should
      land on the implementation, and it did not before.

    • A test now pins the convention rather than leaving it to review. mark_read was named
      in a release that also renamed the calendar's strays for exactly this reason, which is
      what a convention with no test looks like.

    Notes

    • mark_read/mark_unread stayed two tools rather than collapsing into one
      update_message(read=…).
      Uniformity argued for the collapse; the split survives because
      a boolean is what a model passes backwards, and update_message(read=false) reads correct
      while doing the opposite. The uniformity was available without paying that.

    • move_message and search_messages keep their own verbs. Folding the move into
      update_message would give one tool two return contracts — passing a destination returns
      a new message_id and the flag arguments do not — and folding search into list_messages
      would erase the distinction that tells a model it forgot a search term. Both were
      considered and rejected on those grounds rather than on taste.

    Downloads