-
released this
2026-08-20 13:14:15 -05:00 | 36 commits to main since this releaseM4: the mailbox's structure, not the messages in it.
create_folder,rename_folderanddelete_folder, behindMAIL_FOLDER_ADMIN— off by default, and off means the tools are absent rather than registered and refusing.Parallel to
MAIL_WRITABLE_FOLDERSrather than nested under it, which is the positionCALENDAR_ADMINalready takes: managing a folder is a different question from managing what is inside it, and neither implies the other.Where it diverges from the calendar, on purpose
delete_calendarreaches every collection on the account.rename_folderanddelete_folderact only on folders insideMAIL_FOLDERS, because the two allowlists are not the same kind of thing:CALENDARSselects among collections the user picked, whileMAIL_FOLDERSis the read boundary for this whole tenant and makes a folder invisible.Reaching past it would destroy mail this server was never permitted to read — and worse, the count guard below would answer "how many messages are in the folder you cannot see" for any name a caller cares to try, turning a guard into an oracle.
create_folderis necessarily exempt: the name does not exist yet, so there was nothing to have granted.delete_foldertakes guards, not aforceflagThe plan said
force=True. That is not a guard — it is a boolean a model sets while re-reading its own last refusal, andCLAUDE.mdhad already settled the question: destructive tools make the caller name what they are destroying.So it takes
expected_nameandexpected_message_count, the two-step shapedelete_calendarestablished. Calling without the count refuses and reports it, which puts the number in front of a human before the second call acknowledges it. A test pins both arguments into the schema and pinsforceout of it.The messages do not go to trash. They go with the folder — the second operation in this tenant that nothing can undo, after
delete_message(permanent=true). INBOX is refused because IMAP forbids it anyway; trash, sent, drafts and junk are refused because this tenant addresses them, and deleting trash destroys what the user throws away in order to be able to undo.rename_folderinvalidates handlesEvery outstanding
message_idnaming the old folder stops working, because a handle carries its folder name verbatim. Inherent, not a defect — but the description says so, or the model reuses handles it read moments earlier and reads the resulting "no such folder" as a failure rather than as its own doing.Renaming a folder out of
MAIL_FOLDERSis refused rather than warned about: this server loses sight of it immediately, including the ability to rename it back.Also
imap/adminlog phase — no folder name logged, only that it happened and, for a delete, how many messages went.folder_admin=on|offon the boot log'sgrantsline, and a preflight check for a gate that is on with nothing it may act on.- All three bust the
list_folderscache, or the next read serves a stale answer to a model that was just told the call succeeded.
Not verified
RENAMEhas never run against the live account.CREATEandDELETEwere exercised during probe 5;RENAMEwas not, and it is the one operation here carrying a live-verify condition. Unit tests cannot clear it — the fake renames a folder in a dict, and what needs checking is that a stale handle fails cleanly rather than resolving to a different message under a collidingUIDVALIDITY. Probe 10 is written up indocs/icloud-mail-plan.md.The same sentence was true of
move_messagefor four releases. Treat this as working-but-unproven.969 tests green.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-20 12:55:47 -05:00 | 37 commits to main since this releaseThe boot summary had grown to nineteen fields on one line. It is the line this project tells you to check after every deploy — it is how
version=andtenant=get confirmed — so it is read often, and at that width it wraps three times in Portainer's viewer and gets skimmed instead.The split
One line per group, behind a short
mcp-gateway startingline carrying onlyversionandtenant:mcp-gateway starting | version=0.24.1 tenant=icloudmail account | tenant=icloudmail imap=imap.mail.me.com:993 mail_address_domain=icloud.com timezone=America/Chicago serving | bind=0.0.0.0:8787 mcp_path=/icloudmail allowed_hosts=icloudmail.mcp.jrendar.org grants | mail_folders=3 allowlisted mail_writable_folders=3 writable trash_folder=(default) unsubscribe=on auth | auth_mode=oauth resource_url=... forgejo_url=... allowed_subjects=joeyr fastmcp_home=/var/lib/fastmcp limits | max_messages=200 mail_body_max_bytes=262144 attachment_max_bytes=5242880 imap_timeout=60Ordered by what gets read first when a deploy looks wrong: which account, then whether it is serving on the right hostname, then what it may do, and only then the auth wiring and the numbers.
grantsis the line the split exists for; on the flat line it sat between a timeout and a URL.The grouping is a partition of
summary(), not a filter, and a test pins that on every tenant: a field is on exactly one line, and a field on no line fails the suite. Without it a setting added later silently stops printing — and the settings most likely to be added here are grants, where "it never printed" and "it is off" look identical from a log.unsubscribe=on|offNot hypothetical, because 0.24.0 had just done it.
unsubscribe_messageshipped with no boot field at all, so whether the tool was registered was answerable only by reading the connector's tool list from a client — and when it was missing there, nothing in the log said why. It is the grant with the widest reach on this tenant, being the sole outbound request to a host the operator did not choose, and every other grant was already confirmable from the boot log.MAIL_UNSUBSCRIBE_ENABLEDis documented0.24.0 missed that too. Every other setting is documented in
.env.exampleat length; this one existed only in code and in a deployment file that is not in this repo — which put the full rationale (the allowlist of non-blocking hygiene flags,dmarc=pass, DKIM coverage of the unsubscribe headers, and what one-click does not cover) nowhere a reader of this project would find it.No behaviour change to any tool. 940 tests pass.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
Source code (ZIP)
-
v0.24.0
Stablereleased this
2026-08-20 00:44:37 -05:00 | 38 commits to main since this releaseThe server could see that a sender offers a one-click unsubscribe and could not use
it.get_messagehas reportedunsubscribe: {one_click: true}since 0.19.0, so the
answer to "unsubscribe me from this" was "tap the button in Mail" — which is one tap, and
fine. The reason to build the tool anyway is the gate, not the convenience: hitting an
unsubscribe endpoint on a hostile message confirms to the sender that the mailbox is live
and read. A person tapping Unsubscribe cannot tell whether the sender authenticated. This
server already computes that, so it can decline to be the thing that answers a phish.Added
-
unsubscribe_message, performing the RFC 8058 one-click POST for one message,
behindMAIL_UNSUBSCRIBE_ENABLED. Off by default, and off means the tool is not
registered rather than registered and refusing — the ruleMAIL_WRITABLE_FOLDERS
already follows, and it matters more here, because the fallback when the tool does not
exist is one tap in Mail.It takes a
message_idand has no URL parameter. The URI is read server-side from
the message's own DKIM-signed header. A tool that POSTs to a URL it is handed is an open
POST proxy reachable by anything that can talk to this server, including text sitting in
an email — so the door is closed by there being no parameter to push on, and a test
pins the argument schema.The request follows the RFC's constraints rather than a generic POST: no cookies, no
authorization, no redirects followed (senders MUST NOT return one, and chasing it would
re-POST this mailbox's token to a host the signature never covered),multipart/form-data
per the SHOULD, and exactly one attempt — a retry against a stranger's endpoint is a
second unsubscribe request, not another go at the first. -
A gate scored against the live account before it was written, which is the only
reason it is the right shape. Probe 9 (2026-08-20, 320 messages across every folder, 16
one-click) ran the designed rules over real mail and refused 11 of the 16, including
the clean, DKIM-aligned,p=quarantinesender the design named as the case that must
proceed. Two conditions were wrong:-
"no DKIM verdict may be unaligned" refuses ordinary bulk mail. An ESP signs with
its ownd=alongside the sender's, so a correctly aligned message carries an
unaligned verdict too. The property actually wanted is "the From domain is
authenticated", which isdmarc=pass— already parsed, already trust-keyed, and true
via SPF alignment even where no DKIM signature aligns. -
"any hygiene flag refuses" refuses newsletters.
reply_to_elsewhereis what
"send from no-reply, reply to support" produces andrecipient_in_unsubscribeis what
every ESP does by construction. Together they refused a Constant Contact newsletter
published underp=reject. Both are now warnings; every other flag blocks.
The flags that survive as blocking are the ones describing deception, and they are
what caught the phish.p=nonewarns and never refuses — 11 of the 16 live one-click
senders publish it, including every obviously legitimate one. -
-
A DKIM
h=coverage check on the offer itself. RFC 8058 requires both unsubscribe
headers to be covered by a valid signature, so coverage proves the URI was not injected
in transit. Read only off a signature whosed=matches aheader.dthat a trusted
verdict recorded as passing — readingh=off an arbitrary signature proves nothing.
Three states, not two: covered, not covered, and no verified signature to read, with the
last warning rather than refusing. Four of sixteen live messages carry two verified
signatures; they agreed in every case, so any one covering both is enough and no
tiebreak rule exists to be disagreed with later.What it does not buy, stated because the temptation is real: the phish signs its own
mail correctly and its unsubscribe header is validly signed too. Integrity, not good
faith. -
allow_unauthenticated, scoped to authentication refusals only. It carries a
dmarc=fail— four of the sixteen live messages are senders that fail DMARC while
passing SPF, and a user may well recognise them — and it can never carry a hygiene flag
or an unsigned offer. "Unauthenticated but I know who they are" is a judgement a person
can make; "this message is impersonating someone" is not, and the argument is
model-settable, so the text arguing for it could come from the message being
unsubscribed from. -
mail/unsubscribeas a log phase. Its own, for the reasonimap/writeis separate
fromimap/fetch: this is the only request this server makes to a host it did not
choose, and "did this server contact that sender" must be answerable without reading
every mail line. The URI never appears — only its hostname. The query string is a bearer
token identifying this mailbox, which makes it account data by the rule inclient.py,
and it stays out of tool responses for the same reason.
Fixed
- A
List-Unsubscribeheader that is present and empty is no longer reported as
unreadable. Eleven messages in the live INBOX arrive that way — confirmed against a
fullBODY.PEEK[]read, so it is the senders and not the fetch.unparsed: truereads
to a model as "there is an unsubscribe here I could not read", which has it tell the
user a facility might exist. The sender advertised nothing, so the true sentence is the
one absence produces. The three states are unchanged and the middle one still means what
it says: content that defeated the parser, not the absence of content.
Not built, deliberately
- No bulk mode. One message per call. A loop over a folder announces the mailbox as
live to every sender in the batch, which is the exact outcome the gate exists to avoid. - No
mailto:unsubscribe. This server cannot send mail — that is M3. It is not a
small gap: 24 of the 40 live senders offering any facility offer only a mailto, more
than the 16 this tool covers. Reported by name rather than failing opaquely. - No auto-move to junk afterwards. Junking is a separate call, and this stays atomic.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.23.1
Stablereleased this
2026-08-19 23:24:50 -05:00 | 39 commits to main since this releaseFixed
docs/operations.mdstill described the 0.22.0 live pass as 16 checks and stopped at
the eighth. It now documents all 21, including the two that matter most for reading the
result: that apassunderp=noneis called a weak claim in both the data and the
assessment, and that a low provider score is never presented as reassurance.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
v0.23.0
Stablereleased this
2026-08-19 23:22:51 -05:00 | 40 commits to main since this releaseA
dmarc=passwas being reported as one fact when it is really two very different
ones. 0.22.0 shipped an authentication block that came back all-green on a confirmed
phishing message. That was accurate — the sender owned their domain and signed their own
mail — but it read as a clean bill of health, and "technically true" is not the bar for a
security signal.Added
-
The published DMARC policy, beside the verdict it qualifies.
passunder
p=rejectmeans the domain owner asks receivers to bin anything that fails, so they
have staked their own delivery on it.passunderp=nonemeans they asked receivers
to do nothing about failures — passing cost anyone controlling the domain almost
nothing, and disposable and abused domains characteristically publish it. The phish
publishedp=none, as did every message in the Junk folder, against 25rejectand
18quarantineacross a 60-message inbox sample.It is not in
Authentication-Resultsat all — it comes from iCloud'sX-DMARC-Policy,
a header already being fetched and discarded. The caveat also rides along in
assessment, because a qualifier the reader has to go looking for is one that gets
skipped. -
The provider's own verdict —
X-Apple-ActionandX-ICL-Score, reported as
providerwith an explicitflagged_as_junk. A different question from
authentication: not "did this domain send it" but "does Apple think it is junk".Reported with the honest limit attached. The scores do separate Apple's own junk
calls (Junk at 4.33; inbox median 3.33, one message of 60 at or above the lowest Junk
score) — but Apple's filter scored the confirmed phish 3.33, exactly the inbox
median, and routed it to INBOX. So only the flagged direction carries information,
andscore_notesays so in the payload rather than in a doc, so that "Apple didn't
flag it" can never be read back as reassurance.
Changed
probes/m5_verify.pynow runs 21 read-only live checks, up from 16, including that
ap=nonepass is described as a weak claim in both the data and the assessment, and
that a low provider score is never presented as reassurance.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.22.0
Stablereleased this
2026-08-19 23:11:51 -05:00 | 41 commits to main since this releaseThe mail tenant can see the junk folder, and can tell you why a message is a phish.
Prompted by a brand-impersonating message in the live INBOX that nothing on the read
surface could characterise. Two probes settled the design before any of it was written,
and both overturned the assumption the work started from.iCloud sends no
\Junkattribute, so discovery finds nothing and the folder name has
to be configured — the opposite ordering from what special-use suggests. And iCloud does
not stamp one combinedAuthentication-Resultsheader: it writes four to six of them,
one verdict each, every one with its ownauthserv-id. The usual "trust only the topmost
instance" rule readsbimi=skippedthere and would have reported that every message in
the mailbox carries no authentication data at all.The finding that reordered the work: the message that prompted it passes SPF, DKIM and
DMARC with every domain aligned. The sender owned the domain they sent from and signed
their own mail correctly, while impersonating an unrelated brand in the display name, the
body and the links. Authentication results would have given it a clean bill of health, so
the header-hygiene signals — not the authentication block — are what catch it.Added
-
get_message_security, a read tool registered with no write grant required, on the
argument that seeing a message is a phish must not require the ability to move it. It
returns hygiene signals, parsed authentication results and anassessmentsentence
together in one response, deliberately not as two tools: a caller handed the
authentication block alone sees four green verdicts and reports a phish as genuine. -
icloudmail/hygiene.py— six sender-independent signals, all network-free and
fixture-tested: invisible and bidi characters in the display name, subject orReply-To;
Latin/Cyrillic-or-Greek homoglyphs within a single word; a brand in the display name
absent from the sending domain; aRe:/Fwd:prefix with noIn-Reply-Toor
References; aReply-Toon another domain; and the recipient's own address, plain, hex
or base64, inside the unsubscribe link. Every flag carries its evidence and the field it
came from — no score, nois_phishingboolean. -
icloudmail/authresults.py— SPF, DKIM, DMARC and ARC as the receiving MTA recorded
them. Nothing is recomputed: SPF cannot be re-evaluated after delivery, and re-verifying
DKIM would manufacture failures a middlebox already caused. Trust is keyed per method
to theauthserv-idthat reported it, so a forged header cannot outvote the real one by
sitting above it, and anything outside the boundary is labelleduntrusted_claimsrather
than merged. -
JUNK_FOLDER(defaultJunk) andMAIL_AUTHSERV_DOMAIN(defaulticloud.com),
with a preflight advisory when the junk folder is not inMAIL_FOLDERS— a mismatch that
otherwise fails silently, leaving the folder readable but untagged. -
probes/m5_verify.py, the repeatable read-only live check. 16 checks against the real
mailbox, all passing, driven through the tool layer rather than the client.
Changed
-
list_folderstags the configured junk folderrole: "junk"when the server
volunteers no attribute, config first and\Junksecond — the same resolution order as
trash_folder, for the same reason. A real attribute still wins. -
get_messagecarrieshygiene_flagsand ahygiene_note, names only and only when
something fired, so an ordinary read pays nothing for it. Without the pointer a reader
never learns the second tool exists, and the signals would be absent from exactly the
messages they were written for. -
DKIM verdicts always travel with their signing domain and an
alignedboolean. A
valid signature proves only that the signing domain sent the message, and a phisher
signs their own mail and passes. -
DMARC deliberately carries no
alignedkey. Itsheader.fromis the From domain,
so comparing them returns true on a forged message too — a security check that cannot
fail is worse than no field. It reportsevaluated_from_matches_headerinstead, which
differs only when a message carries twoFromheaders.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-12 15:58:48 -05:00 | 51 commits to main since this releasemove_messagenever worked, anddelete_messagewithoutpermanentnever worked
either, because it is a move. Both failed closed with "the server did not say where it
landed" for the whole life of M2. Fixed, and M2 is now verified against the live mailbox
— 27 checks, all passing.Fixed
-
The
COPYUIDwas read from the wrong place.IMAPClient.copy()returnsNoneon
iCloud; imaplib files the response code underuntagged_responses["COPYUID"]as
[b"<validity> <src> <dst>"], already stripped of the keyword. The parser was reading the
return value, found nothing, and — correctly — refused to expunge rather than guess a
handle. So the damage was a move that always failed rather than one that lost mail, which
is the direction that branch was written to fail in. -
A stale
COPYUIDcan no longer be mistaken for this copy's answer.
untagged_responsesaccumulates across commands on one connection, so the key is now
popped before the COPY as well as after. Left unguarded, a copy that produced no
response code could read the previous one's and mint a handle naming a different message —
worse than refusing, and silent.
Changed
- The test fake now models where the value actually lives. It previously returned a
COPYUIDstring fromcopy(), which no server does — that is precisely why the suite was
green while every real move failed. Two regression tests pin the real shape: one that the
handle comes fromuntagged_responses, one that a leftover entry is not read as this
copy's answer.
Notes
-
This is what the live checklist is for. 799 tests passed against a fake built on the
same wrong assumption as the code, which is the failure mode this project already had a
name for: the fakes agree with the code because the same person wrote both. The probe that
caught it drives the real tool layer against the real mailbox and lives inprobes/,
gitignored, because it names real folders. -
What the run established, beyond the fix: a moved message's new handle resolves to the
same message and reports the destination folder; the old handle refuses rather than
answering; no duplicate is left behind; an omittedupdate_messageargument leaves that
flag alone; both permanent-delete refusals leave the message intact; a default delete
lands in the trash folder and can be moved back out; and — the one that is silent and
unrecoverable when wrong —UID EXPUNGEremoved only the message it named, while a
neighbour flagged\Deletedby another client survived. -
Still owed for M2: the Trash default confirmed on a phone. The probe checks that the
handle names the configured trash folder, which is not the same as Apple showing the
message in the Trash a phone displays.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-12 12:28:12 -05:00 | 54 commits to main since this releaseTwo corrections to mail's tool surface, both found by reading it against the calendar's
rather than by anything failing.Added
-
delete_message(permanent=true)now requiresexpected_subject, checked against what
the handle actually resolves to, and refuses naming both subjects on a mismatch.Every other destructive tool in this project already worked this way —
delete_event
requiresexpected_title,delete_calendarrequiresexpected_nameand an event count —
and mail's was the only one taking a handle alone. It was also the worst place for the
omission: a deleted calendar event can be rebuilt from a listing you already have, while
an expunged message is gone from Apple's servers with no trash, no device copy and no
recovery. The most dangerous operation here was the least guarded.The default delete is deliberately left alone. It lands in trash and returns the
handle to undo it, so demanding a subject every time would be friction with nothing behind
it. Guard the irreversible branch, keep the reversible one cheap.Comparison is stripped and case-folded, matching
delete_event: a caller echoing a
subject back through a model does not reproduce whitespace exactly, and refusing on a
trailing space teaches it to stop passing the argument. The refusal is raised before
anything is flagged, and the tool description says that a mismatch means the wrong message
was addressed — so re-read the folder rather than retrying with the subject from the error,
which would delete precisely the message the guard just saved.
Changed
-
get_unread_count→list_unread_counts. It answers with a collection of folders,
andget_is reserved for fetching exactly one named thing. The old name promised a
number and returned every folder on the account. The optionalfolderargument stays, and
now reads the same waylist_messages(folder=…)does — a list narrowed by a filter, not a
lookup.The rule this settles, now written in
CLAUDE.md:get_returns one named thing,
list_returns a collection. The rest of the surface already obeyed it.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.15.0 — one update_message
Stablereleased this
2026-08-12 12:18:01 -05:00 | 55 commits to main since this releaseOne
update_messagereplaces the three tools 0.14.0 had just created. Mail's write
surface is nine tools rather than eleven:update_message(message_id, read=None, flagged=None)Both arguments are optional and default to unset rather than to a value. An omitted one
is left alone —update_message(read=true)on a starred message does not clear the star —
and passing neither is an error rather than a no-op that reports success.0.14.0's split was wrong, and this repo already contained the argument against it.
That release justifiedupdate_message_read/update_message_unreadon the theory that a
model passes booleans backwards. Butdelete_eventnext door puts a far more dangerous
three-way choice —this/following/all, where the wrong pick ends a recurring
commitment — in a single parameter with no default, and refuses when it is missing.
update_eventtakes seven optional fields and refuses when given none. The convention was
settled; the split was inventing a second answer to a question already decided, and paying
for it in tool count and in duplicate permission toggles for two operations of identical
risk.0.14.0 was tagged but never deployed, so in practice this is one rename rather than two,
and one reset of Claude's per-tool permission toggles rather than two.Changed
-
mark_read,mark_unread,flag_message→update_message. Renamed by 0.14.0,
merged here. The connector now lists:delete_message,get_attachment,get_message,
get_unread_count,list_folders,list_messages,move_message,search_messages,
update_message. -
The client keeps two methods,
set_readandset_flagged, because they are two
STOREs against two IMAP flags — that is protocol shape, not tool shape. Neither takes a
default: a write method that assumes which way you meant is the same class of mistake as
a tool that does. -
A partial failure reports what actually changed. Two flags are two round trips, so the
first can land and the second fail. The error envelope carriesupdated: falseand the
flag that did change, rather than dropping it and describing the message wrongly.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-12 12:12:28 -05:00 | 56 commits to main since this releaseMail's tool names now follow the same verb-first convention the calendar settled on in
0.10.1. Three renames, no behaviour change:Was Now mark_readupdate_message_readmark_unreadupdate_message_unreadflag_messageupdate_message_flagBreaking in the one way that matters day to day: Claude's per-tool permission toggles
are keyed to tool names, so the three reset to defaults and have to be set back to ask.
That cost is why this shipped as its own release rather than riding along with the next
change — and why the remaining names were left alone rather than churned again later.Changed
-
mark_andflag_were the strays. The connector lists tools alphabetically, so the
prefix is the only lever on how they group for the reader. Mail's eleven now sort into
delete_,get_×3,list_×2,move_,search_,update_×3, and the three write
tools that change a message's state sit together instead of scattering across the list. -
The client methods were renamed to match —
ICloudMailClient.update_message_readand
.update_message_flag. Deliberate: grepping a tool name off a connector screenshot should
land on the implementation, and it did not before. -
A test now pins the convention rather than leaving it to review.
mark_readwas named
in a release that also renamed the calendar's strays for exactly this reason, which is
what a convention with no test looks like.
Notes
-
mark_read/mark_unreadstayed two tools rather than collapsing into one
update_message(read=…). Uniformity argued for the collapse; the split survives because
a boolean is what a model passes backwards, andupdate_message(read=false)reads correct
while doing the opposite. The uniformity was available without paying that. -
move_messageandsearch_messageskeep their own verbs. Folding the move into
update_messagewould give one tool two return contracts — passing a destination returns
a newmessage_idand the flag arguments do not — and folding search intolist_messages
would erase the distinction that tells a model it forgot a search term. Both were
considered and rejected on those grounds rather than on taste.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-