Skip to content

mailcore: a draft stays addressable by its local: id after reconcile_id (#1884, #1881) - #1891

Merged
yeroo merged 7 commits into
mainfrom
issue-1884-mailcore-a-draft-stays-addressab
Nov 5, 2026
Merged

yeroo merged 7 commits into
mainfrom
issue-1884-mailcore-a-draft-stays-addressab

Conversation

@yeroo

@yeroo yeroo commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

A draft stays addressable by its local: id after Store::reconcile_id renames it to its Graph id. A compose window left open across a SaveDraft keeps working: later edits, attachments, a second SaveDraft and SendDraft reach the draft. A queued SendDraft { local: } retried after a failed send finds its draft again.

Closes #1884
Closes #1881

What changed

  • Alias on the row. messages.local_id: reconcile_id keeps the local: id there, only for a local: id. The issue proposed an alias table; a column is used instead. It goes with the message on every delete path (delete_message, a delta @removed, an FK cascade), so no trigger or cleanup is needed and no orphan alias is possible. Migration: the swallowed ALTER TABLE like the other columns, then a UNIQUE index created in Store::init.
  • Resolve inside the statement. draft_row_sql! (on messages) and current_id_sql! (for bodies/outbound_attachments) are spliced into the one SQL statement that acts. That keeps it atomic against a reconcile_id the sync thread commits on its own connection. Graph ids are never looked up (substr(?1,1,6) = 'local:' guard).
  • Draft calls that resolve: draft (now one JOIN bodies statement; the returned row's id is the current id), update_draft_fields, mark_sent, move_message, delete_message, add/remove/clear_outbound_attachment(s), outbound_attachments.
  • New Store calls:
    • current_id(id);
    • delete_message_resolved(id) -> Option<String>: DELETE … RETURNING id, one statement.
  • Outbox. ensure_draft_on_graph decides create vs update from the stored row's current id, so an already-reconciled local: id is patched, never created twice. A queued Delete resolves its id; one still local: makes no Graph call (it never reached Graph, short of a delete racing the create).
  • Deletes name the row they removed. The engine's SyncCommand::Delete, lookxy's UI deletes (App::delete_and_send: the DeleteThread confirm and both delete_selected paths) and the mail.delete automation verb delete through delete_message_resolved. They send SyncCommand::Delete with the id of the row actually deleted. The list can still hold a local: id after the sync thread reconciled it.
  • Payloads unchanged. SaveDraft/SendDraft payloads keep the id they were addressed with, and so does SyncEvent::Sent { id }, so a UI tracking pending_ops by its id still finds them.
  • Test server. FakeServer::start_limited (routes with a use limit; start is unchanged). Also the 202/503 reason phrases.
  • Docs. CLAUDE.md: "Mail drafts keep their local: id (mailcore: a draft stays addressable by its local: id after reconcile_id #1884, mailcore: a queued draft op keeps its local: id after reconcile_id, so it can never be retried #1881)".

The four assertions the issue named as intentionally changing (draft(local) was None after reconcile) now assert the row with the Graph id. A fifth, that outbound_attachments(local) was empty after reconcile, now asserts the stored key is the Graph id and the local: id lists the same row.

Testing

cargo fmt --check; cargo clippy -p mailcore -p lookxy --all-targets -- -D warnings; cargo test -p mailcore -p lookxy: mailcore 284 passed, lookxy 332 passed (24 new).

Acceptance cases from #1884 (test server)

  • Save, edit, Send by the local: id, the edit through a second Store connection: 1 POST, 1 PATCH carrying the edit, 1 send, Sent { id: local }.
  • Save, edit, Save: 1 POST, then 1 PATCH carrying the edits; nothing left queued or quarantined.
  • Offline, two queued saves: 1 POST, then 1 PATCH.
  • mailcore: a queued draft op keeps its local: id after reconcile_id, so it can never be retried #1881: the first send returns 503 (Throttled). A command barrier confirms the op is still queued; then Refresh: 1 POST, 1 PATCH, 2 sends, one Sent { id: local }.
  • A queued save then send of the same local: id: the message is sent.

Other tests

  • Store: resolution of every draft call, the alias dropped on delete and on a folder cascade, kept across a delta upsert, the migration of a pre-local_id file, and delete_message_resolved.
  • Outbox: patch-not-create, delete by a reconciled id, no Graph call for an unresolved local: delete.
  • lookxy: deleting a list row and a thread that still hold a reconciled local: id sends the Graph id; mail.delete likewise.

Review record

revmux, 2 rounds (Claude-only profile).

r1:

  • Major, fixed: lookxy's UI delete paths with a stale local: id deleted the draft locally but never on Graph. Fixed with delete_message_resolved everywhere.
  • Minor, fixed: mail.delete resolved and deleted in two statements.
  • Minor, fixed: a vacuous wait in the mailcore: a queued draft op keeps its local: id after reconcile_id, so it can never be retried #1881 test. A 503 is GraphError::Throttled, which emits no state change, so the test now waits on a command barrier.
  • Minor, fixed: an over-strong "never reached Graph" comment.

r2:

  • Minor, fixed: a stale SyncCommand::SaveDraft doc.
  • Pre-existing, folded: Compose::draft_id's doc claimed it becomes the Graph id.

Nothing disputed. review stopped: round 2 had no Major; all findings fixed

Out of scope, filed as follow-ups in #1892:

  • Move/MarkRead/SetFlag/SetCategories ops with a local: id;
  • calendar events' local: ids;
  • attachment re-upload on a retried send;
  • a queued save of a deleted never-synced draft quarantining;
  • busy_timeout on the store's connections.

🤖 Generated with Claude Code

yeroo and others added 7 commits November 5, 2026 05:07
#1881's retried send needs a route that fails once and then succeeds.
`FakeServer::start_limited` takes a use limit per route; a used-up route
is skipped, so the next matching route answers. `start` keeps every route
unlimited, so the existing `Route` literals are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A compose window left open across SaveDraft, or a queued SendDraft
retried after a 503, still holds the draft's local: id. Once the drain
reconciled the draft to its Graph id, every call with that id touched no
row, so later edits were lost, the next SaveDraft/SendDraft failed "no
local draft stored" until it was quarantined, and the message was never
sent.

reconcile_id now keeps the local: id on the row (messages.local_id, with
a migration and a unique index), so the alias goes with the message on
every delete path. Every draft call resolves the id inside its one
statement, atomic against the sync thread's own connection, and
Store::current_id exposes the same rule. ensure_draft_on_graph decides
create vs update by the row's current id, so a reconciled local: id is
patched, never created twice. Delete resolves before the engine's local
delete and queues the resolved id; a local: id that never reached Graph
deletes nothing there instead of being quarantined.

The SaveDraft/SendDraft payloads and Sent { id } keep the id the caller
used. Five existing assertions that draft(local) is None after a
reconcile now assert it returns the Graph-id row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A script can hand mail.delete a draft's local: id after the draft has
been reconciled. The local delete removes the alias along with the row,
so the id is resolved before it, and SyncCommand::Delete names the Graph
id. The reply still echoes the id the caller passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lookxy list can still hold a draft's local: id after the sync thread
reconciled it, since a SaveDraft drain reloads nothing. Deleting that row
then removed the Graph row along with its alias, and SyncCommand::Delete
{ local: } resolved to itself. OutboxOp::Delete returned Ok without any
Graph call, so the draft was gone locally but stayed on Graph, with no
error.

Store::delete_message_resolved runs DELETE ... RETURNING id, so the match
and the delete are one statement that a reconcile_id on the sync thread's
connection cannot split. Callers send Delete with the id it returns.
That covers lookxy's three list deletes (delete_and_send), mail.delete
(no longer two steps), and the engine's Delete arm; when the UI already
deleted the row, the arm falls back to current_id. The outbox comment and
CLAUDE.md now say that an id still resolving to local: is treated as
never having reached Graph, not that it is known to be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wait for PendingOps(1) returned at once: enqueue_and_drain emits it
before draining. A 503 is Throttled, so the engine emits no event at all
and leaves the op's attempts untouched. A FetchBody of an unknown id,
queued behind the SendDraft, is the barrier instead. Its 404 error
arrives only once the send has been handled, and only then does the test
check that nothing was sent and that the op is still queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SyncCommand::SaveDraft's doc still said a local: id is always created,
and Compose::draft_id's doc said it becomes the Graph id. Neither holds
any more. A local: id already reconciled to a Graph id is patched in
place, and a compose window keeps the local: id it opened under, which
the store resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 99.22879% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
mailcore/src/store/mod.rs 98.34% 4 Missing ⚠️
lookxy/src/app.rs 91.66% 1 Missing ⚠️
lookxy/src/ui/mod.rs 98.57% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@yeroo
yeroo merged commit c113227 into main Nov 5, 2026
14 checks passed
@yeroo
yeroo deleted the issue-1884-mailcore-a-draft-stays-addressab branch November 5, 2026 02:16
@yeroo

yeroo commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

[linux] Merged automatically (auto-merge is on for this checkout): review stopped: round 2 had no Major; all findings fixed; merge rounds: none; whole suite green on 4933814 (cargo test --workspace --locked, 9025 tests; the html-export feature needs the wasm target this machine lacks, and CI ran it green); CI 14/14 passed; merge-check ok.

Follow-ups: the leftovers issue #1892, with the plan's five out-of-scope items:

  • Move/MarkRead/SetFlag/SetCategories ops push a draft's local: id to Graph
  • check calendar events for the same stale local: id after reconcile_event_id
  • a retried SendDraft re-uploads its attachments
  • a queued SaveDraft of a deleted never-synced draft quarantines instead of dropping
  • busy_timeout on the store's connections

No finding was disputed or deferred; no duplicate reports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants