From 1a0c9872b208022238236f7f8ae9dc8c2bb3e638 Mon Sep 17 00:00:00 2001 From: Fathi Ben Nasr Date: Wed, 12 Aug 2026 13:12:07 +0000 Subject: [PATCH] AddressBook: do not let an empty side wipe the other during sync Sync() treats an empty list on either side as authoritative: * a local contact holding an etag but missing from the remote listing is deleted locally, so an empty or unparsed listing removes every previously synced contact; * a local tombstone is deleted remotely, so a local store that is empty apart from tombstones prunes the server. An empty list is far more often a fault than a real "delete everything": a fresh or rebuilt local store, a listing that failed to parse, a stale server-side index. On a server whose DAV index had lost its records the listing came back empty and the first sync removed 7 local contacts that existed on both sides moments earlier. Skip the deletions in whichever direction the source list is empty and let the existing import/export reconcile instead. Both guards log a warning with the counts, so a skipped pass is visible rather than silent. When both sides hold data the behaviour is unchanged. --- .../Providers/AddressBook/PdoAddressBook.php | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php index 155ea781d..fa50e4d4b 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Providers/AddressBook/PdoAddressBook.php @@ -160,6 +160,31 @@ class PdoAddressBook $bReadWrite = $this->isDAVReadWrite(); + /** + * An empty list on either side is far more often a fault than a real + * "delete everything": a fresh or rebuilt local store, a DAV listing + * that failed to parse, a stale server-side index. Acting on it + * destroys data the other side still holds, so skip the deletions in + * that direction and let the following import/export reconcile. + */ + $iLocalLive = 0; + foreach ($aLocalSyncData as $aGuardData) { + if (empty($aGuardData['deleted'])) { + ++$iLocalLive; + } + } + $iRemoteCount = \count($aRemoteSyncData); + $bProtectRemote = (0 === $iLocalLive && 0 < $iRemoteCount); + $bProtectLocal = (0 === $iRemoteCount && 0 < $iLocalLive); + if ($bProtectRemote) { + \SnappyMail\Log::warning('PdoAddressBook', "Sync() local store is empty while remote holds" + . " {$iRemoteCount} contacts: importing only, no remote deletions"); + } + if ($bProtectLocal) { + \SnappyMail\Log::warning('PdoAddressBook', "Sync() remote listing is empty while local holds" + . " {$iLocalLive} contacts: keeping local, no local deletions"); + } + // Delete remote when Mode = read + write if ($bReadWrite) { \SnappyMail\Log::info('PdoAddressBook', 'Sync() is import and export'); @@ -168,7 +193,7 @@ class PdoAddressBook if ($aData['deleted']) { ++$iCount; unset($aLocalSyncData[$sKey]); - if (isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) { + if (!$bProtectRemote && isset($aRemoteSyncData[$sKey], $aRemoteSyncData[$sKey]['vcf'])) { \SnappyMail\HTTP\Stream::JSON(['messsage'=>"Delete remote {$sKey}"]); $this->davClientRequest($oClient, 'DELETE', $sPath.$aRemoteSyncData[$sKey]['vcf']); } @@ -183,9 +208,11 @@ class PdoAddressBook // Delete local $aIdsForDeletion = array(); - foreach ($aLocalSyncData as $sKey => $aData) { - if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) { - $aIdsForDeletion[] = $aData['id_contact']; + if (!$bProtectLocal) { + foreach ($aLocalSyncData as $sKey => $aData) { + if (!empty($aData['etag']) && !isset($aRemoteSyncData[$sKey])) { + $aIdsForDeletion[] = $aData['id_contact']; + } } } if (\count($aIdsForDeletion)) {