• v0.29.3 6cd989a737

    0.29.3 — the boot log describes the right tenant
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-21 22:55:28 -05:00 | 17 commits to main since this release

    The Gmail service's first real boot found two defects, both in what the operator reads.
    The tenant itself was fine — check=imap detail=10 folder(s) readable, the same ten labels
    G1's live pass saw. What was wrong was every line describing it.

    Fixed

    • Preflight demanded icloud_apple_id from the Gmail tenant, on every boot.
      _check_secrets was a two-way icloudmail-or-else, so gmail fell down the calendar
      side and asked for an Apple ID it has no use for and will never have. The process then
      started anyway with a failed check — the worst of both, because an operator who sees a red
      line at every boot stops reading them, and the next one is the real failure. The identity
      pair is now named per tenant, so a fifth tenant is a KeyError here rather than a wrong
      credential name in a log.

    • The boot summary described calendars on a mail tenant. summary() dispatched on
      tenant == "icloudmail", so Gmail got the calendar branch and announced caldav_url,
      apple_id_domain, calendars=(all), writable_calendars=(none, read-only),
      invites=off, calendar_admin=off, ics_feeds=(none) and max_events — on a process
      with no calendar at all — while printing not one mail field.

      Not cosmetic. That log is what answers what can this thing do to my mail?, and it
      answered a different question fluently. writable_calendars=(none, read-only) even reads
      as reassurance, which is worse than silence, because the reader takes it for the mail
      answer. Now dispatches on is_mail_tenant.

    Notes

    • Same root cause twice, and is_mail_tenant was written to prevent exactly it. Its
      docstring says the next mail provider should not have to find every
      tenant == "icloudmail" in the codebase — and then two of them went unswept in 0.29.1.
      A sweep found four in total; the other two already had explicit gmail branches.

    • 1106 tests could not see either. Both live in per-tenant dispatch that no Gmail test
      exercised: the boot summary had never been asserted for this tenant, and _check_secrets
      had never been called with a Gmail config. Three tests now pin them, and reverting either
      fix fails them.

    • The gate did not catch this and could not have. It took a container, a stack and a
      real Google account — which is the same argument the phase gates make, one layer further
      out. G1's live pass drove the tool layer; it never booted the process.

    Downloads
  • v0.29.2 97bbcced61

    0.29.2 — unread counts are distinct, not summed
    All checks were successful
    CI / gate (push) Successful in 1m50s
    CI / image (push) Successful in 18s
    Stable

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

    G1's live pass ran, and it earned its keep. 52 checks through the tool layer against the
    real mailbox, and the first run failed one of them — which turned out to be a defect in the
    tool, not in the check. Written up in docs/operations.md under Reading (G1).

    Fixed

    • list_unread_counts overstated the unread total by 34% — 1214 reported against 904
      actual. total_unread summed the per-label counts, and excluding All Mail was necessary
      but not sufficient
      : Gmail labels overlap each other too. \Important and \Starred are
      overlays on messages that already carry another label, a user label sits alongside
      \Inbox rather than instead of it, and Trash and Spam are counted in the sum while All
      Mail excludes them.

      total_unread is now All Mail's own UNSEEN — the distinct count, taken once, by Google
      — and the response carries a note telling the reader the per-label numbers overlap and
      must not be added up. When that count cannot be taken the total is omitted rather than
      filled in from the sum: the sum is not a rougher version of this number, it is a different
      and wrong one, and the response says so in the words a reader needs.

      This is the project's recurring defect in its purest form. A request that succeeded, an
      answer that looked authoritative, and a number that was wrong by a third.

    Notes

    • 1103 passing tests could not see it, and the reason is worth keeping. _FakeIMAP
      returned the same UNSEEN for every folder, so no message in the fake was ever in two
      labels at once — the code and the fake shared an assumption the real server does not.
      Three tests now pin the behaviour using the live account's actual numbers, one of them
      reproducing the 1214-against-904 overcount exactly, and reverting the fix fails them.

    • A phase gate is not a formality, and this is the second time that has been demonstrated.
      M3's pass found send_draft destroying the only record of a delivered message. G1's found
      this. Both were shipped code with a green suite behind them.

    • The one failing check was a wrong assertion, not a wrong result: it claimed All Mail
      must hold at least the labelled total, reasoning that All Mail holds a copy of everything.
      The opposite is true. Chasing why it failed is what found the defect — which is the
      argument for investigating a failing probe before adjusting it.

    • The one unknown was benign, and was proved so rather than assumed. The newest INBOX
      message carried no Authentication-Results; it is the self-sent probe 9 message, the
      header is genuinely absent on the server, and the tool's "not applicable" note matched.
      The next two messages are ordinary inbound and parse clean against mx.google.com.

    • Nothing is deployed yet. G1 is verified, not stood up: the NFS export directory, the
      NPM proxy host, the fourth Forgejo OAuth app and the stack entry are all still owed.

    Downloads
  • v0.29.1 1b8c346809

    0.29.1 — G1, with the address the PII guard demands
    All checks were successful
    CI / gate (push) Successful in 1m25s
    CI / image (push) Successful in 15s
    Stable

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

    0.29.0's gate went red in CI and never published an image. The tag and the release
    exist; the registry has no 0.29.0, and :latest stayed on 0.28.3, so nothing was
    deployed from it. This is that release with the one thing CI caught.

    Fixed

    • tests/test_gmail.py used realistic @gmail.com addresses, and test_no_pii
      refuses those in a public repo. Now example.com / example.net, which _RESERVED
      already exempts — rather than extending _ALLOWED, since nothing in the file depends on
      the domain, only on the two tenants having different ones.

    Notes

    • The local gate was green, and was green honestly. test_no_pii walks tracked
      files, and tests/test_gmail.py was still untracked when the gate ran — so the file
      under test was invisible to the test that would have caught it. Committing brought it
      into scope, and CI, which checks out the committed tree, saw it immediately.

      That is the check working, not failing: an untracked file is not published, so scanning
      tracked files is right. What was wrong is the habit of gating before staging. Run the
      gate against the staged tree
      when a commit adds files — git stash push --keep-index
      is what that costs.

    • Reproduced in a clean venv built the way CI builds one — --require-hashes from
      requirements-dev.txt, then -e . --no-deps — rather than guessed at from the run
      status. The API exposes no log endpoint and the web log route does not take a token, so
      reproducing locally is the shorter path.

    • 1100 tests green.

    Downloads
  • v0.29.0 e15f4332db

    0.29.0 — G1, a fourth tenant reading Gmail
    Some checks failed
    CI / gate (push) Failing after 1m22s
    CI / image (push) Has been skipped
    Stable

    joeyr released this 2026-08-21 21:29:11 -05:00 | 21 commits to main since this release

    G1: a fourth tenant, and it reads Gmail. Seven read tools on their own hostname, their
    own connector and their own credential, built against what nine live probes found rather
    than against the RFCs. The code is not live-verified and G1 is not done until it is
    docs/gmail-plan.md carries the pass to run, and a green gate has cleared nothing on this
    project since M2 shipped 799 passing tests around a move_message that had never worked.

    Added

    • TENANT=gmail, with mcp_gateway/gmail/: list_folders, list_unread_counts,
      list_messages, search_messages, get_message, get_message_security and
      get_attachment. Read-only, and not by a switch — there is no write path in the
      package at all, asserted against the built server with MAIL_WRITABLE_FOLDERS and
      MAIL_SEND_ENABLED both set to values that would enable one next door.

    • GOOGLE_MAIL_ADDRESS and GOOGLE_APP_PASSWORD, required only by this tenant, which
      reads neither Apple value. Provider-prefixed rather than generic because these arrive as
      per-service Portainer variables: two service blocks each holding a MAIL_ADDRESS, with
      different mailboxes in them, is the paste hazard Credentials already names for
      MCP_OAUTH_SIGNING_KEY.

    • deploy/nginx/gmail.conf, and Config.mail_address / Config.is_mail_tenant so
      nothing downstream has to know which tenant it is in to ask for the mailbox.

    Changed

    • list_unread_counts and search_messages exclude All Mail from the all-labels
      answer
      , because Gmail files every message there as well as under its label — probe 5
      found every sampled INBOX message in both. Summing across labels without this reports
      roughly double the real unread total, and reports it confidently. Excluded by role,
      never by name
      : the [Gmail]/ folders are localized, so matching the English string
      would silently stop matching and put the double count straight back with no error.

    • Both mail tenants now name their account in every colliding tool description. Seven
      names exist on both connectors, and CLAUDE.md mandates verb-first names with a test
      pinning it — so the names cannot disambiguate and the descriptions have to. A rule
      applied to one of two colliding tenants is not a rule, so iCloud's seven changed in this
      commit too, and a test fails if either side ever drops it.

    Notes

    • Gmail advertises SPECIAL-USE, UIDPLUS, MOVE and X-GM-EXT-1. iCloud has
      neither SPECIAL-USE nor MOVE, so its role-guessing and its COPYUID workaround are
      iCloud's problems and are deliberately not carried across. Copying them would have
      added mystery rather than safety.

    • get_message_security needed no new parsing. mail/authresults.py reads Gmail's
      header unmodified. The tool does add one thing probe 9 found by accident: a message the
      user sent themselves carries no Authentication-Results, because it never left
      Google — so an absent block is reported as not applicable rather than rendered as a
      failure. Everything in Sent looks like that, and it is exactly what a naive
      implementation would flag as suspicious.

    • Two of the new tests were rewritten after they passed. The first fake overrode
      unread_counts and re-implemented the All Mail rule inside itself, which asserts only
      that the test agrees with the test. It now intercepts the connection, so the real method
      runs — and both it and the naming rule were confirmed by breaking the code and watching
      them fail.

    • 1100 tests green (16 new).

    Downloads
  • v0.28.3 3daa875c5a

    0.28.3 — the rest of the shared mail layer leaves the tenant
    All checks were successful
    CI / gate (push) Successful in 1m25s
    CI / image (push) Successful in 14s
    Stable

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

    The rest of the shared mail layer follows the parsers out. 0.28.2 moved the seven
    modules that were already separate files; this moves what was still buried inside
    icloudmail/client.py and is no more about iCloud than they were. Found by starting the
    Gmail client and discovering how much of it would have been a second copy. No behaviour
    changed.

    Changed

    • The eight mail errors moved to mcp_gateway/mail/errors.py. MailError and its
      subclasses describe facts about mail — a folder outside the allowlist, a handle whose
      UIDVALIDITY has moved — not facts about a provider. Imported rather than re-declared
      per tenant so there is exactly one MailError in the process: two would make
      except MailError catch half of what it was written to catch, and the missed half would
      surface as an unhandled exception rather than a tool error.

    • The address, MIME and unsubscribe helpers moved to mcp_gateway/mail/parts.py
      _address, _unsubscribe, _has_attachments, _split_addresses,
      _nth_attachment_bytes, _first_part, _strip_html. Each takes what a FETCH already
      returned and answers a question about it. _has_attachments walks a BODYSTRUCTURE the
      same way whichever server sent it.

    • _body and _attachments became body_of and attachments_of there, and the
      tenant's methods delegate. The only thing tying either to a tenant was a cap read from
      config, so the cap is a parameter now and nothing provider-shaped is left.

    client.py, smtp.py and tools.py still hold everything that is iCloud — the
    COPYUID workaround, the missing SPECIAL-USE, the junk-folder fallback. Those are the
    tenant, and none of them should follow.

    Notes

    • icloudmail/client.py is ~300 lines smaller and every name it used to export is
      still importable from it, so nothing outside had to change. One test now imports
      _strip_html from mail.parts instead, because that is where the behaviour lives and a
      test should name the module that owns it.

    • Why now rather than in 0.28.2: these were not obviously shared until there was a
      second consumer. Extracting on suspicion would have been designing an abstraction with
      one caller; extracting when the second client is being written is the first moment the
      shape is known.

    • 1084 tests green, unchanged.

    Downloads
  • v0.28.2 e29cf704ac

    0.28.2 — the mail parsers leave the iCloud tenant
    All checks were successful
    CI / gate (push) Successful in 1m24s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-21 18:01:59 -05:00 | 26 commits to main since this release

    The mail parsing layer is no longer part of the iCloud tenant. Seven modules that
    never knew which mailbox they were looking at moved to mcp_gateway/mail/, ahead of the
    Gmail tenant that will reuse every one of them. No behaviour changed anywhere.

    Changed

    • authresults, hygiene, unsubscribe, compose, models, messageid and
      headers moved from mcp_gateway/icloudmail/ to mcp_gateway/mail/
      — roughly 2,350
      lines. Each takes an already-parsed email.message.Message and returns a value; none
      opens a connection or names a provider, which is what made the move a rename rather than
      a redesign.

      client.py, smtp.py and tools.py stayed. Those are the tenant.

      Done before the Gmail client rather than after it. Letting gmail/ import from
      icloudmail/ in the meantime would have meant rewriting every one of these imports a
      second time, and leaving a package named for one provider as the home of shared code.

    Notes

    • The acceptance condition was that no test could need editing except its import
      lines
      , and it held: 1084 tests green, and every changed line under tests/ is an
      import statement. The seven moved bodies are byte-identical to what left icloudmail/.
      A test that had needed a real edit would have meant something moved that should not have.

    • Six of the twenty files rewritten are in probes/, which is gitignored, so the gate
      cannot see them and this diff does not show them. They were fixed in the same pass. Left
      alone they would have raised ImportError the next time CLAUDE.md's "re-run
      m2_verify.py whenever the write path is touched" rule was followed — months later, and
      reading as a broken probe rather than a stale import.

    Downloads
  • v0.28.1 da11020843

    0.28.1 — M3 verified live, and the draft that was destroying itself
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 15s
    Stable

    joeyr released this 2026-08-20 22:27:36 -05:00 | 30 commits to main since this release

    M3's live pass ran, and it found something 1082 unit tests had not.

    Probe 6 is answered: submission delivers. 36 checks through the tool layer via probes/m3_verify.py on 2026-08-20, written up in docs/operations.md under Sending (M3). Every message was self-addressed — which is what makes the probe safe to re-run, and also bounds what it proves: delivery through the account, not to a third-party MX.

    Fixed

    • send_draft expunged the draft even when the Sent copy had failed, destroying the only surviving record of a message that had already been delivered and could not be retrieved from anywhere.

      The plan's rule was "do not expunge until both the send and the append have returned", and the code honoured it literally — but returning is not succeeding, and with no Sent copy that draft was the last thing holding what the user had written. The draft is now expunged only when a second copy actually exists: leaving a stale draft costs a deletion, expunging this one costs the message. The response distinguishes kept on purpose from could not be removed, because they are different sentences to the user, and both say the mail has gone either way.

      Found by writing the live checklist before the probe and noticing the code disagreed with it. No unit test could have — the fake and the code shared the assumption, which is this project's oldest failure mode and the third time a probe rather than the suite has caught it.

    What the pass confirmed

    • The gate runs before SMTP: with the Sent folder unwritable the send refuses, reports no send verdict, and nothing is delivered.
    • sent: true, saved_to_sent: false is reachable and reports honestly — the message really did arrive, and the note leads with delivery and says not to resend.
    • expected_recipients guards the reply-all three ways; the refusal says not to resubmit with the computed list.
    • send_draft is four ordered steps, and refuses an INBOX handle — the call that would mail a received message onward to its original recipients.
    • The APPENDUID handle resolves with get_message immediately, to the copy in Sent. The shape iCloud actually returns is now on the record rather than inferred from the RFC.

    Notes

    • docs/icloud-mail-plan.md is deleted. Every phase it tracked has shipped and been verified.
    • On this run SEARCH did find the just-appended Sent copy, where probe 4 had found the index lagging. Recorded rather than acted on — the code never searches, and one run is not a reason to simplify the handle away.
    • Still unverified, deliberately: delivery to a third-party MX, Bcc to a distinct recipient, and the mailto: unsubscribe.
    • 1084 tests green.
    Downloads
  • v0.28.0 969094fa7e

    0.28.0 — M3: sending, and the two verdicts it comes back with
    All checks were successful
    CI / gate (push) Successful in 1m25s
    CI / image (push) Successful in 14s
    Stable

    joeyr released this 2026-08-20 17:17:07 -05:00 | 31 commits to main since this release

    M3: this server can send mail. Five tools behind MAIL_SEND_ENABLED, off by default and registering nothing when off, plus the mailto: half of unsubscribe_message — the larger half, since probe 9 found 24 of the 40 senders offering any facility offer only a mailto against 16 one-click.

    Not live-verified. Probe 6 — does SMTP submission actually deliver — has not run, and a green gate clears nothing here: M2 shipped with 799 passing tests and a move_message that had never worked once. The pass to run is in docs/operations.md under M3: the live pass, and MAIL_SEND_ENABLED stays out of the stack file until it has.

    The widest grant in the project and not close. Every other one is bounded by the account — a wrong delete is in trash, a wrong rename is renamed back, the unsubscribe POST reaches one endpoint the sender chose. A sent message is in somebody else's mailbox and nothing on this account reaches it.

    Added

    • send_message, reply_message, forward_message, create_draft, send_draft.
    • Two verdicts on every send. iCloud does not save a Sent copy server-side on SMTP submission, so a send is submit-then-APPEND: two operations that fail separately and are not atomic. sent: true, saved_to_sent: false is a real outcome — delivered, with only the user's own record missing — and the note says outright not to resend, because a model reading it as failure mails the recipient twice. send_draft adds a third, draft_removed.
    • reply_all requires expected_recipients and refuses on mismatch, naming the difference in both directions. It cannot be satisfied without having read the original, which makes a blind reply-all impossible rather than discouraged.
    • The mailto: unsubscribe path, riding the existing gate unchanged — sending widens how a sender may be contacted, never who may be. One-click wins whenever both are offered.
    • SMTP_HOST, SMTP_PORT, SMTP_TIMEOUT, SENT_FOLDER, DRAFTS_FOLDER, a preflight check that refuses at boot when those folders are missing or unwritable, and the smtp/send + tool/send log phases.

    Security

    • An address is syntax, not a value. A recipient carrying CR or LF ends the header and starts another one — Bcc: being the obvious one to append. The whole C0 range is refused at the boundary, and the refusal never echoes the value back.
    • A mailto: unsubscribe's recipient comes from the URI path and nowhere else. RFC 6068 lets the query name cc, bcc and a second to; honouring those would be an open relay wearing an unsubscribe hat. Only subject and body are read.
    • send_draft works only on a message in the drafts folder — submitting a received message would mail it onward to its original recipients.
    • The Sent folder is proved writable before SMTP is contacted. A refusal arriving after submission is not a gate.
    • Bcc is an envelope recipient and never a header, except in a draft, which send_draft strips before submitting.

    Notes

    • APPENDUID gets its own parser. RFC 4315 gives COPYUID three fields and APPENDUID two, so reusing one would read the wrong field and mint a handle for a UID the server never mentioned. A test pins both shapes.
    • The Sent copy is located by APPENDUID, never by searching — probe 4 found the index lags the write.
    • No retry on an ambiguous SMTP failure: a retry is a second message, not another attempt at the first.
    • 1082 tests green (75 new).

    Full detail in CHANGELOG.md.

    Downloads
  • v0.26.0 a2b712381f

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

    joeyr released this 2026-08-20 16:43:15 -05:00 | 34 commits to main since this release

    Two gaps in unsubscribe_message, found by reading the code rather than by waiting for
    a message to trip them.
    Both are about the same thing: the tool exists so a POST is not
    made blind, and neither the URI it POSTs to nor the answer it gets back was fully
    accounted for.

    Added

    • A redirect now says where it pointed. redirect_seen reported that the endpoint
      answered 3xx — non-compliant under RFC 8058, which forbids senders from returning one —
      but not the part worth knowing, which is whether the endpoint the sender's DKIM signature
      covered just tried to hand this mailbox to a host it did not. The Location header is
      read off the response already in hand: redirect_host names it, and
      redirect_offsite is true when it differs from the host the POST went to. Both appear
      only on a redirect, so their presence is the signal.

      Still not followed, and that is deliberate. Following would re-POST the subscriber
      token to a host nothing vouched for. This reports a destination; it does not visit one,
      and it costs no second request. Host only, never the Location's query string — the
      same rule that keeps the unsubscribe URI out of logs and responses, and senders routinely
      carry the token through into the redirect.

      On an off-site redirect the response's note says outright that the mismatch is
      something to tell the user rather than act on: the POST has already landed, so there
      is nothing to retry, and a model reading 302 without that sentence will report the
      unsubscribe as having failed.

    Fixed

    • A malformed unsubscribe URI raised instead of refusing. List-Unsubscribe: <https://[oops> passes the scheme check, and urlsplit does not return nothing on an
      authority like that — it raises ValueError("Invalid IPv6 URL"), on the first line of
      post_one_click, past the gate and outside the (MailError, BadMessageId) the tool
      layer catches. A bare https:// with no host got as far as a pointless request. Both are
      now the shape refusal unsubscribe_uri_unusable, decided in decide alongside
      not_https and before anything leaves the machine. Not overridable: there is nowhere to
      send.

      This is the module's own rule applied to a case that escaped it — a refusal is a
      result, not an exception
      , because the caller has to tell the user why nothing happened
      and a traceback is not that sentence.

    Verified

    • The transport-failure path now has tests that exercise the conversion, not just the
      wording it produces. The existing test faked the MailError at the client boundary,
      which proved the envelope and nothing about the except httpx.HTTPError that raises it.
      Four failure modes go through it — DNS, connection refused, connect timeout, read
      timeout — plus assertions that the error text and the log line carry neither the URI nor
      the sender's host, since httpx puts the URL in the message of some transport errors.

      Guarded by a test asserting the fixture actually reaches the POST. A message that quietly
      started failing the gate would leave all four passing while testing nothing.

    • unsubscribe_headers_unsigned was checked and needed no change. It is unconditionally
      blocking: allow_unauthenticated cannot carry it, whether or not a hygiene flag is
      present alongside. Signature coverage is a question about the URI's integrity, not the
      sender's identity, so the authentication override has no business carrying it — already
      the case since 0.24.0, and already pinned by a test.

    Downloads
  • v0.25.1 b42b6a586a

    0.25.1 — M4 verified live
    All checks were successful
    CI / gate (push) Successful in 1m22s
    CI / image (push) Successful in 13s
    Stable

    joeyr released this 2026-08-20 16:30:01 -05:00 | 35 commits to main since this release

    0.25.0 shipped folder administration with the one thing that mattered untested, and said so. This closes it. No behaviour changed — the probe found nothing to fix, which is worth recording as plainly as a defect would have been.

    probes/m4_verify.py, 27 checks through the tool layer against the real mailbox. Scratch folders only; INBOX is not in the allowlist the script builds.

    The condition the phase owed

    A rename must invalidate outstanding handles loudly, not silently. A green suite could not answer that — the fake renames a folder in a dict, so a stale handle fails there by construction. Both halves held live:

    • A handle read before the rename comes back as an error naming the folder it could not open. Not a message, and not an empty result.
    • With a new folder deliberately created at the old name and a different message inside it, the stale handle still refuses. Two independent things stop it: iCloud issues the reused name a fresh UIDVALIDITY (observed 17453722531745372267 on a name reused seconds later), and the handle's own UIDVALIDITY check refuses before any fetch.

    That second case is the one the probe was really built for. A message_id carries (folder, UIDVALIDITY, uid), so a colliding UIDVALIDITY on a reused name would have let an old handle resolve to a different message — every guard downstream reasoning about the wrong one. It does not occur.

    Also confirmed

    Each of these is a claim a tool description makes to the user:

    • A created folder is invisible to list_folders until MAIL_FOLDERS names it.
    • A rename carries the folder's messages with it; a duplicate create is refused.
    • Deleting the trash folder is refused as protected.
    • delete_folder without a count refuses and reports the real number; a wrong count and a wrong expected_name each refuse, and none of the three refusals removed anything.
    • The deleted folder's messages do not go to trash. Searched for afterwards and absent — the one thing separating this from delete_message, and the reason its guards are heavier.

    On probing a safety check

    The protected-folder check passes a deliberately impossible expected_message_count alongside, and the rename half names a destination outside the allowlist. Two independent guards would both have to be broken before the probe could touch anything real. A probe that verifies a safety check must not be the thing that fires when the check is missing.

    Write-up in docs/operations.md beside M1's and M2's. docs/icloud-mail-plan.md is down to M3. 969 tests green.

    Downloads