• v0.32.1 219afe3d39

    v0.32.1 -- the truncation warning promises no ordering
    All checks were successful
    CI / gate (push) Successful in 1m28s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-22 22:41:42 -05:00 | 1 commits to main since this release

    A truncation warning that promised an ordering it does not have. Wording only;
    no behaviour changed.

    Fixed

    • list_messages said "This is the newest 200 of 358" and the window is not the newest
      anything.
      The cap is uids[-cap:] — the highest UIDs, which is arrival order in that
      label
      . For INBOX that is close enough to date order that the sentence went unnoticed for
      twelve versions. For the Bin it is the order things were trashed, so a 2009 newsletter
      deleted last week sits above a 2019 one deleted in 2019.

      A connector read that sentence, was asked for the oldest messages in the Bin, and reported
      a pagination bug in this server — reasoning correctly from a claim that was false. A
      read-only census of the live Bin found 358 real matches and zero dropped messages older
      than the window showed
      , so messages_found was right and the answer happened to be
      right; nothing in the mechanism guarantees the second part.

      The warning now says "Only N of M" — matching icloudmail, which never made the claim —
      and names the question the result cannot answer: not "the oldest N", with before as
      the way to ask it properly.

    • The behaviour is deliberately unchanged. Sorting by date before capping means an
      ENVELOPE fetch for every match in order to discard most of them, and before already
      answers the question. The defect was one word, and one word is what moved.

    Notes

    • The general shape: a warning about incompleteness is read more carefully than the data,
      so an inaccuracy in it is load-bearing.
      This one turned a correct result into a bug
      report against the server. The test pins the wording rather than the behaviour, which is
      unusual here and is the point.
    Downloads
  • v0.32.0 35e035fea3

    v0.32.0 — G5: the irreversible surface, and an expunge that does not lie
    All checks were successful
    CI / gate (push) Successful in 1m48s
    CI / image (push) Successful in 19s
    Stable

    joeyr released this 2026-08-22 22:07:15 -05:00 | 2 commits to main since this release

    G5. The Gmail tenant's irreversible surface. expunge_message behind
    MAIL_EXPUNGE_ENABLED, unsubscribe_message behind MAIL_UNSUBSCRIBE_ENABLED.

    The tool list changed, so reconnect the Gmail connector.

    Added

    • expunge_message, and it routes through the Bin because nothing else works.
      expected_subject required by the signature, checked against a real ENVELOPE before
      anything moves, and an untitled message cannot be destroyed through it at all.

      A straight port of the mail tenant's method would have lied. icloudmail flags
      \Deleted and expunges in place; that destroys the message there. Probe 11 measured the
      identical sequence here and it archives. Gmail has a per-account setting — "when a
      message is marked as deleted and expunged from the last visible IMAP folder"
      — whose
      default is Archive, and it is not readable over IMAP. Measured:

      expunge inside result
      a label the message also has others for the LABEL goes, the message does not
      its last label archived into All Mail
      All Mail nothing at all
      the Bin destroyed

      So the Bin route is unconditional — the setting cannot be read, so it is the only
      implementation that means the same thing on every account. This would have shipped the
      exact failure this project exists to refuse, inside the one tool whose whole description
      is a promise of irreversibility. Written up in CLAUDE.md under An expunge does not mean
      the same thing on every server
      .

    • unsubscribe_message, one-click only on this tenant. No url argument, ever — the
      URI comes from the message's own signed header, read server-side, because a tool that
      POSTs to a URL it was handed is an open POST proxy reachable by text sitting in an email.
      A mailto:-only sender is refused with mailto_only: that half sends mail and this
      tenant has no submission path until G4. send_enabled=False is passed to the gate
      hard-coded rather than read from config, so a deployment that turned
      MAIL_SEND_ENABLED on cannot get a gate approving a mailto this client then cannot send.

      The gate logic, the flag allowlist, allow_unauthenticated carrying an authentication
      refusal and nothing else, redirects reported and never followed — all of it is
      mail/unsubscribe.py unchanged since 0.24.0. None of it was re-decided.

    • unsubscribe_message deliberately does NOT nest inside the write gate, unlike
      expunge_message which does. It changes nothing in this mailbox — it sends a request to a
      third party — so the write allowlist has no bearing on whether it is safe, and requiring
      it would tie a decision about talking to strangers to a decision about filing mail.

    Fixed

    • A server that can unsubscribe no longer calls itself READ-ONLY. Because the tool
      registers with no write allowlist at all, the read-only sentence and a tool that
      irreversibly tells a stranger the mailbox is live could coexist — and did, in the first
      draft. Caught by the surface test rather than by review. READ-ONLY is kept for the case
      where it is true, which is also the default deployment.

    Notes

    • The phase-ordering argument was measured and turned out to be about a different
      mailbox.
      docs/gmail-plan.md had unsubscribe shipping after send, because 24 of 40
      iCloud senders offering anything offered only mailto:. On Gmail: 200 INBOX messages
      sampled, 61 offering a facility — 30 both, 27 one-click only, 4 mailto only. 57 of 61
      (93%) usable with no sending capability at all.
      G4 would add almost nothing to this
      tool's reach here.

      The lesson is not "unsubscribe goes early" but that a phase-ordering argument built on
      one account's header census is a fact about that account.
      Re-measuring cost four lines.

    • Every Gmail probe had been failing to clean up for weeks, in exactly the way probe 11
      now documents.
      Each teardown expunged inside its own scratch label and printed
      "removed N"; a sweep found 14 supposedly-destroyed fixtures sitting in All Mail,
      including from probe 8 — the probe whose own finding is that a label expunge does not
      delete. probes/_gmail_cleanup.py is now the single correct implementation and every
      Gmail probe calls it. Gitignored, so nothing here changes.

    • Live pass run: probes/g5_verify.py, 21 checks, all green, including the one that
      matters — after expunge_message returns, the message is gone from every label on the
      real server, All Mail included. Written up in docs/operations.md. Still owed: one
      real one-click unsubscribe, chosen deliberately, because succeeding at that tells a
      stranger this mailbox is live and is not a thing to do for a test.

    Downloads
  • v0.31.2 d2982a7a15

    v0.31.2 — preflight stops repeating itself
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-22 21:19:14 -05:00 | 3 commits to main since this release

    Two small things in the Gmail tenant's preflight, both spotted by reading its
    boot log rather than by any test.
    No tool, argument or description changed, so
    no reconnect — a service image update is enough.

    Fixed

    • oauth_storage ran twice on this tenant. The shared OAuth block runs it
      for every tenant, and the Gmail branch ran it again, so the container probed
      the same directory twice and printed check=oauth_storage twice. Nothing
      broke, and that is the problem with it: an operator counting preflight lines
      against the docs finds one too many and has to work out which is real.

    • Gmail dialled out to IMAP even when a local check had already failed. The
      other three tenant branches guard on local_ok; this one did not, which was
      an oversight from when the tenant was added rather than a decision. A
      container about to report a broken timezone or a missing secret would open an
      IMAP connection first and put a second, less self-explanatory failure line in
      front of the one that actually says what is wrong.

    Notes

    • Neither was reachable from the suite, so the tests are new and are written
      as properties over every tenant
      — "no check runs twice on any tenant" and
      "a broken local config stops the network checks on every tenant" — rather than
      as two assertions about Gmail. Both defects arrived with a new tenant, and
      the next one would arrive the same way.

      Confirmed by restoring the old branch and watching both fail, per A new guard
      is not proved until it has been broken
      in CLAUDE.md.

    Downloads
  • v0.31.1 0e47bba829

    v0.31.1 — an unmatched folder name is not a misconfiguration
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-22 21:10:16 -05:00 | 4 commits to main since this release

    An unmatched folder name no longer reads as a misconfiguration. One error
    string on the Gmail tenant; no tool, argument or description changed, so no
    reconnect is needed
    — a service image update is enough.

    Fixed

    • resolve_folder's refusal said the wrong thing about WHY a label was
      missing.
      It read "there is no folder named X available to this server … a
      name that is not listed cannot be read at all"
      , which is true of an
      allowlisted deployment and false of a label created ten minutes ago:
      list_folders is cached for FOLDER_LIST_TTL, an hour by default, so a new
      label is genuinely absent from that list for up to an hour after it exists.

      Found in the field, and the cost was a wrong recommendation rather than a
      wrong read.
      A model asked for a label created minutes earlier, got this
      refusal, and reported to the operator that the label "wasn't allowlisted" and
      should be added to the read allowlist — on a tenant whose MAIL_FOLDERS is
      deliberately empty, meaning every label is readable and there is no allowlist
      to add anything to. The refusal itself was correct; its explanation produced a
      confident, wrong config change.

      It now says the server cannot currently see the name, that a recently
      created label may simply not be listed yet, that the list is cached for up to
      an hour, and not to advise changing any server setting on the strength of the
      message. The 0.29.7 half — this is not the same as the folder being empty
      is unchanged and still the more important half.

    • The test pinned the sentence rather than the property, so it would have
      passed on any wording that happened to contain the old phrase and failed on
      every improvement to it. It now asserts what the refusal has to prevent: a
      reader concluding "no mail", and a reader concluding "misconfigured". Both are
      things that actually happened.

    Notes

    • An error a model acts on is part of the interface. The tenant's own
      CLAUDE.md already says a refusal must be written to be acted on rather than
      merely reported; this is the same rule extended one step, to what the reader
      will infer about the cause and recommend on the strength of it.
    Downloads
  • v0.31.0 814da4878d

    v0.31.0 — G2: the Gmail tenant can write, and a move is not a move
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 16s
    Stable

    joeyr released this 2026-08-22 13:59:41 -05:00 | 8 commits to main since this release

    G2. The Gmail tenant can write. update_message, move_message and delete_message,
    behind MAIL_WRITABLE_FOLDERS and registered nowhere when it is empty — which it still is
    in the stack file, because the setting does not go in until the live pass has run.

    The tool list changed, so reconnect the Gmail connector. Claude caches tool
    descriptions per connector and does not refetch them because a container restarted.

    Added

    • update_message(message_id, read, starred). Two optional flags, neither defaulted;
      passing neither is an error rather than a no-op reported as success. starred, where
      the mail tenant says flagged
      — Apple's clients draw a flag, Gmail draws a star and
      has a Starred view a user asks for by name. Probe 10g confirmed the IMAP \Flagged bit
      and the [Gmail]/Starred label are the same thing, so the rename costs nothing
      underneath.

    • move_message(message_id, destination_folder), and it says what it really does.
      Gmail has labels, not folders: this removes the source label and adds the destination,
      and the message KEEPS every other label it had and stays in All Mail. The description
      says so in those words, because a model that reports "moved" to a user who then finds
      the message under another label has told them something false about their own mailbox.

      The destination goes through the same four-tier resolver the read tools got in 0.29.7,
      so spam, trash and Sent Mail all land correctly — and a name matching nothing is
      an error, never a silent success. The source does not: it comes out of a handle this
      server minted, and running that through a fuzzy matcher would let a forged near-miss
      resolve onto a real label.

    • delete_message(message_id), trash-only, no second argument, and none ever. Gmail's
      delete_message is born the way 0.30.0 made the mail tenant's, so the two connectors
      never mean two things by one name. expunge_message is G5's.

    Changed

    • A move here is ONE command. icloudmail moves in three — COPY, flag \Deleted,
      scoped UID EXPUNGE — because iCloud advertises no MOVE. Gmail does, probe 10b issued
      one and read a COPYUID back, so that workaround is not ported. Its intermediate state
      — a message flagged deleted but not expunged, hidden in most clients while still
      counting against the label — cannot arise here.

    • _trash_folder resolves the \Trash role FIRST on this tenant, the opposite order
      from next door
      , and the inversion is load-bearing rather than tidy. TRASH_FOLDER is
      shared by both mail tenants and defaults to Apple's Deleted Messages, which is not a
      label on any Gmail account — so config-first would send every delete to a label that
      does not exist, or, if a user happens to have made one by that name, to an ordinary
      label Gmail's Bin view never shows while the tool reported success. Written up in
      CLAUDE.md under A role folder resolves the way the provider guarantees it.

    • The server instructions stop claiming to be read-only when they are not. The
      sentence is now built from the same setting the tools are registered on, so the promise
      in the initialize response and the surface behind it cannot drift apart.

    • New preflight check gmail_trash, silent when writes are off. Not a restatement of
      mail_writable: that asks whether the write allowlist is readable, this asks whether
      the one label every delete lands in is reachable at all. A deployment can pass every
      other check and still refuse every delete.

    Notes

    • probes/gmail_probe_write.py ran first, and G2 was scoped without it. Probes 1–9
      read CAPABILITY, proved COPY additive and proved an expunge inside a label removes the
      label — but never issued a MOVE, never read a COPYUID, and never touched Trash,
      which is three of the four things these tools are built out of. Findings in
      docs/gmail-plan.md under Probe 10. The two that changed the code:

      A COPY to [Gmail]/Trash removes the source label, unlike a COPY to any other
      label, so a delete really does take the message out of the mailbox and the description
      may say so. And Trash HIDES a message's other labels rather than stripping them — a
      two-label message put in Trash was invisible in both while X-GM-LABELS still reported
      them, and moving it back out brought the untouched one with it. That is what lets
      delete_message promise a reversible delete instead of hedging.

    • Two of five new tests passed against deliberately broken code, and both were fixed
      rather than trusted. One asserted a true thing the broken version also satisfied; the
      other could not reach its own hazard because the fake ASSIGNED where imaplib APPENDS.
      The rule is now in CLAUDE.md under A new guard is not proved until it has been
      broken
      .

    • Not yet run live. G2's pass is written up in docs/operations.md beside M1–M5 and
      G1 and is owed before this is trusted on real mail — including the absence check, which
      no unit test can confirm on the deployed service.

    Downloads
  • v0.30.0 ecc1f7a8b1

    v0.30.0 — destroying mail becomes its own tool, its own grant, its own decision
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-22 13:01:04 -05:00 | 9 commits to main since this release

    M6. Destroying mail is its own tool, its own grant, and its own decision.
    delete_message(permanent=true) is gone; expunge_message replaces it. Gmail's write phase
    (G2) is what forced the question — its delete_message had to be specified before it was
    written — and the answer applies to the tenant that already shipped one.

    Breaking, and in the way that costs a minute: the mail connector's tool list changed, so
    reconnect it — Claude caches tool descriptions per connector and does not refetch them
    because a container restarted. Per-tool permission toggles are keyed to tool names, so
    expunge_message arrives at its default and delete_message's existing toggle survives.
    Same shape as 0.14.0's rename, and the same reason it ships on its own rather than riding
    along with the next change.

    Changed

    • delete_message moves to trash and does nothing else. No permanent, no
      expected_subject, one argument. It is the tool a model reaches for when a user says
      "delete", and it can no longer do anything a user would not expect from that word.

    Added

    • expunge_message, behind MAIL_EXPUNGE_ENABLED. \Deleted + UID EXPUNGE on one
      message, expected_subject required rather than conditionally required, and no
      message_id in the response because there is nothing left to name.

      A boolean was the weakest available gate on the one operation with no undo — one token
      in a JSON object, on the same tool as the safe operation, described in a paragraph
      appended to a description whose opening sentence was about moving to trash. Splitting buys
      three things a parameter cannot: a description where every sentence is about
      irreversibility, a grant that can be given and withheld separately, and a decision visible
      in the call rather than buried in an argument.

    • The gate nests, and it is the only one here that does. MAIL_EXPUNGE_ENABLED layers
      over MAIL_WRITABLE_FOLDERS rather than sitting parallel to it: that list says which
      folders may be changed, this says whether destroying is permitted at all, and an expunge
      is both. Turning it on alone registers nothing. While it was a boolean, naming a folder
      writable was already consent to destroy what was in it
      , with nothing in the boot log to
      say so — the grants line now carries expunge=on|off.

      MAIL_FOLDER_ADMIN deliberately does not nest, and the two are not inconsistent:
      managing a collection is a different kind of grant, while destroying is a stronger degree
      of the same one.

    Notes

    • CLAUDE.md said not to do this, and now says when to. "Do not split a tool to dodge
      a bad argument"
      delete_event keeps this/following/all in one parameter, and
      that rule stands. The distinction is written down rather than left to whoever remembers
      this release: delete_event's scope is one operation with three equally-undoable targets,
      while delete-versus-expunge is two operations that differ in whether the mail still
      exists. The test is not could this be one argument — it usually could — but does the
      choice change what a wrong call costs, and would an operator grant one side without the
      other.
      Two yeses is two tools.

    • Named expunge_message, not delete_message_permanently. Two names differing only by
      a suffix are the easiest pair to confuse, which defeats the split. Expunge is IMAP's own
      verb, so nothing about "delete this email" reads as selecting it by accident.

    • A test tried to assert the wrong property and the fake refused it. "A delete never
      expunges" is false about a correct implementation: iCloud advertises no MOVE, so a move
      is COPY, flag the original, expunge the original. The COPY is the whole difference —
      one call leaves the message somewhere and the other does not — and that is what is pinned
      instead.

    • The trash-missing error no longer offers the irreversible path as the way out. It read
      "or pass permanent=true to remove the message outright", which is a suggestion to
      destroy mail because a configuration value is unset. It now names TRASH_FOLDER and says
      explicitly not to reach for expunge_message instead.

    • Not yet run live. M6's live pass is written up in docs/operations.md beside M1–M5
      and is owed before this is trusted on real mail — including the two absence checks, which
      no unit test can confirm on the deployed service.

    Downloads
  • v0.29.7 72318a8ef6

    v0.29.7 — refuse an unmatched folder name, and read headers that are malformed
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 16s
    Stable

    joeyr released this 2026-08-22 12:25:27 -05:00 | 12 commits to main since this release

    Two defects found the same way the last three were: a person used the connector and
    asked what an answer meant.
    Checking the Gmail spam folder produced {"messages": [], "count": 0} — read, reasonably, as "the Spam folder is clear". It held 29 messages. Then
    inspecting one of them failed twice with a TypeError about a type nobody had heard of.

    Fixed

    • A folder name that matched nothing was answered with an empty result instead of an
      error.
      folders=["SPAM"] reached IMAP unchanged; SELECT refused it; search_messages
      caught the refusal per folder — correctly, because one bad label must not lose the rest
      of a search — and the caller was handed a clean, successful, empty answer. The real label
      is [Gmail]/Spam.

      The bug is not that the guess failed. It is that failing was indistinguishable from
      succeeding
      , which is this project's recurring defect in its purest form: nothing in the
      response could tell "this folder is empty" from "this folder does not exist", so there
      was no way to be suspicious of it. list_folders was the only route to the exact
      strings, and nothing required calling it first.

      Names are now resolved against the readable labels before any command is sent, in four
      tiers — the exact name, then case-insensitively, then with the [Gmail]/ prefix
      optional, then by RFC 6154 role, so spam, junk, bin and archive land on the right
      label on an account in any language. A name that still matches nothing is refused
      with the closest real label named: "There is no folder named 'Spem'… Did you mean
      '[Gmail]/Spam'?"
      The four tiers are ordered so a user label literally called Spam
      outranks the role folder it shadows.

      Resolution only ever considers labels MAIL_FOLDERS already allows, so it is not a way
      around the allowlist, and the handle path — where a message_id names its own folder —
      is deliberately left matching exactly.

    • A single 8-bit byte in a header took get_message_security out entirely. The error
      was sequence item 0: expected str instance, Header found, and it repeated because
      nothing about it suggested retrying was pointless.

      Message.get does not always return a string. The compat32 policy wraps any header whose
      raw bytes are not ASCII in an email.header.Header, and that is the ordinary shape of
      spam
      — an unencoded em dash in a From or a List-Unsubscribe is a syntax error the
      RFCs forbid and bulk senders emit anyway. So the tool whose entire job is judging
      suspicious mail was lost to the malformation that makes mail suspicious.

      Two more failures sat behind it, neither reported yet and both reachable from the same
      message: decode_header raised LookupError: unknown encoding: unknown-8bit — which is
      the read path, not the security path — and _unsubscribe raised AttributeError on
      Header.strip. Every header now leaves the message through mail/headers.py, which
      flattens Header, str and absent alike, and falls back to UTF-8 for any charset label
      Python cannot look up rather than raising.

      The bytes are decoded, not replaced. str(Header) renders every non-ASCII byte as
      U+FFFD; decode_header hands the original bytes back, and they are nearly always UTF-8,
      so the character the sender wrote is what a reader sees. A header that is both 8-bit
      and RFC 2047 needs two passes to get there — the first decode_header short-circuits
      on a Header and leaves =?utf-8?B?…?= sitting in the output as literal text.

    Notes

    • The Gmail fake refused nothing before this. select_folder accepted any name, so the
      silent-empty defect could not be reproduced against it however many tests were written —
      the same lesson as 0.29.6's crowded label, one file along. It now answers
      NO [NONEXISTENT] Unknown Mailbox the way the server does, and serves whole-message bytes
      so get_message and get_message_security run end to end for the first time.

    • Verified by reverting. Each new test was run against the unpatched code: the tool
      returns {'messages': [], 'count': 0} for ["SPAM"], and the malformed message raises
      TypeError, LookupError and AttributeError in three separate places.

    • The header fix is in mail/, so the iCloud tenant gets it too — it parses the same
      messages with the same functions and had the same crash waiting in it.

    • icloudmail still has the folder defect. Its _search_targets matches names
      literally in exactly the same way. Not fixed here: its folder story is different enough —
      wildcards, no SPECIAL-USE on Drafts — that the resolver is not liftable unchanged, and
      guessing at it without a live pass is how the last one shipped.

    Downloads
  • v0.29.6 7d3b74c128

    0.29.6 — complete coverage, honest counts
    All checks were successful
    CI / gate (push) Successful in 1m29s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-22 00:19:33 -05:00 | 14 commits to main since this release

    0.29.5's own fix carried a regression, and the acceptance run caught it in one question.
    Asked to search is:unread, the connector reported "the newest 200 of 458" — then pointed
    out that 458 could not be reconciled with the 904 the count tool gives, and said so rather
    than picking one. Both numbers were wrong for different reasons.

    Fixed

    • The truncation warning understated the number it exists to disclose. 0.29.5 set found
      to the count of DISTINCT messages, but built that from the per-folder window, which is
      capped at MAX_MESSAGES. One label held 856 matches and contributed 200, so 656 vanished
      from the total the warning quotes. The true figure was 1088; it said 458.

      This is the worst possible line to get wrong in this direction. Its entire job is to
      say the answer is incomplete, and it was itself incomplete — a reader told there are 458
      believes 458 exist and stops looking. X-GM-MSGID is now fetched for every matching uid,
      not just the window; that is one integer per message and no envelopes, so the expensive
      fetch still runs only over what is returned.

    • The default search could not see archived mail at all. Excluding All Mail was the
      obvious way to avoid duplicates, and it was wrong: an archived message carries no other
      label
      , so All Mail is the only place it exists. On the live account that was 42 unread
      messages
      a default search could not reach, with nothing in the response to suggest the
      answer was partial.

      All Mail is now searched last. That is safe only because 0.29.5 made results collapse
      on message id — placed last it supplies the messages nothing else has and loses every
      duplicate to a real label, so coverage is complete and the folder shown is still a real
      label wherever one exists.

      After both fixes, is:unread reports 1088, which reconciles exactly:
      904 (All Mail) + 155 (Trash) + 29 (Spam).

    Notes

    • The first test written for this passed against the broken code, because every folder in
      the fixture held fewer messages than the cap, so the window never dropped anything. The
      fixture now has a crowded label — the shape the live account actually has. A test whose
      fixture cannot reach the failing branch is not a test
      , and the mutation run is what
      exposed it.

    • Three fixes deep in the same defect now: the aggregate count (0.29.2), the fan-out
      (0.29.5), and the count of what the fan-out did not return (0.29.6). Each was found one
      layer further out than the last, and none by the suite.

    • Every one of them was found by a person using the connector and asking what a number
      meant.
      That is the acceptance test this project did not have, and it has now paid for
      itself three times.

    • G1's live pass re-run and extended: 54 checks, 0 failed. One of its assertions had to
      change, because it encoded the old exclusion — the probe was requiring the behaviour that
      hid the 42.

    Downloads
  • v0.29.5 fe79825200

    0.29.5 — one message, one search result
    All checks were successful
    CI / gate (push) Successful in 1m25s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-21 23:57:10 -05:00 | 15 commits to main since this release

    search_messages returned one message once per label it carried. Found by driving the
    deployed connector through an acceptance run — a person asking it twelve questions — rather
    than by any test here.

    Fixed

    • A search that fans out across labels returned duplicates. from:google older_than:6m has:attachment came back as three results that were one message, sitting in INBOX,
      Sent Mail and a user label; a month of attachments came back as 25 rows for 23 messages.
      Gmail files one message under every label it has, and search_messages concatenated the
      per-label hits.

      This is 0.29.2's defect one tool along. That release fixed the aggregate count and did
      not sweep the fan-out — the same overlap, the same wrong number, in the tool next door.

      Results are now collapsed on X-GM-MSGID, Gmail's own per-message id, which is
      identical across every label a message appears under (verified live). The RFC822
      Message-ID header was considered and rejected: it is written by the sender, so it is
      absent on some mail and forgeable on the rest, and a dedup key an attacker can choose is
      one that can hide a message by colliding with another.

      found counts messages too, not rows — otherwise the truncation warning claims more
      matches than exist.

    • The surviving copy is now the useful one. Default searches order labels INBOX first,
      then user labels, then role folders, and keep the first match. Previously the survivor was
      whatever LIST returned first, which could report a message as sent — or as spam —
      because a copy of it was also there. An explicit folders argument keeps the caller's
      order, since they expressed a preference.

    Notes

    • The fake could not express the bug. _FakeIMAP implemented list_folders and
      folder_status and nothing else, so search_messages had never been run end to end in a
      test — only _search_targets was, which checks the folder list and not what comes back
      from it. The fake now holds messages that carry LABELS rather than a location, which is
      the property under test: a fake where every message sits in one folder cannot fail a
      deduplication bug, however many tests are written against it.

    • Mutation-tested, and the first attempt was wrong. Disabling the continue still
      passed, because the dict collapses by key on its own; only giving every row a unique key
      removes deduplication, and that fails all four new tests. A mutation that does not fail is
      a test that does not test.

    • G1's live pass re-run after the change: 52 checks, 0 failed. Required, because the read
      path was touched.

    Downloads
  • v0.29.4 029db76b63

    0.29.4 — every tenant serves its healthcheck
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-21 23:05:03 -05:00 | 16 commits to main since this release

    The Gmail tenant served no /healthz, so its container never started. Third defect
    found by deploying rather than by testing, and the one that named itself least.

    Fixed

    • build_gmail_mcp registered no healthcheck route. The image carries
      HEALTHCHECK CMD ... urlopen('http://127.0.0.1:8787/healthz'), so the request 404'd,
      failed three times, and Swarm killed the task before it reached RUNNING — over and over.

      Nothing in the symptom named the healthcheck. The boot log was flawless: tenant=gmail,
      all seven preflight checks passed, check=imap detail=10 folder(s) readable, serving | tenant=gmail path=/gmail port=8787. Portainer showed the task stuck in starting
      forever. nginx returned 502, because an unhealthy task is never added to the service VIP,
      so there was nothing behind the proxy to reach. Three layers, three plausible wrong
      diagnoses, and the actual cause invisible from all of them.

      Preflight cannot catch this: it runs before the app is built and asks about IMAP and
      Forgejo, not about our own routing table.

    • subscriptions had the identical gap, since 0.21.0. The only reason it was not the
      tenant that failed is that it has never been deployed. Fixed in the same commit.

    Added

    • tests/test_healthcheck.py — every tenant in TENANTS, asserted against the built
      app's route table. Written as a loop rather than a test beside each tenant, because the
      failure mode is a tenant being forgotten, and a per-tenant test is forgotten in exactly
      the same motion as the route it would have checked. A fifth tenant fails here on the day
      it is added.

    Notes

    • Three defects, three layers, none reachable from the one below. The gate proved the
      code; G1's live pass drove the tool layer and found the unread overcount; the first boot
      found the tenant dispatch; the first deploy found this. Each needed the layer above to
      exist before it could fail — which is the argument for standing a thing up rather than
      declaring it done when the suite is green.
    Downloads