Skip to content

fix(sync): export draft changes under the client's ServerId - #93

Merged
ralflang merged 1 commit into
FRAMEWORK_6_0from
fix/draft-uid-alias-wire-identity
Jul 28, 2026
Merged

fix(sync): export draft changes under the client's ServerId#93
ralflang merged 1 commit into
FRAMEWORK_6_0from
fix/draft-uid-alias-wire-identity

Conversation

@TDannhauer

Copy link
Copy Markdown
Contributor

Summary

  • Fixes Deletions from server not propagated to client #92: server-side deletions (and flag changes) of EAS 16 edited drafts never reached the client because they were exported under an IMAP UID the client has never seen.
  • Introduces a per-folder draft UID alias map in the IMAP folder state that remembers which ServerId the client holds for an edited draft, and translates between that wire identity and the live IMAP UID at every protocol boundary.
  • No database schema change: the alias map is serialized into the existing folder state (sync_data), same mechanism as the ghost UID eviction list, for both SQL and Mongo state backends.

Motivation

Relation to the earlier #77 fix

This PR completes the draft identity handling started for #77 (Gmail Drafts up-sync loop). An EAS 16 Draft Modify is applied server-side as IMAP append+delete, so the message gets a new UID on every edit. The #77 fix established that the Sync reply must echo the ServerId the client sent — returning the post-append UID made Gmail reject the SyncKey and loop. It also suppressed the mirror Add(new UID)/Delete(old UID) pair on the next export, so the client would not see a spurious duplicate/delete cycle.

That was correct but one-sided: it pinned the client permanently on the pre-edit ServerId while the server's folder state, mailmap, and change diff engine all moved on to the post-append UID — without any durable record connecting the two. The applied-change map introduced for #77 covers only import retries under the same SyncKey; nothing translated in the other direction. Every later reference to that message — from either side — therefore crossed an identity gap:

  • server → client: exports used the live UID the client has never seen;
  • client → server: commands used a ServerId that no longer exists on IMAP.

Issue #92 is the first symptom of that gap (deletes of edited drafts never reach the device); the other import-side paths listed below were latently broken the same way. This PR makes the wire identity that #77 established durable (per-folder alias in the IMAP folder state) and applies the translation consistently at every protocol boundary, in both directions.

