• 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