From 922960a3b3025e28e82adef577baf06f04de7867 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 19:01:58 +0200 Subject: [PATCH 1/7] Request FolderSync if no hierarchy sync key found Add handling for missing hierarchy sync key during PING. --- lib/Horde/ActiveSync/Request/Ping.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/Horde/ActiveSync/Request/Ping.php b/lib/Horde/ActiveSync/Request/Ping.php index c9a2c3f5..9401d1e2 100644 --- a/lib/Horde/ActiveSync/Request/Ping.php +++ b/lib/Horde/ActiveSync/Request/Ping.php @@ -210,6 +210,16 @@ protected function _handle() // Start waiting for changes, but only if we don't have any errors if ($this->_statusCode == self::STATUS_NOCHANGES) { + if ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$collections->haveHierarchy()) { + $this->_logger->info( + 'No HIERARCHY SYNCKEY in sync_cache during PING, requesting FolderSync.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + $this->_handleGlobalError(); + return true; + } + $changes = $collections->pollForChanges($heartbeat, $interval, ['pingable' => true]); if ($changes !== true && $changes !== false) { switch ($changes) { From 51f123b6cd0ac3fd38719f1f3dd316c759f6badf Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 19:04:10 +0200 Subject: [PATCH 2/7] Add hierarchy check before polling for changes --- lib/Horde/ActiveSync/Collections.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/Horde/ActiveSync/Collections.php b/lib/Horde/ActiveSync/Collections.php index 98b1f63e..0e177ce1 100644 --- a/lib/Horde/ActiveSync/Collections.php +++ b/lib/Horde/ActiveSync/Collections.php @@ -1171,6 +1171,13 @@ public function pollForChanges($heartbeat, $interval, array $options = []) return self::COLLECTION_ERR_SERVER; } + if (!$this->haveHierarchy()) { + $this->_logger->info( + 'COLLECTIONS: Hierarchy sync required, terminating pollForChanges.' + ); + return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED; + } + // Need to update AND SAVE the timestamp for race conditions to be // detected. $this->lasthbsyncstarted = $started; From 376d59239142e5e016e98019e0b053fa46ce00bc Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 19:08:47 +0200 Subject: [PATCH 3/7] Add version check for hierarchy requirement --- lib/Horde/ActiveSync/Collections.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Horde/ActiveSync/Collections.php b/lib/Horde/ActiveSync/Collections.php index 0e177ce1..36518784 100644 --- a/lib/Horde/ActiveSync/Collections.php +++ b/lib/Horde/ActiveSync/Collections.php @@ -1171,7 +1171,8 @@ public function pollForChanges($heartbeat, $interval, array $options = []) return self::COLLECTION_ERR_SERVER; } - if (!$this->haveHierarchy()) { + if ($this->_as->device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$this->haveHierarchy()) { $this->_logger->info( 'COLLECTIONS: Hierarchy sync required, terminating pollForChanges.' ); From 48df6219606530b70a280f4db866c9019d1b9ae0 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 19:21:51 +0200 Subject: [PATCH 4/7] Handle orphan collections during PING in ActiveSync --- lib/Horde/ActiveSync/Collections.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/Horde/ActiveSync/Collections.php b/lib/Horde/ActiveSync/Collections.php index 36518784..403654c3 100644 --- a/lib/Horde/ActiveSync/Collections.php +++ b/lib/Horde/ActiveSync/Collections.php @@ -1226,9 +1226,22 @@ public function pollForChanges($heartbeat, $interval, array $options = []) try { $this->initCollectionState($collection, true); } catch (Horde_ActiveSync_Exception_StateGone $e) { + if (!empty($options['pingable'])) { + $this->_logger->notice( + sprintf( + 'COLLECTIONS: State not found for %s during PING; dropping orphan collection from cache.', + $id + ) + ); + $this->_cache->removePingableCollection($id); + $this->_cache->removeCollection($id, true); + unset($this->_collections[$id]); + $this->save(); + continue; + } $this->_logger->notice( sprintf( - 'COLLECTIONS: State not found for %s. Continuing by rquesting a SYNC.', + 'COLLECTIONS: State not found for %s. Continuing by requesting a SYNC.', $id ) ); From af1a5127e460ed4875c159b932e36e59b27ff77d Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 20:48:02 +0200 Subject: [PATCH 5/7] Refactor collection sync and pingable logic Updated hierarchy check and added folder resync logic for collections. Enhanced pingable collections handling and added logging for missing folder cache entries. --- lib/Horde/ActiveSync/Collections.php | 124 +++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 7 deletions(-) diff --git a/lib/Horde/ActiveSync/Collections.php b/lib/Horde/ActiveSync/Collections.php index 403654c3..5b268285 100644 --- a/lib/Horde/ActiveSync/Collections.php +++ b/lib/Horde/ActiveSync/Collections.php @@ -666,7 +666,8 @@ public function checkStaleRequest() */ public function haveHierarchy() { - return isset($this->_cache->hierarchy); + return !empty($this->_cache->hierarchy) + && $this->_cache->hierarchy !== '0'; } /** @@ -1163,12 +1164,25 @@ public function pollForChanges($heartbeat, $interval, array $options = []) ) ); - // If pinging, make sure we have pingable collections. Note we can't - // filter on them here because the collections might change during the - // loop below. - if (!empty($options['pingable']) && !$this->havePingableCollections()) { - $this->_logger->err('COLLECTIONS: No pingable collections.'); - return self::COLLECTION_ERR_SERVER; + if (!empty($options['pingable'])) { + if ($this->collectionsNeedFolderResync()) { + return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED; + } + + $this->restorePingableCollectionsFromCache(); + + // If pinging, make sure we have pingable collections. Note we can't + // filter on them here because the collections might change during the + // loop below. + if (!$this->havePingableCollections()) { + $this->_logger->err('COLLECTIONS: No pingable collections.'); + if ($this->_as->device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$this->haveHierarchy()) { + return self::COLLECTION_ERR_FOLDERSYNC_REQUIRED; + } + + return self::COLLECTION_ERR_SERVER; + } } if ($this->_as->device->version >= Horde_ActiveSync::VERSION_TWELVEONE @@ -1407,13 +1421,109 @@ public function havePingableCollections() return false; } + /** + * True if a collection has item sync state but no folder cache entry. + * + * The client must run FolderSync to remap folder ids (common after a + * partial cache reset or hierarchy invalidation). + * + * @return boolean + */ + public function collectionsNeedFolderResync() + { + foreach ($this->_cache->getCollections(false) as $id => $collection) { + $synckey = $collection['synckey'] ?? ''; + if ($synckey === '' && !empty($collection['lastsynckey'])) { + $synckey = $collection['lastsynckey']; + } + if ($synckey === '' || $synckey === '0') { + continue; + } + if (!$this->_cache->getFolder($id)) { + $this->_logger->info( + sprintf( + 'COLLECTIONS: Collection %s has sync state but no folder cache entry; FolderSync required.', + $id + ) + ); + + return true; + } + } + + return false; + } + + /** + * Re-mark collections that have synckeys as PINGable. + * + * Outlook and similar clients may send explicit PING folder lists before + * synckeys exist; a previous request must not leave the device with no + * pingable collections until the user resets the account. + * + * @return boolean True if the sync cache was updated. + */ + public function restorePingableCollectionsFromCache() + { + $updated = false; + + foreach ($this->_cache->getCollections(false) as $id => $collection) { + $synckey = $collection['synckey'] ?? ''; + if ($synckey === '' && !empty($collection['lastsynckey'])) { + $synckey = $collection['lastsynckey']; + } + if ($synckey === '' || $synckey === '0' || !$this->_cache->getFolder($id)) { + continue; + } + + if (!$this->_cache->collectionIsPingable($id)) { + $this->_cache->setPingableCollection($id); + $updated = true; + } + + if (!isset($this->_collections[$id])) { + if (empty($collection['class'])) { + try { + $collection['class'] = $this->getCollectionClass($id); + } catch (Horde_ActiveSync_Exception $e) { + continue; + } + } + if (empty($collection['serverid'])) { + try { + $collection['serverid'] = $this->getBackendIdForFolderUid($id); + } catch (Horde_ActiveSync_Exception $e) { + continue; + } + } + if (empty($collection['synckey']) && !empty($collection['lastsynckey'])) { + $collection['synckey'] = $collection['lastsynckey']; + } + $this->_collections[$id] = $collection; + } + } + + if ($updated) { + $this->save(); + } + + return $updated; + } + /** * Marks all loaded collections with a synckey as pingable. */ public function updatePingableFlag() { + if (!count($this->_collections)) { + return; + } + $collections = $this->_cache->getCollections(false); foreach ($collections as $id => $collection) { + if (!isset($this->_collections[$id])) { + continue; + } if (!empty($this->_collections[$id]['synckey'])) { $this->_logger->meta( sprintf( From 11fae69c4f62ec23b5adc4354439c703b09655cd Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 20:48:37 +0200 Subject: [PATCH 6/7] Enhance PING request collection handling Refactor PING request handling to improve collection loading logic and status codes. --- lib/Horde/ActiveSync/Request/Ping.php | 62 +++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/lib/Horde/ActiveSync/Request/Ping.php b/lib/Horde/ActiveSync/Request/Ping.php index 9401d1e2..4349bd9e 100644 --- a/lib/Horde/ActiveSync/Request/Ping.php +++ b/lib/Horde/ActiveSync/Request/Ping.php @@ -143,10 +143,22 @@ protected function _handle() $this->_logger->info('Handling empty PING request.'); $isEmpty = true; $collections->loadCollectionsFromCache(); + $collections->restorePingableCollectionsFromCache(); if ($collections->collectionCount() == 0 || !$collections->havePingableCollections()) { - $this->_logger->warn('Empty PING request with no cached collections. Request full PING.'); - $this->_statusCode = self::STATUS_MISSING; + if ($collections->collectionsNeedFolderResync() + || ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$collections->haveHierarchy())) { + $this->_logger->info( + 'Empty PING with stale folder or hierarchy state; requesting FolderSync.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + } else { + $this->_logger->warn( + 'Empty PING request with no cached collections. Request full PING.' + ); + $this->_statusCode = self::STATUS_MISSING; + } $this->_handleGlobalError(); return true; } @@ -162,6 +174,7 @@ protected function _handle() } $this->_logger->meta(sprintf('Actual heartbeat value in use is %s.', $heartbeat)); if ($this->_decoder->getElementStartTag(self::FOLDERS)) { + $pingCollectionsLoaded = false; while ($this->_decoder->getElementStartTag(self::FOLDER)) { $collection = []; if ($this->_decoder->getElementStartTag(self::SERVERENTRYID)) { @@ -181,14 +194,43 @@ protected function _handle() // iOS clients that request collections in PING before // they issue an initial SYNC for them. $collections->addCollection($collection, true); + $pingCollectionsLoaded = true; } catch (Horde_ActiveSync_Exception_StateGone $e) { } } // Since PING sends all or none (no PARTIAL) we update the // pingable flags so we have it for an empty PING. - $collections->validateFromCache(); - $collections->updatePingableFlag(); + if ($pingCollectionsLoaded) { + $collections->validateFromCache(); + $collections->updatePingableFlag(); + } else { + // Outlook and others may list folders in PING before a + // synckey exists; do not clear pingable flags on cached + // collections. Fall back to the last known PING set. + $this->_logger->info( + 'No collections loaded from explicit PING folder list; using cached collections.' + ); + $collections->loadCollectionsFromCache(); + $collections->restorePingableCollectionsFromCache(); + if ($collections->collectionCount() == 0) { + if ($collections->collectionsNeedFolderResync() + || ($this->_device->version >= Horde_ActiveSync::VERSION_TWELVEONE + && !$collections->haveHierarchy())) { + $this->_logger->info( + 'Explicit PING with stale folder or hierarchy state; requesting FolderSync.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + } else { + $this->_logger->warn( + 'Explicit PING request with no loadable collections.' + ); + $this->_statusCode = self::STATUS_MISSING; + } + $this->_handleGlobalError(); + return true; + } + } if (!$this->_decoder->getElementEndTag()) { throw new Horde_ActiveSync_Exception('Protocol Error'); @@ -246,12 +288,14 @@ protected function _handle() if ($this->_device->version < Horde_ActiveSync::VERSION_FOURTEEN) { $this->_logger->warn('Version is < 14.0, returning false since we have no PINGABLE collections.'); return false; - } else { - $this->_logger->warn('Version is >= 14.0 returning status code 132 since we have no PINGABLE collections.'); - $this->_statusCode = Horde_ActiveSync_Status::STATEFILE_NOT_FOUND; - $this->_handleGlobalError(); - return true; } + + $this->_logger->warn( + 'No PINGABLE collections; requesting FolderSync instead of status 132.' + ); + $this->_statusCode = self::STATUS_FOLDERSYNCREQD; + $this->_handleGlobalError(); + return true; } } elseif ($changes) { $collections->save(true); From 4934feb49a25248bd9cf403fb181f0b9cf8fbfe5 Mon Sep 17 00:00:00 2001 From: Torben Dannhauer Date: Sat, 23 May 2026 20:49:05 +0200 Subject: [PATCH 7/7] Update FolderCreate.php