fix(sync): export draft changes under the client's ServerId - #93
Merged
Conversation
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
🔍 CI ResultsOverall: ❌ 12/12 lanes failed TL;DR: ❌ Quality issues: PHPStan: 243 unique errors in 11 lanes; PHP-CS-Fixer: 29 files. Summary by PHP Version
Quality Metrics
❌ Failed Lanesphp8.0-dev
php8.0-stable
php8.1-dev
php8.1-stable
php8.2-dev
php8.2-stable
php8.3-dev
php8.3-stable
php8.4-dev
php8.4-stable
php8.5-dev
php8.5-stable
CI powered by horde-components • View full results |
This was referenced Jul 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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:
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)
Remove(<new UID>); the client knows only the old ServerId and silently drops the command. Edited drafts were never removed from the device.Sync FETCHfor 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_draftUidAliasesmap (live UID → client ServerId) with lookup in both directions, replace-on-re-edit semantics, and serialization round-trip (optionaldakey, 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 forchangeMessage,importMessageDeletion,importMessageMove, andimportMessageReadFlag; 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
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 whileupdateState()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.AppointmentTest,FindRequestTest,Rfc822Testthat also fail onFRAMEWORK_6_0).