fix(sync): skip no-op draft rewrites and treat \Deleted as removed - #95
Merged
Conversation
🔍 CI ResultsOverall: ❌ 12/12 lanes failed TL;DR: ❌ Quality issues: PHPStan: 243 unique errors in 11 lanes; PHP-CS-Fixer: 30 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 |
TDannhauer
added a commit
that referenced
this pull request
Jul 26, 2026
Field testing of #95 (issue #94) showed Gmail hardcodes Read 0 in its echoed draft Modifies, so any draft displayed (\Seen) by another client in the meantime failed the no-op comparison and was rewritten anyway, leaving a fresh \Deleted copy behind on every echo. A draft's read state is metadata, not content: exclude it from the comparison. Also log which field failed the comparison whenever a draft Modify is applied as a rewrite, so traffic logs show why a rewrite happened.
Contributor
Author
|
Field testing (issue #94) surfaced two follow-ups, pushed as separate commits so they can be judged independently:
|
Gmail echoes a Modify for every draft Add it receives even when the user changed nothing. Applying it as IMAP append+delete rewrote the draft under a new UID and materialized the client's copy over server state, resurrecting prior versions a third-party IMAP client had flagged \Deleted (#94). Importer: before applying a draft Modify, compare the EAS-visible fields (recipients, subject, importance, read, follow-up flag, categories, body) against the current server copy and acknowledge a no-op without rewriting the message. Any doubt (fetch failure, attachment instructions, mismatch) falls through to the regular rewrite path. IMAP strategies: apply native Exchange semantics to \Deleted across all mail folders. Initial syncs exclude \Deleted messages; a tracked message that gains the flag is exported as a Remove (merged with expunged UIDs); an untracked message carrying the flag is never exported; losing the flag re-exports the message as a regular Add. Fixes #94
Field testing of #95 (issue #94) showed Gmail hardcodes Read 0 in its echoed draft Modifies, so any draft displayed (\Seen) by another client in the meantime failed the no-op comparison and was rewritten anyway, leaving a fresh \Deleted copy behind on every echo. A draft's read state is metadata, not content: exclude it from the comparison. Also log which field failed the comparison whenever a draft Modify is applied as a rewrite, so traffic logs show why a rewrite happened.
Messages flagged \Deleted before the server honored the flag never produce another MODSEQ event, so CONDSTORE change detection never converges them and they linger on clients indefinitely (the flag was ignored by all prior releases). Sweep tracked UIDs once per folder with SEARCH DELETED, remove matches from the client, and persist the swept state in the folder object; a failed search is retried on the next poll. Non-CONDSTORE servers converge without this: the Plain strategy re-reads all flags on every poll. This is one-time convergence logic for the semantic change introduced in this branch; the alternative would be requiring every user to re-provision their account after the upgrade.
ralflang
force-pushed
the
fix/deleted-flag-native-exchange
branch
from
July 28, 2026 07:55
8003d81 to
c9247ab
Compare
ralflang
pushed a commit
that referenced
this pull request
Jul 28, 2026
Field testing of #95 (issue #94) showed Gmail hardcodes Read 0 in its echoed draft Modifies, so any draft displayed (\Seen) by another client in the meantime failed the no-op comparison and was rewritten anyway, leaving a fresh \Deleted copy behind on every echo. A draft's read state is metadata, not content: exclude it from the comparison. Also log which field failed the comparison whenever a draft Modify is applied as a rewrite, so traffic logs show why a rewrite happened.
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
\Deletedper native Exchange semantics in all IMAP change-detection strategies: excluded from initial sync, exported asRemovewhen a tracked message gains the flag, re-exported asAddwhen it loses the flag.Motivation
Issue #94: when a third-party IMAP client (e.g. Thunderbird) updates a draft via append +
\Deletedflag, Gmail resurrects the old version. Two server-side defects combine:\Deletedmessages were synced as live mail, so the old draft version a third-party client flagged for deletion was never removed from the device — the device kept "reconciling" it back.Native Exchange neither rewrites an unchanged item nor exposes messages flagged for deletion. This PR aligns Horde with both behaviors for all EAS clients and all mail folders — no Gmail-specific special-casing.
Relation to #93 / #77
This branch is stacked on #93 (
fix/draft-uid-alias-wire-identity), which keeps the client's ServerId stable across draft UID churn. #93 fixes the identity bookkeeping; this PR removes the churn itself for no-op edits and completes the deletion semantics. Together they close the loop that the ServerId echo from #77 originally opened. The diff shows both commits until #93 merges, after which it reduces to this one.Changes
lib/Horde/ActiveSync/Connector/Importer.php—_isNoOpDraftModify(): before a draft rewrite, fetch the current server copy (untruncated, in the body type the client sent) and compare the EAS-visible fields (To/Cc/Bcc, subject, importance, read, follow-up flag, categories, body with normalized line endings). On a no-op, reply success with the same stable stat shape as a real edit and leave the message untouched. Conservative by design: attachment add/remove instructions, fetch failures, or any mismatch fall through to the regular rewrite path. Expected exception type caught:Horde_Exception(fromHorde_ActiveSync_Driver_Base::fetch()).lib/Horde/ActiveSync/Imap/Strategy/Initial.php— initial sync searches withUNDELETED; covers both the CONDSTORE priming path and non-CONDSTORE servers.lib/Horde/ActiveSync/Imap/Strategy/Modseq.php— a tracked message that gained\Deletedis diverted from the change list into the removals (merged with VANISHED UIDs); an untracked one is never exported. A message that loses the flag is untracked by then and exports as a regularAdd(genuine undelete).lib/Horde/ActiveSync/Imap/Strategy/Plain.php— same semantics for servers without CONDSTORE, applied in the per-poll flag fetch.StrategyDeletedFlagTest(Modseq remove/suppress/merge, Plain remove, InitialUNDELETEDsearch).Upgrade note
Messages already carrying
\Deletedbefore this change converge on their next flag/MODSEQ event or a folder resync; fresh syncs are correct immediately.Test plan
\Deletedhandling)