Observed consequences before this change (all in the #92 logs)

  • A webmail-side delete of an edited draft exported Remove(<new UID>); the client knows only the old ServerId and silently drops the command. Edited drafts were never removed from the device.
  • A second client edit of the same draft targeted the stale UID on IMAP.
  • A client delete of an edited draft was misclassified as "already applied" by the mailmap check (the draft-modify mirror-delete row is keyed on the old UID).
  • A client Sync FETCH for an edited draft failed NotFound — which the ghost eviction feature would then escalate into wrongly evicting a live draft.

Changes

  • Horde_ActiveSync_Folder_Imap: new _draftUidAliases map (live UID → client ServerId) with lookup in both directions, replace-on-re-edit semantics, and serialization round-trip (optional da key, no folder cache version bump, old states stay valid).
  • Horde_ActiveSync_State_Base: delegation helpers (recordDraftUidAlias(), getDraftClientIdForUid(), getDraftUidForClientId(), removeDraftUidAliases()); a delivered deletion settles the alias in _acknowledgeExportedChange() (used by both SQL and Mongo backends).
  • Horde_ActiveSync_Connector_Importer: records the alias on every draft modify (including the applied-map retry path); resolves stale client ServerIds to the live UID for changeMessage, importMessageDeletion, importMessageMove, and importMessageReadFlag; mailmap mirror-suppression rows now carry the UID actually touched on IMAP; replies keep echoing the client's ServerId.
  • Horde_ActiveSync_Connector_Exporter_Sync: server-originated Remove/SoftDelete/flag changes are encoded under the aliased client ServerId (updateState() bookkeeping stays on the live UID); fetchIds() resolves aliased fetch requests and echoes the requested id.

Existing drafts that already diverged before this fix are not healed retroactively (the server has no record of their old ServerIds); one resync of the Drafts collection converges them, after which the mismatch cannot recur.

Expected exceptions

No new catch blocks; error paths are unchanged.

Test plan

  • New FolderImapDraftUidAliasTest: alias semantics (both lookup directions, single alias per client ServerId across repeated edits, self/empty guard, removal) and folder state serialization round-trip.
  • Connector/Exporter/SyncTest: deletion exported under the aliased ServerId while updateState() receives the live UID; unaliased deletion unchanged; flag change exported under the alias; fetchIds() resolves the alias and echoes the requested id.
  • Connector/ImporterIdempotentImportTest: draft modify records the alias; second edit resolves to the live UID for the backend and moves the alias to the newest UID; client Remove resolves the alias, reports the client's ServerId, and settles the alias; read flag resolves the alias.
  • Full package unit suite: no regressions (only the pre-existing, unrelated failures in AppointmentTest, FindRequestTest, Rfc822Test that also fail on FRAMEWORK_6_0).

An EAS 16 Draft Modify is applied as IMAP append+delete, so the message
gets a new UID while the Sync reply keeps the client on the ServerId it
sent (Gmail rejects the SyncKey otherwise, see #77). Server-originated
changes for such drafts were then exported under the post-append UID
the client has never seen, so e.g. Gmail silently ignored the Removes
and edited drafts were never deleted from the device.

Record the wire identity as a per-folder draft UID alias in the IMAP
folder state (persisted alongside the folder cache like ghost UIDs, no
schema change) and translate at the protocol boundary:

- Export: Remove/SoftDelete/flag changes and Fetch replies use the
  ServerId the client holds; state bookkeeping stays on the live UID.
- Import: client commands referencing the stale ServerId (second edit,
  delete, move, read flag, fetch) resolve to the live IMAP UID; replies
  keep echoing the client's ServerId.

The alias is dropped once the deletion is delivered to (or imported
from) the client, or the message is moved out of the folder.

Fixes #92
@github-actions

Copy link
Copy Markdown

🔍 CI Results

Overall: ❌ 12/12 lanes failed

TL;DR: ❌ Quality issues: PHPStan: 243 unique errors in 11 lanes; PHP-CS-Fixer: 29 files.

Summary by PHP Version

PHP dev stable
8.0
8.1
8.2
8.3
8.4
8.5

Quality Metrics

  • PHPUnit: did not run (11 lanes exited non-zero with no test output) ❌
  • PHPStan (advisory): 243 unique errors in 11 lanes ⚠️
  • PHP-CS-Fixer: 29 unique files with issues (of 210 checked) ⚠️
❌ Failed Lanes

php8.0-dev

  • PHPUnit: 0 failures, 0 errors

php8.0-stable

  • PHPUnit: ❌ Setup failed (Composer install failed in /tmp/horde-ci/lanes/php8.0-stable/ActiveSync: Your requirements could not be resolved to an installable set of packages.)
  • PHPStan: ❌ Setup failed (Composer install failed in /tmp/horde-ci/lanes/php8.0-stable/ActiveSync: Your requirements could not be resolved to an installable set of packages.)

php8.1-dev

  • PHPUnit: 0 failures, 0 errors

php8.1-stable

  • PHPUnit: 0 failures, 0 errors

php8.2-dev

  • PHPUnit: 0 failures, 0 errors

php8.2-stable

  • PHPUnit: 0 failures, 0 errors

php8.3-dev

  • PHPUnit: 0 failures, 0 errors

php8.3-stable

  • PHPUnit: 0 failures, 0 errors

php8.4-dev

  • PHPUnit: 0 failures, 0 errors
  • PHP-CS-Fixer: 29 files with issues

php8.4-stable

  • PHPUnit: 0 failures, 0 errors

php8.5-dev

  • PHPUnit: 0 failures, 0 errors

php8.5-stable

  • PHPUnit: 0 failures, 0 errors

CI powered by horde-componentsView full results

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deletions from server not propagated to client

2 participants