Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Draft Sync and Threading Headers Plan (APPEND / Message-ID parity)

Context

Issue #371 rerouted the Apple clients from a hand-rolled IMAP stack onto the Lambda API, and ApiBackedImapClient documents the trade-offs of that path: no APPEND, and envelopes that omit Message-ID. A feature audit (2026-06) traced two concrete capability gaps to those constraints:

A closer audit shows the server is further along than the client-side comments suggest. The pieces that already exist:

What is actually missing is narrow:

  1. Envelope payloads omit the threading headers. envelope_dict emits UID, date, subject, from/to/cc, flags, attachment flag, and priority — nothing else (lambda/api/_shared/helper.py:663-687). So the Apple client populates Envelope.messageId with nil and ReplyBuilder degrades to headerless replies.
  2. The draft APPEND has no lifecycle. _save_draft returns only {"status": "saved"} — no UID — and every save creates a new message. There is no replace, no discard (the purge endpoints are deliberately trash-scoped), and therefore no way to run an autosave-style sync loop without littering the Drafts folder with stale copies.

This plan bridges both gaps. It deliberately stops at the data layer: threading UI is a separate plan that consumes these fields.

Goals

Non-goals

Current state (audit)

Envelope serialization is a single choke point

Both /list_envelopes and /search_envelopes build their payloads through the shared envelope_dict + ENVELOPE_FETCH_KEYS (helper.py:663-687, list_envelopes/function.py:27-28, search_envelopes/function.py:369-374). One change covers both endpoints — and a mistake breaks both, so the change ships with the existing local-test harness exercised for each function.

Two of the three missing fields are already fetched: the IMAP ENVELOPE response that imapclient parses carries message_id and in_reply_to; envelope_dict simply does not serialize them. References is not part of ENVELOPE and needs the existing header fetch widened (the key already pulls X-PRIORITY the same way).

The draft path has no lifecycle

append_drafts is create-only. Dovecot supports UIDPLUS, so the APPEND response already includes APPENDUID <uidvalidity> <uid> — the Lambda just discards it. Replace and discard need \Deleted + UID EXPUNGE, which imapclient supports (expunge(messages=...)), guarded by a UIDVALIDITY check so a mailbox reset can never expunge the wrong message. Deletion is deliberately unavailable elsewhere: purge_messages / empty_trash are trash-scoped by design, and that safety posture should be mirrored here — draft expunge is Drafts-scoped only.

The Apple client is one decode away on threading

ApiBackedImapClient.makeEnvelope documents that messageId is omitted “and no behavior depends” — stale on both counts once the fields exist (ApiBackedImapClient.swift:281-283). ReplyBuilder.threading(for:) already reconstructs References as [in_reply_to] + [message_id]; with a real References list available it should prefer original.references + [original.message_id] per RFC 5322.

Recommendations

Layer 1 — Envelope threading headers (Lambda)

Layer 2 — Apple client threading consumption

Layer 3 — Draft lifecycle (Lambda)

Add a dedicated /save_draft function (new entry in the API function map at terraform/infra/modules/app/locals.tf; the per-function build picks the new directory up automatically):

Markdown round-trip. The Apple compose pipeline’s canonical form is Markdown; drafts are stored as standard text/plain + text/html MIME. First pass: resume by converting HTML back with turndown, which the editor stack already bundles. Edge-case lossiness is acceptable for drafts; if it proves annoying, a text/markdown alternative part (stripped at send) is the escape hatch. Rejected: stuffing Markdown into a custom header — drafts should stay standards-shaped for any IMAP consumer.

Layer 4 — Apple client draft sync

Risks and trade-offs

Phased rollout

  1. Phase 0 — reply threading quick win (client-only). Thread Apple replies from the /fetch_message response. No server dependency.
  2. Phase 1 — envelope fields (Lambda). helper.py fetch-key widening + envelope_dict fields + caps. Verify React renders unchanged. Stage, then prod.
  3. Phase 2 — Apple envelope consumption. Decode fields, extend Envelope with references, prefer real References in ReplyBuilder, retire stale constraint comments (including the CLAUDE.md note).
  4. Phase 3 — draft lifecycle (Lambda + Terraform). /save_draft with replace/discard, /send discard_draft_uid, API Gateway wiring. Stage soak before prod.
  5. Phase 4 — Apple draft sync. Sync service over DraftStore, Drafts folder resume UX, offline queueing.
  6. Phase 5 — documentation. As-implemented docs at the top level of docs/, changelog fragments per shipped phase.

Each phase is independently revertible; Phases 1-2 and 3-4 are parallel tracks once Phase 0 lands.

Future work