Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Draft sync and threading headers

As-implemented notes for the 0.10.x draft-sync-and-threading work (plan: docs/0.10.x/draft-sync-and-threading-headers-plan.md). Two related capabilities ship together: envelope payloads now carry the RFC 5322 threading identity, and the Drafts folder gained a real server-side lifecycle that the Apple clients use for cross-device draft sync.

Envelope threading headers

/list_envelopes and /search_envelopes emit three additional fields per envelope, in the same wire shape /fetch_message has always used — lists of angle-bracketed ids:

{
  "message_id":  ["<abc@mail.example>"],
  "in_reply_to": ["<parent@elsewhere.example>"],
  "references":  ["<root@elsewhere.example>", "<parent@elsewhere.example>"]
}

The Apple clients consume the fields two ways: ReplyBuilder prefers the real references chain (falling back to [In-Reply-To, Message-ID] for pre-rollout envelopes), and the message-detail view overlays the headers parsed from the fetched body onto the envelope before seeding a reply — so replies from an open message thread correctly even against cached envelopes that predate the rollout.

Draft lifecycle: /save_draft

/send with draft: true remains the create-only path the React client uses (response shape unchanged). The new /save_draft endpoint adds the lifecycle an autosave-style sync loop needs. It takes the /send compose payload (same sender authorization, header-injection validation, attachment staging, and MIME assembly, now shared via lambda/api/_shared/compose.py) plus:

Field Meaning
op save (default) or discard.
replaces_uid, replaces_uidvalidity The prior server copy this save supersedes (or, for discard, the copy to remove). Both or neither.

Responses:

Safety posture, mirroring the trash-scoping of the purge endpoints:

/send additionally accepts discard_draft_uid + discard_draft_uidvalidity: after successful SMTP delivery it best-effort expunges that Drafts copy (same guard, same scope), so send-from-draft cleans up the server copy in the same spirit as the queued Sent copy. Failures are logged, never surfaced — the mail has already been delivered.

Drafts deliberately retain Bcc (the user is still composing), and a draft saved without a Message-Id stays outside /send’s dedupe window until send assigns one.

Apple client draft sync

DraftStore (5-second local autosave) remains the live editing buffer and the crash-recovery story. Server saves happen:

The sync loop is last-writer-wins keyed on (uidvalidity, uid): each save records the returned coordinates and passes them as replaces_* on the next one; send passes them as discard_draft_uid; the compose window’s “Discard draft” also discards the server copy. A failed replace degrades to save-as-new server-side, so a conflict produces a duplicate draft, never a lost one.

Resume: opening a message in the Drafts folder offers Edit Draft, which seeds compose from the already-fetched message — recipients and subject from the envelope, Bcc and threading from the message headers, and the body from the text/plain part. Both first-party composers are Markdown-canonical and emit the Markdown source as the text part, so the round trip is lossless for our own drafts; an HTML-only draft from a foreign client falls back to editing the raw HTML through the Markdown buffer (Markdown passes inline HTML through, so content is preserved).

Two deliberate simplifications relative to the plan:

Operator notes