-
released this
2026-08-21 22:55:28 -05:00 | 17 commits to main since this releaseThe 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_idfrom the Gmail tenant, on every boot.
_check_secretswas a two-wayicloudmail-or-else, sogmailfell 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 aKeyErrorhere 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 announcedcaldav_url,
apple_id_domain,calendars=(all),writable_calendars=(none, read-only),
invites=off,calendar_admin=off,ics_feeds=(none)andmax_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 onis_mail_tenant.
Notes
-
Same root cause twice, and
is_mail_tenantwas 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 explicitgmailbranches. -
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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-21 22:15:50 -05:00 | 19 commits to main since this releaseG1'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 indocs/operations.mdunder Reading (G1).Fixed
-
list_unread_countsoverstated the unread total by 34% — 1214 reported against 904
actual.total_unreadsummed the per-label counts, and excluding All Mail was necessary
but not sufficient: Gmail labels overlap each other too.\Importantand\Starredare
overlays on messages that already carry another label, a user label sits alongside
\Inboxrather than instead of it, and Trash and Spam are counted in the sum while All
Mail excludes them.total_unreadis now All Mail's ownUNSEEN— 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 sameUNSEENfor 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 foundsend_draftdestroying 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 noAuthentication-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 againstmx.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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-21 21:38:20 -05:00 | 20 commits to main since this release0.29.0's gate went red in CI and never published an image. The tag and the release
exist; the registry has no0.29.0, and:lateststayed on 0.28.3, so nothing was
deployed from it. This is that release with the one thing CI caught.Fixed
tests/test_gmail.pyused realistic@gmail.comaddresses, andtest_no_pii
refuses those in a public repo. Nowexample.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_piiwalks tracked
files, andtests/test_gmail.pywas 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-hashesfrom
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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-21 21:29:11 -05:00 | 21 commits to main since this releaseG1: 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.mdcarries the pass to run, and a green gate has cleared nothing on this
project since M2 shipped 799 passing tests around amove_messagethat had never worked.Added
-
TENANT=gmail, withmcp_gateway/gmail/:list_folders,list_unread_counts,
list_messages,search_messages,get_message,get_message_securityand
get_attachment. Read-only, and not by a switch — there is no write path in the
package at all, asserted against the built server withMAIL_WRITABLE_FOLDERSand
MAIL_SEND_ENABLEDboth set to values that would enable one next door. -
GOOGLE_MAIL_ADDRESSandGOOGLE_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 aMAIL_ADDRESS, with
different mailboxes in them, is the paste hazard Credentials already names for
MCP_OAUTH_SIGNING_KEY. -
deploy/nginx/gmail.conf, andConfig.mail_address/Config.is_mail_tenantso
nothing downstream has to know which tenant it is in to ask for the mailbox.
Changed
-
list_unread_countsandsearch_messagesexclude 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, andCLAUDE.mdmandates 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,MOVEandX-GM-EXT-1. iCloud has
neitherSPECIAL-USEnorMOVE, so its role-guessing and itsCOPYUIDworkaround are
iCloud's problems and are deliberately not carried across. Copying them would have
added mystery rather than safety. -
get_message_securityneeded no new parsing.mail/authresults.pyreads Gmail's
header unmodified. The tool does add one thing probe 9 found by accident: a message the
user sent themselves carries noAuthentication-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_countsand 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-21 18:40:14 -05:00 | 22 commits to main since this releaseThe 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.pyand 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.MailErrorand its
subclasses describe facts about mail — a folder outside the allowlist, a handle whose
UIDVALIDITYhas moved — not facts about a provider. Imported rather than re-declared
per tenant so there is exactly oneMailErrorin the process: two would make
except MailErrorcatch 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_attachmentswalks a BODYSTRUCTURE the
same way whichever server sent it. -
_bodyand_attachmentsbecamebody_ofandattachments_ofthere, 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.pyandtools.pystill hold everything that is iCloud — the
COPYUIDworkaround, the missingSPECIAL-USE, the junk-folder fallback. Those are the
tenant, and none of them should follow.Notes
-
icloudmail/client.pyis ~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_htmlfrommail.partsinstead, 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-21 18:01:59 -05:00 | 26 commits to main since this releaseThe mail parsing layer is no longer part of the iCloud tenant. Seven modules that
never knew which mailbox they were looking at moved tomcp_gateway/mail/, ahead of the
Gmail tenant that will reuse every one of them. No behaviour changed anywhere.Changed
-
authresults,hygiene,unsubscribe,compose,models,messageidand
headersmoved frommcp_gateway/icloudmail/tomcp_gateway/mail/— roughly 2,350
lines. Each takes an already-parsedemail.message.Messageand 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.pyandtools.pystayed. 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 undertests/is an
import statement. The seven moved bodies are byte-identical to what lefticloudmail/.
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 raisedImportErrorthe next timeCLAUDE.md's "re-run
m2_verify.pywhenever the write path is touched" rule was followed — months later, and
reading as a broken probe rather than a stale import.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-20 22:27:36 -05:00 | 30 commits to main since this releaseM3'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.pyon 2026-08-20, written up indocs/operations.mdunder 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_draftexpunged 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: falseis reachable and reports honestly — the message really did arrive, and the note leads with delivery and says not to resend.expected_recipientsguards the reply-all three ways; the refusal says not to resubmit with the computed list.send_draftis four ordered steps, and refuses an INBOX handle — the call that would mail a received message onward to its original recipients.- The
APPENDUIDhandle resolves withget_messageimmediately, 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.mdis deleted. Every phase it tracked has shipped and been verified.- On this run
SEARCHdid 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-20 17:17:07 -05:00 | 31 commits to main since this releaseM3: this server can send mail. Five tools behind
MAIL_SEND_ENABLED, off by default and registering nothing when off, plus themailto:half ofunsubscribe_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_messagethat had never worked once. The pass to run is indocs/operations.mdunder M3: the live pass, andMAIL_SEND_ENABLEDstays 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: falseis 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_draftadds a third,draft_removed. reply_allrequiresexpected_recipientsand 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 thesmtp/send+tool/sendlog 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 namecc,bccand a secondto; honouring those would be an open relay wearing an unsubscribe hat. Onlysubjectandbodyare read. send_draftworks 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_draftstrips before submitting.
Notes
APPENDUIDgets its own parser. RFC 4315 givesCOPYUIDthree fields andAPPENDUIDtwo, 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
v0.26.0
Stablereleased this
2026-08-20 16:43:15 -05:00 | 34 commits to main since this releaseTwo 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_seenreported that the endpoint
answered3xx— 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. TheLocationheader is
read off the response already in hand:redirect_hostnames it, and
redirect_offsiteis 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 theLocation'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
notesays 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 reading302without 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, andurlsplitdoes not return nothing on an
authority like that — it raisesValueError("Invalid IPv6 URL"), on the first line of
post_one_click, past the gate and outside the(MailError, BadMessageId)the tool
layer catches. A barehttps://with no host got as far as a pointless request. Both are
now the shape refusalunsubscribe_uri_unusable, decided indecidealongside
not_httpsand 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 theMailErrorat the client boundary,
which proved the envelope and nothing about theexcept httpx.HTTPErrorthat 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_unsignedwas checked and needed no change. It is unconditionally
blocking:allow_unauthenticatedcannot 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
0.25.1 — M4 verified live
Stablereleased this
2026-08-20 16:30:01 -05:00 | 35 commits to main since this release0.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(observed1745372253→1745372267on a name reused seconds later), and the handle's ownUIDVALIDITYcheck refuses before any fetch.
That second case is the one the probe was really built for. A
message_idcarries(folder, UIDVALIDITY, uid), so a collidingUIDVALIDITYon 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_foldersuntilMAIL_FOLDERSnames it. - A rename carries the folder's messages with it; a duplicate create is refused.
- Deleting the trash folder is refused as protected.
delete_folderwithout a count refuses and reports the real number; a wrong count and a wrongexpected_nameeach 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_countalongside, 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.mdbeside M1's and M2's.docs/icloud-mail-plan.mdis down to M3. 969 tests green.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads