• v0.13.0 3c91ce5f22

    v0.13.0 — mail M2: a writable mailbox
    All checks were successful
    CI / gate (push) Successful in 1m16s
    CI / image (push) Successful in 50s
    Stable

    joeyr released this 2026-08-12 11:56:24 -05:00 | 58 commits to main since this release

    Mail 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 when MAIL_WRITABLE_FOLDERS names 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 with MAIL_FOLDERS, inherited from CALENDARS vs WRITABLE_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_read and mark_unread as two tools, not one taking read: bool. A boolean is
      the kind of argument a model passes backwards, and mark_read(read=False) reads correct
      while doing the opposite.

    • move_message returns a new message_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_id and previous_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_message moves to trash by default and returns the handle it landed on, so
      the delete is reversible with move_message. permanent=true is \Deleted + UID EXPUNGE and 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 in MAIL_WRITABLE_FOLDERS or 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 documented Deleted Messages. Configuration
      first, the server's \Trash attribute 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-checks MAIL_FOLDERS against 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_secrets demanded ICLOUD_APPLE_ID from every tenant. Only CalDAV uses it —
      IMAP authenticates as the iCloud Mail address, and Config.from_env has 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
      advertises UIDPLUS but not MOVE (probe 3), so there is no atomic form available.
      Copying first means the failure mode is a duplicate, never a loss; a MOVE-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 by SEARCH — the index lags the write —
      so looking it up would report a failure that did not happen. A copy that comes back
      without a COPYUID leaves 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 a STORE naming 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: DELETE on 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 server instructions say 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; see docs/operations.md.

    Downloads