-
released this
2026-08-12 11:56:24 -05:00 | 58 commits to main since this releaseMail M2: the mailbox becomes writable, behind a folder allowlist that is empty by
default. Five tools —mark_read,mark_unread,flag_message,move_message,
delete_message— registered only whenMAIL_WRITABLE_FOLDERSnames something. A
deployment that does not set it offers exactly the surface it had in 0.12.1.And the mail tenant finally has a preflight. It had none: the branch in
__main__
skipped it, so the service booted to two log lines while the calendar printed eight
checks. A revoked app-specific password looked identical to a healthy start until the
first tool call.Added — mail write tools (M2)
-
MAIL_WRITABLE_FOLDERS, comma-separated, empty by default. Note the deliberate
asymmetry withMAIL_FOLDERS, inherited fromCALENDARSvsWRITABLE_CALENDARS: empty
read means every folder, empty write means none. Both defaults fail safe, and
inverting one to match the other would be worse than the asymmetry. -
mark_readandmark_unreadas two tools, not one takingread: bool. A boolean is
the kind of argument a model passes backwards, andmark_read(read=False)reads correct
while doing the opposite. -
move_messagereturns a newmessage_id, and says which one died. The UID changes
on move, so the handle passed in is dead when the call returns. The response carries
message_idandprevious_message_id, and the tool description says so in the imperative
— this is the contract most easily lost, because nothing fails loudly when it is ignored. -
delete_messagemoves to trash by default and returns the handle it landed on, so
the delete is reversible withmove_message.permanent=trueis\Deleted+UID EXPUNGEand returns no handle, because there is no longer anything to name. -
Trash is not exempt from the write allowlist. A default delete writes into
TRASH_FOLDER, so trash must be named inMAIL_WRITABLE_FOLDERSor the default delete
is refused. A grant that quietly extended itself to a folder the operator never named
would be the allowlist deciding its own scope. -
TRASH_FOLDER, defaulting to Apple's documentedDeleted Messages. Configuration
first, the server's\Trashattribute second — not the reverse, because iCloud does not
advertise SPECIAL-USE, so that attribute arrives by grace and a server that stopped
sending it would silently relocate every delete.
Added — the mail tenant's preflight
imap— logs in and counts readable folders. Zero readable is a failure, not a pass
with an empty count: the credential worked and the allowlist or the account is wrong,
which is a different repair.mail_folders— cross-checksMAIL_FOLDERSagainst what the server actually has. An
unmatched name is a folder the operator believes they granted, and this client makes an
unmatched folder invisible, so every read of it refuses.mail_writable— every writable folder must be readable, since the writable check
runs the readable one first.trash— confirms the folder a default delete writes into is writable and exists.
Otherwise it is invisible until the first delete, which then refuses while naming a folder
the user never mentioned.- Counts, never names. These are the only preflight checks whose subject is the
operator's own mail, and the mail tenant's rule is that no folder name reaches a log. "1
not found" is the whole message; which one is a matter of reading the stack file.
Fixed
_check_secretsdemandedICLOUD_APPLE_IDfrom every tenant. Only CalDAV uses it —
IMAP authenticates as the iCloud Mail address, andConfig.from_envhas required exactly
the right one per tenant since 0.11.0. The check would have failed a correct mail
deployment on a value nothing reads. Latent until now, because the mail branch ran no
preflight at all.
Notes
-
The move is copy → flag →
UID EXPUNGE, and the order is the safety argument. iCloud
advertisesUIDPLUSbut notMOVE(probe 3), so there is no atomic form available.
Copying first means the failure mode is a duplicate, never a loss; aMOVE-shaped
implementation that deleted first would invert that. The expunge is scoped to the one
UID, verified by probe 5 against a folder holding another message already flagged
\Deleted, which survived. -
The new handle is parsed from
COPYUID, never searched for. Probe 4 found that a
just-written message is not immediately findable bySEARCH— the index lags the write —
so looking it up would report a failure that did not happen. A copy that comes back
without aCOPYUIDleaves the original in place rather than guessing: a duplicate is
recoverable, a handle resolving to somebody else's mail is not. -
Every write verifies against what the server said, not against the absence of an
exception. IMAP does not error on aSTOREnaming a UID the folder no longer holds — it
succeeds and affects nothing — so the flag methods read the echoed flags back. This is the
calendar's 0.9.x lesson applied before it could cost anything:DELETEon a CalDAV
collection returns 204 whether or not it deleted something. -
Every write tool's error envelope carries its verdict explicitly as
false. A missing
key is not the same signal as a false one, and a model reading a verdict-less error
envelope reports the write as done. -
Still not built: sending. M3 (
send/reply/forward/drafts) and M4 (folder
administration) remain unwritten, and the serverinstructionssay so — a model that
assumes mailbox changes and composition arrive together will offer to reply. -
Not yet live-verified. M2's own condition, from the plan, is a move that survives a
round trip with a resolving handle and the Trash default confirmed on a phone. Green tests
do not clear it; seedocs/operations.md.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-