diff --git a/lib/ext/Syncroton/Command/Sync.php b/lib/ext/Syncroton/Command/Sync.php index 38d0d2e..20ee34d 100644 --- a/lib/ext/Syncroton/Command/Sync.php +++ b/lib/ext/Syncroton/Command/Sync.php @@ -1,1280 +1,1282 @@ */ /** * class to handle ActiveSync Sync command * * @package Syncroton * @subpackage Command */ class Syncroton_Command_Sync extends Syncroton_Command_Wbxml { public const STATUS_SUCCESS = 1; public const STATUS_PROTOCOL_VERSION_MISMATCH = 2; public const STATUS_INVALID_SYNC_KEY = 3; public const STATUS_PROTOCOL_ERROR = 4; public const STATUS_SERVER_ERROR = 5; public const STATUS_ERROR_IN_CLIENT_SERVER_CONVERSION = 6; public const STATUS_CONFLICT_MATCHING_THE_CLIENT_AND_SERVER_OBJECT = 7; public const STATUS_OBJECT_NOT_FOUND = 8; public const STATUS_USER_ACCOUNT_MAYBE_OUT_OF_DISK_SPACE = 9; public const STATUS_ERROR_SETTING_NOTIFICATION_GUID = 10; public const STATUS_DEVICE_NOT_PROVISIONED_FOR_NOTIFICATIONS = 11; public const STATUS_FOLDER_HIERARCHY_HAS_CHANGED = 12; public const STATUS_RESEND_FULL_XML = 13; public const STATUS_WAIT_INTERVAL_OUT_OF_RANGE = 14; public const STATUS_TOO_MANY_COLLECTIONS = 15; public const CONFLICT_OVERWRITE_SERVER = 0; public const CONFLICT_OVERWRITE_PIM = 1; public const MIMESUPPORT_DONT_SEND_MIME = 0; public const MIMESUPPORT_SMIME_ONLY = 1; public const MIMESUPPORT_SEND_MIME = 2; public const BODY_TYPE_PLAIN_TEXT = 1; public const BODY_TYPE_HTML = 2; public const BODY_TYPE_RTF = 3; public const BODY_TYPE_MIME = 4; /** * truncate types */ public const TRUNCATE_ALL = 0; public const TRUNCATE_4096 = 1; public const TRUNCATE_5120 = 2; public const TRUNCATE_7168 = 3; public const TRUNCATE_10240 = 4; public const TRUNCATE_20480 = 5; public const TRUNCATE_51200 = 6; public const TRUNCATE_102400 = 7; public const TRUNCATE_NOTHING = 8; /** * filter types */ public const FILTER_NOTHING = 0; public const FILTER_1_DAY_BACK = 1; public const FILTER_3_DAYS_BACK = 2; public const FILTER_1_WEEK_BACK = 3; public const FILTER_2_WEEKS_BACK = 4; public const FILTER_1_MONTH_BACK = 5; public const FILTER_3_MONTHS_BACK = 6; public const FILTER_6_MONTHS_BACK = 7; public const FILTER_INCOMPLETE = 8; protected $_defaultNameSpace = 'uri:AirSync'; protected $_documentElement = 'Sync'; /** * list of collections * * @var array */ protected $_collections = []; protected $_modifications = []; /** * the global WindowSize * * @var integer */ protected $_globalWindowSize; /** * there are more entries than WindowSize available * the MoreAvailable tag hot added to the xml output * * @var boolean */ protected $_moreAvailable = false; protected $_maxWindowSize = 100; protected $_heartbeatInterval = null; /** * process the XML file and add, change, delete or fetches data */ public function handle() { // input xml $requestXML = simplexml_import_dom($this->_mergeSyncRequest($this->_requestBody, $this->_device)); if (! isset($requestXML->Collections)) { $this->_outputDom->documentElement->appendChild( $this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_RESEND_FULL_XML) ); return $this->_outputDom; } $intervalDiv = 1; if (isset($requestXML->HeartbeatInterval)) { $intervalDiv = 1; $this->_heartbeatInterval = (int)$requestXML->HeartbeatInterval; } elseif (isset($requestXML->Wait)) { $intervalDiv = 60; $this->_heartbeatInterval = (int)$requestXML->Wait * $intervalDiv; } $maxInterval = Syncroton_Registry::getPingInterval(); if ($maxInterval <= 0 || $maxInterval > Syncroton_Server::MAX_HEARTBEAT_INTERVAL) { $maxInterval = Syncroton_Server::MAX_HEARTBEAT_INTERVAL; } if ($this->_heartbeatInterval && $this->_heartbeatInterval > $maxInterval) { $sync = $this->_outputDom->documentElement; $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_WAIT_INTERVAL_OUT_OF_RANGE)); $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Limit', floor($maxInterval / $intervalDiv))); $this->_heartbeatInterval = null; return; } $this->_globalWindowSize = isset($requestXML->WindowSize) ? (int)$requestXML->WindowSize : 100; if (!$this->_globalWindowSize || $this->_globalWindowSize > 512) { $this->_globalWindowSize = 512; } if ($this->_globalWindowSize > $this->_maxWindowSize) { $this->_globalWindowSize = $this->_maxWindowSize; } // load options from lastsynccollection $lastSyncCollection = ['options' => []]; if (!empty($this->_device->lastsynccollection)) { $lastSyncCollection = Zend_Json::decode($this->_device->lastsynccollection); if (!array_key_exists('options', $lastSyncCollection) || !is_array($lastSyncCollection['options'])) { $lastSyncCollection['options'] = []; } } $maxCollections = Syncroton_Registry::getMaxCollections(); if ($maxCollections && count($requestXML->Collections->Collection) > $maxCollections) { $sync = $this->_outputDom->documentElement; $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_TOO_MANY_COLLECTIONS)); $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Limit', $maxCollections)); return; } $collections = []; foreach ($requestXML->Collections->Collection as $xmlCollection) { $collectionId = (string)$xmlCollection->CollectionId; $collections[$collectionId] = new Syncroton_Model_SyncCollection($xmlCollection); // do we have to reuse the options from the previous request? if (!isset($xmlCollection->Options) && array_key_exists($collectionId, $lastSyncCollection['options'])) { $collections[$collectionId]->options = $lastSyncCollection['options'][$collectionId]; if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " restored options to " . print_r($collections[$collectionId]->options, true)); } } // store current options for next Sync command request (sticky options) $lastSyncCollection['options'][$collectionId] = $collections[$collectionId]->options; } $this->_device->lastsynccollection = Zend_Json::encode($lastSyncCollection); if ($this->_device->isDirty()) { Syncroton_Registry::getDeviceBackend()->update($this->_device); } foreach ($collections as $collectionData) { // has the folder been synchronised to the device already try { $collectionData->folder = $this->_folderBackend->getFolder($this->_device, $collectionData->collectionId); } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " folder {$collectionData->collectionId} not found"); } // trigger INVALID_SYNCKEY instead of OBJECT_NOTFOUND when synckey is higher than 0 // to avoid a syncloop for the iPhone if ($collectionData->syncKey > 0) { $collectionData->folder = new Syncroton_Model_Folder([ 'deviceId' => $this->_device, 'serverId' => $collectionData->collectionId, ]); } $this->_collections[$collectionData->collectionId] = $collectionData; continue; } if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " SyncKey is {$collectionData->syncKey} Class: {$collectionData->folder->class} CollectionId: {$collectionData->collectionId}"); } // initial synckey if($collectionData->syncKey === 0) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " initial client synckey 0 provided"); } // reset sync state for this folder $this->_syncStateBackend->resetState($this->_device, $collectionData->folder); $this->_contentStateBackend->resetState($this->_device, $collectionData->folder); $collectionData->syncState = new Syncroton_Model_SyncState([ 'device_id' => $this->_device, 'counter' => 0, 'type' => $collectionData->folder, 'lastsync' => $this->_syncTimeStamp, ]); $this->_collections[$collectionData->collectionId] = $collectionData; continue; } $syncKeyReused = $this->_syncStateBackend->haveNext($this->_device, $collectionData->folder, $collectionData->syncKey); if ($syncKeyReused) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " already known synckey {$collectionData->syncKey} provided"); } } // check for invalid synckey if(($collectionData->syncState = $this->_syncStateBackend->validate($this->_device, $collectionData->folder, $collectionData->syncKey)) === false) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " invalid synckey {$collectionData->syncKey} provided"); } // reset sync state for this folder $this->_syncStateBackend->resetState($this->_device, $collectionData->folder); $this->_contentStateBackend->resetState($this->_device, $collectionData->folder); $this->_collections[$collectionData->collectionId] = $collectionData; continue; } $dataController = Syncroton_Data_Factory::factory($collectionData->folder->class, $this->_device, $this->_syncTimeStamp); switch($collectionData->folder->class) { case Syncroton_Data_Factory::CLASS_CALENDAR: $dataClass = 'Syncroton_Model_Event'; break; case Syncroton_Data_Factory::CLASS_CONTACTS: $dataClass = 'Syncroton_Model_Contact'; break; case Syncroton_Data_Factory::CLASS_EMAIL: $dataClass = 'Syncroton_Model_Email'; break; case Syncroton_Data_Factory::CLASS_NOTES: $dataClass = 'Syncroton_Model_Note'; break; case Syncroton_Data_Factory::CLASS_TASKS: $dataClass = 'Syncroton_Model_Task'; break; default: throw new Syncroton_Exception_UnexpectedValue('invalid class provided'); } $clientModifications = [ 'added' => [], 'changed' => [], 'deleted' => [], 'forceAdd' => [], 'forceChange' => [], 'toBeFetched' => [], ]; // handle incoming data if($collectionData->hasClientAdds()) { $adds = $collectionData->getClientAdds(); if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($adds) . " entries to be added to server"); } $clientIdMap = []; if ($syncKeyReused && $collectionData->syncState->clientIdMap) { $clientIdMap = Zend_Json::decode($collectionData->syncState->clientIdMap); } foreach ($adds as $add) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " add entry with clientId " . (string) $add->ClientId); } try { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " adding entry as new"); } $clientId = (string)$add->ClientId; // If the sync key was reused, but we don't have a $clientId mapping, // this means the client sent a new item with the same sync_key. if ($syncKeyReused && array_key_exists($clientId, $clientIdMap)) { // We don't normally store the clientId, so if a command with Add's is resent, // we have to look-up the corresponding serverId using a cached clientId => serverId mapping, // otherwise we would duplicate all added items on resend. $serverId = $clientIdMap[$clientId]; $clientModifications['added'][$serverId] = [ 'clientId' => (string)$add->ClientId, 'serverId' => $serverId, 'status' => self::STATUS_SUCCESS, 'contentState' => null, ]; } else { $serverId = $dataController->createEntry($collectionData->collectionId, new $dataClass($add->ApplicationData)); $clientModifications['added'][$serverId] = [ 'clientId' => (string)$add->ClientId, 'serverId' => $serverId, 'status' => self::STATUS_SUCCESS, 'contentState' => $this->_contentStateBackend->create(new Syncroton_Model_Content([ 'device_id' => $this->_device, 'folder_id' => $collectionData->folder, 'contentid' => $serverId, 'creation_time' => $this->_syncTimeStamp, 'creation_synckey' => $collectionData->syncKey + 1, ])), ]; } } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " failed to add entry " . $e->getMessage()); } $clientModifications['added'][] = [ 'clientId' => (string)$add->ClientId, 'status' => self::STATUS_SERVER_ERROR, ]; } } } // handle changes, but only if not first sync if(!$syncKeyReused && $collectionData->syncKey > 1 && $collectionData->hasClientChanges()) { $changes = $collectionData->getClientChanges(); if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($changes) . " entries to be updated on server"); } foreach ($changes as $change) { $serverId = (string)$change->ServerId; try { $dataController->updateEntry($collectionData->collectionId, $serverId, new $dataClass($change->ApplicationData)); $clientModifications['changed'][$serverId] = self::STATUS_SUCCESS; } catch (Syncroton_Exception_AccessDenied $e) { $clientModifications['changed'][$serverId] = self::STATUS_CONFLICT_MATCHING_THE_CLIENT_AND_SERVER_OBJECT; $clientModifications['forceChange'][$serverId] = $serverId; } catch (Syncroton_Exception_NotFound $e) { // entry does not exist anymore, will get deleted automaticaly $clientModifications['changed'][$serverId] = self::STATUS_OBJECT_NOT_FOUND; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " failed to update entry " . $e); } // something went wrong while trying to update the entry $clientModifications['changed'][$serverId] = self::STATUS_SERVER_ERROR; } } } // handle deletes, but only if not first sync if(!$syncKeyReused && $collectionData->hasClientDeletes()) { $deletes = $collectionData->getClientDeletes(); if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($deletes) . " entries to be deleted on server"); } foreach ($deletes as $delete) { $serverId = (string)$delete->ServerId; try { // check if we have sent this entry to the phone $state = $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId); try { $dataController->deleteEntry($collectionData->collectionId, $serverId, $collectionData); } catch(Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->crit(__METHOD__ . '::' . __LINE__ . ' tried to delete entry ' . $serverId . ' but entry was not found'); } } catch (Syncroton_Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . ' tried to delete entry ' . $serverId . ' but a error occured: ' . $e->getMessage()); } $clientModifications['forceAdd'][$serverId] = $serverId; } $this->_contentStateBackend->delete($state); } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . ' ' . $serverId . ' should have been removed from client already'); } // should we send a special status??? //$collectionData->deleted[$serverId] = self::STATUS_SUCCESS; } $clientModifications['deleted'][$serverId] = self::STATUS_SUCCESS; } } // handle fetches, but only if not first sync if($collectionData->syncKey > 1 && $collectionData->hasClientFetches()) { // the default value for GetChanges is 1. If the phone don't want the changes it must set GetChanges to 0 // some prevoius versions of iOS did not set GetChanges to 0 for fetches. Let's enforce getChanges to false here. $collectionData->getChanges = false; $fetches = $collectionData->getClientFetches(); if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found " . count($fetches) . " entries to be fetched from server"); } $toBeFetched = []; foreach ($fetches as $fetch) { $serverId = (string)$fetch->ServerId; $toBeFetched[$serverId] = $serverId; } $collectionData->toBeFetched = $toBeFetched; } $this->_collections[$collectionData->collectionId] = $collectionData; $this->_modifications[$collectionData->collectionId] = $clientModifications; } } /** * (non-PHPdoc) * @see Syncroton_Command_Wbxml::getResponse() */ public function getResponse() { $sync = $this->_outputDom->documentElement; $collections = $this->_outputDom->createElementNS('uri:AirSync', 'Collections'); $totalChanges = 0; // Detect devices that do not support empty Sync reponse $emptySyncSupported = !preg_match('/(meego|nokian800)/i', $this->_device->useragent); // continue only if there are changes or no time is left if ($this->_heartbeatInterval > 0) { $intervalStart = time(); $sleepCallback = Syncroton_Registry::getSleepCallback(); $wakeupCallback = Syncroton_Registry::getWakeupCallback(); do { // take a break to save battery lifetime $sleepCallback(); sleep(Syncroton_Registry::getPingTimeout()); // make sure the connection is still alive, abort otherwise if (connection_aborted()) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Exiting on aborted connection"); } exit; } $wakeupCallback(); $now = new DateTime('now', new DateTimeZone('UTC')); foreach($this->_collections as $collectionData) { // continue immediately if folder does not exist if (! ($collectionData->folder instanceof Syncroton_Model_IFolder)) { break 2; // countinue immediately if syncstate is invalid } elseif (! ($collectionData->syncState instanceof Syncroton_Model_ISyncState)) { break 2; } else { if ($collectionData->getChanges !== true) { continue; } try { // just check if the folder still exists $this->_folderBackend->get($collectionData->folder); } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " collection does not exist anymore: " . $collectionData->collectionId); } $collectionData->getChanges = false; // make sure this is the last while loop // no break 2 here, as we like to check the other folders too $intervalStart -= $this->_heartbeatInterval; } // check that the syncstate still exists and is still valid try { $syncState = $this->_syncStateBackend->getSyncState($this->_device, $collectionData->folder); // another process synchronized data of this folder already. let's skip it if ($syncState->id !== $collectionData->syncState->id) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " syncstate changed during heartbeat interval for collection: " . $collectionData->folder->serverId); } $collectionData->getChanges = false; // make sure this is the last while loop // no break 2 here, as we like to check the other folders too $intervalStart -= $this->_heartbeatInterval; } } catch (Syncroton_Exception_NotFound $senf) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " no syncstate found anymore for collection: " . $collectionData->folder->serverId); } $collectionData->syncState = null; // make sure this is the last while loop // no break 2 here, as we like to check the other folders too $intervalStart -= $this->_heartbeatInterval; } // safe battery time by skipping folders which got synchronied less than Syncroton_Command_Ping::$quietTime seconds ago if (! $collectionData->syncState instanceof Syncroton_Model_SyncState || ($now->getTimestamp() - $collectionData->syncState->lastsync->getTimestamp()) < Syncroton_Registry::getQuietTime()) { continue; } $dataController = Syncroton_Data_Factory::factory($collectionData->folder->class, $this->_device, $this->_syncTimeStamp); // countinue immediately if there are any changes available if($dataController->hasChanges($this->_contentStateBackend, $collectionData->folder, $collectionData->syncState)) { break 2; } } } // See: http://www.tine20.org/forum/viewtopic.php?f=12&t=12146 // // break if there are less than PingTimeout + 10 seconds left for the next loop // otherwise the response will be returned after the client has finished his Ping // request already maybe } while (Syncroton_Server::validateSession() && time() - $intervalStart < $this->_heartbeatInterval - (Syncroton_Registry::getPingTimeout() + 10)); } // First check for folders hierarchy changes foreach ($this->_collections as $collectionData) { if (! ($collectionData->folder instanceof Syncroton_Model_IFolder)) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " Detected a folder hierarchy change on {$collectionData->collectionId}."); } $sync->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_FOLDER_HIERARCHY_HAS_CHANGED)); return $this->_outputDom; } } foreach ($this->_collections as $collectionData) { $collectionChanges = 0; /** * keep track of entries added on server side */ $newContentStates = []; /** * keep track of entries deleted on server side */ $deletedContentStates = []; // invalid synckey provided if (! ($collectionData->syncState instanceof Syncroton_Model_ISyncState)) { // set synckey to 0 $collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection')); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', 0)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_INVALID_SYNC_KEY)); // initial sync } elseif ($collectionData->syncState->counter === 0) { $collectionData->syncState->counter++; // initial sync // send back a new SyncKey only $collection = $collections->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Collection')); if (!empty($collectionData->folder->class)) { $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Class', $collectionData->folder->class)); } $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey', $collectionData->syncState->counter)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_SUCCESS)); } else { $dataController = Syncroton_Data_Factory::factory($collectionData->folder->class, $this->_device, $this->_syncTimeStamp); $clientModifications = $this->_modifications[$collectionData->collectionId]; $serverModifications = [ 'added' => [], 'changed' => [], 'deleted' => [], ]; $status = self::STATUS_SUCCESS; $hasChanges = 0; if ($collectionData->getChanges === true) { // continue sync session? if(is_array($collectionData->syncState->pendingdata)) { $serverModifications = $collectionData->syncState->pendingdata; if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " restored from sync state: (added/changed/deleted) " . count($serverModifications['added']) . '/' . count($serverModifications['changed']) . '/' . count($serverModifications['deleted']) . ' entries for sync from server to client'); } } else { try { $hasChanges = $dataController->hasChanges($this->_contentStateBackend, $collectionData->folder, $collectionData->syncState); } catch (Syncroton_Exception_NotFound $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Folder changes checking failed (not found): " . $e->getTraceAsString()); } $status = self::STATUS_FOLDER_HIERARCHY_HAS_CHANGED; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " Folder changes checking failed: " . $e->getMessage()); } // Prevent from removing client entries when getServerEntries() fails // @todo: should we break the loop here? $status = self::STATUS_SERVER_ERROR; } } if ($hasChanges) { // update _syncTimeStamp as $dataController->hasChanges might have spent some time $this->_syncTimeStamp = new DateTime('now', new DateTimeZone('UTC')); try { // fetch entries added since last sync $allClientEntries = $this->_contentStateBackend->getFolderState( $this->_device, $collectionData->folder, $collectionData->syncState->counter ); // fetch entries changed since last sync $allChangedEntries = $dataController->getChangedEntries( $collectionData->collectionId, $collectionData->syncState, $collectionData->options['filterType'] ); // fetch all entries $allServerEntries = $dataController->getServerEntries( $collectionData->collectionId, $collectionData->options['filterType'] ); // add entries $serverDiff = array_diff($allServerEntries, $allClientEntries); // add entries which produced problems during delete from client $serverModifications['added'] = $clientModifications['forceAdd']; // add entries not yet sent to client $serverModifications['added'] = array_unique(array_merge($serverModifications['added'], $serverDiff)); // @todo still needed? foreach($serverModifications['added'] as $id => $serverId) { // skip entries added by client during this sync session if(isset($clientModifications['added'][$serverId]) && !isset($clientModifications['forceAdd'][$serverId])) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped added entry: " . $serverId); } unset($serverModifications['added'][$id]); } } // entries to be deleted $serverModifications['deleted'] = array_diff($allClientEntries, $allServerEntries); // entries changed since last sync $serverModifications['changed'] = array_merge($allChangedEntries, $clientModifications['forceChange']); foreach($serverModifications['changed'] as $id => $serverId) { // skip entry, if it got changed by client during current sync if(isset($clientModifications['changed'][$serverId]) && !isset($clientModifications['forceChange'][$serverId])) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped changed entry: " . $serverId); } unset($serverModifications['changed'][$id]); } // skip entry, make sure we don't sent entries already added by client in this request elseif (isset($clientModifications['added'][$serverId]) && !isset($clientModifications['forceAdd'][$serverId])) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " skipped change for added entry: " . $serverId); } unset($serverModifications['changed'][$id]); } } // entries comeing in scope are already in $serverModifications['added'] and do not need to // be send with $serverCanges $serverModifications['changed'] = array_diff($serverModifications['changed'], $serverModifications['added']); } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->crit(__METHOD__ . '::' . __LINE__ . " Folder state checking failed: " . $e->getMessage()); } if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " Folder state checking failed: " . $e->getTraceAsString()); } // Prevent from removing client entries when getServerEntries() fails // @todo: should we break the loop here? $status = self::STATUS_SERVER_ERROR; } if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " found (added/changed/deleted) " . count($serverModifications['added']) . '/' . count($serverModifications['changed']) . '/' . count($serverModifications['deleted']) . ' entries for sync from server to client'); } } } // collection header $collection = $this->_outputDom->createElementNS('uri:AirSync', 'Collection'); if (!empty($collectionData->folder->class)) { $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Class', $collectionData->folder->class)); } $syncKeyElement = $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'SyncKey')); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'CollectionId', $collectionData->collectionId)); $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $status)); $responses = $this->_outputDom->createElementNS('uri:AirSync', 'Responses'); if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " Processing collection " . $collectionData->collectionId); } // send reponse for newly added entries if(!empty($clientModifications['added'])) { foreach($clientModifications['added'] as $entryData) { $add = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Add')); $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ClientId', $entryData['clientId'])); // we have no serverId if the add failed if(isset($entryData['serverId'])) { $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $entryData['serverId'])); } $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $entryData['status'])); } } // send reponse for changed entries if(!empty($clientModifications['changed'])) { foreach($clientModifications['changed'] as $serverId => $status) { if ($status !== Syncroton_Command_Sync::STATUS_SUCCESS) { $change = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Change')); $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', $status)); } } } // send response for to be fetched entries if(!empty($collectionData->toBeFetched)) { // unset all truncation settings as entries are not allowed to be truncated during fetch $fetchCollectionData = clone $collectionData; // unset truncationSize if (isset($fetchCollectionData->options['bodyPreferences']) && is_array($fetchCollectionData->options['bodyPreferences'])) { foreach($fetchCollectionData->options['bodyPreferences'] as $key => $bodyPreference) { unset($fetchCollectionData->options['bodyPreferences'][$key]['truncationSize']); } } $fetchCollectionData->options['mimeTruncation'] = Syncroton_Command_Sync::TRUNCATE_NOTHING; foreach($collectionData->toBeFetched as $serverId) { $fetch = $responses->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Fetch')); $fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); try { $applicationData = $this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData'); $dataController ->getEntry($fetchCollectionData, $serverId) ->appendXML($applicationData, $this->_device); $fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_SUCCESS)); $fetch->appendChild($applicationData); } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); } if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getTraceAsString()); } $fetch->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'Status', self::STATUS_OBJECT_NOT_FOUND)); } } } if ($responses->hasChildNodes() === true) { $collection->appendChild($responses); } $commands = $this->_outputDom->createElementNS('uri:AirSync', 'Commands'); foreach($serverModifications['added'] as $id => $serverId) { if($collectionChanges == $collectionData->windowSize || $totalChanges + $collectionChanges >= $this->_globalWindowSize) { break; } try { $add = $this->_outputDom->createElementNS('uri:AirSync', 'Add'); $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $applicationData = $add->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData')); $dataController ->getEntry($collectionData, $serverId) ->appendXML($applicationData, $this->_device); $commands->appendChild($add); $newContentStates[] = new Syncroton_Model_Content([ 'device_id' => $this->_device, 'folder_id' => $collectionData->folder, 'contentid' => $serverId, 'creation_time' => $this->_syncTimeStamp, 'creation_synckey' => $collectionData->syncState->counter + 1, ]); $collectionChanges++; } catch (Syncroton_Exception_MemoryExhausted $seme) { // continue to next entry, as there is not enough memory left for the current entry // this will lead to MoreAvailable at the end and the entry will be synced during the next Sync command if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " memory exhausted for entry: " . $serverId); } break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); } if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getTraceAsString()); } // We bump collectionChanges anyways to make sure the windowSize still applies. $collectionChanges++; } // mark as sent to the client, even the conversion to xml might have failed unset($serverModifications['added'][$id]); } /** * process entries changed on server side */ foreach($serverModifications['changed'] as $id => $serverId) { if($collectionChanges == $collectionData->windowSize || $totalChanges + $collectionChanges >= $this->_globalWindowSize) { break; } try { $change = $this->_outputDom->createElementNS('uri:AirSync', 'Change'); $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $applicationData = $change->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ApplicationData')); + $collectionData->options['_changesOnly'] = true; + $dataController ->getEntry($collectionData, $serverId) ->appendXML($applicationData, $this->_device); $commands->appendChild($change); $collectionChanges++; } catch (Syncroton_Exception_MemoryExhausted $seme) { // continue to next entry, as there is not enough memory left for the current entry // this will lead to MoreAvailable at the end and the entry will be synced during the next Sync command if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " memory exhausted for entry: " . $serverId); } break; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); } // We bump collectionChanges anyways to make sure the windowSize still applies. $collectionChanges++; } unset($serverModifications['changed'][$id]); } foreach($serverModifications['deleted'] as $id => $serverId) { if($collectionChanges == $collectionData->windowSize || $totalChanges + $collectionChanges >= $this->_globalWindowSize) { break; } try { // check if we have sent this entry to the phone $state = $this->_contentStateBackend->getContentState($this->_device, $collectionData->folder, $serverId); $delete = $this->_outputDom->createElementNS('uri:AirSync', 'Delete'); $delete->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'ServerId', $serverId)); $deletedContentStates[] = $state; $commands->appendChild($delete); $collectionChanges++; } catch (Exception $e) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . " unable to convert entry to xml: " . $e->getMessage()); } // We bump collectionChanges anyways to make sure the windowSize still applies. $collectionChanges++; } unset($serverModifications['deleted'][$id]); } $countOfPendingChanges = (count($serverModifications['added']) + count($serverModifications['changed']) + count($serverModifications['deleted'])); if ($countOfPendingChanges > 0) { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " there are " . $countOfPendingChanges . " more items available"); } $collection->appendChild($this->_outputDom->createElementNS('uri:AirSync', 'MoreAvailable')); } else { if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " there are no more items available"); } $serverModifications = null; } if ($commands->hasChildNodes() === true) { $collection->appendChild($commands); } $totalChanges += $collectionChanges; // If the client resent an old sync-key, we should still respond with the latest sync key if (isset($collectionData->syncState->counterNext)) { //TODO we're not resending the changes in between, but I'm not sure we have to. $collectionData->syncState->counter = $collectionData->syncState->counterNext; } // increase SyncKey if needed if (( // sent the clients updates... ? !empty($clientModifications['added']) || !empty($clientModifications['changed']) || !empty($clientModifications['deleted']) ) || ( // is the server sending updates to the client... ? $commands->hasChildNodes() === true ) || ( // changed the pending data... ? $collectionData->syncState->pendingdata != $serverModifications ) ) { // ...then increase SyncKey $collectionData->syncState->counter++; } $syncKeyElement->appendChild($this->_outputDom->createTextNode($collectionData->syncState->counter)); if ($this->_logger instanceof Zend_Log) { $this->_logger->info(__METHOD__ . '::' . __LINE__ . " current synckey is " . $collectionData->syncState->counter); } if (!$emptySyncSupported || $collection->childNodes->length > 4 || $collectionData->syncState->counter != $collectionData->syncKey) { $collections->appendChild($collection); } //Store next $collectionData->syncState->extraData = $dataController->getExtraData($collectionData->folder); } if (isset($collectionData->syncState) && $collectionData->syncState instanceof Syncroton_Model_ISyncState && $collectionData->syncState->counter != $collectionData->syncKey ) { if ($this->_logger instanceof Zend_Log) { $this->_logger->debug(__METHOD__ . '::' . __LINE__ . " update syncState for collection: " . $collectionData->collectionId); } // store pending data in sync state when needed if (isset($countOfPendingChanges) && $countOfPendingChanges > 0) { $collectionData->syncState->pendingdata = [ 'added' => $serverModifications['added'] ?? [], 'changed' => $serverModifications['changed'] ?? [], 'deleted' => $serverModifications['deleted'] ?? [], ]; } else { $collectionData->syncState->pendingdata = null; } $collectionData->syncState->lastsync = clone $this->_syncTimeStamp; // increment sync timestamp by 1 second $collectionData->syncState->lastsync->modify('+1 sec'); if (!empty($clientModifications['added'])) { // Store a client id mapping in case we encounter a reused sync_key in a future request. $newClientIdMap = []; foreach($clientModifications['added'] as $entryData) { // No serverId if we failed to add if ($entryData['status'] == self::STATUS_SUCCESS) { $newClientIdMap[$entryData['clientId']] = $entryData['serverId']; } } $collectionData->syncState->clientIdMap = Zend_Json::encode($newClientIdMap); } //Retry in case of deadlock $retryCounter = 0; while (true) { try { $transactionId = Syncroton_Registry::getTransactionManager()->startTransaction(Syncroton_Registry::getDatabase()); // store new synckey $this->_syncStateBackend->create($collectionData->syncState, true); // @phpstan-ignore-line // store contentstates for new entries added to client foreach($newContentStates as $state) { try { //This can happen if we rerun a previous sync-key $state = $this->_contentStateBackend->getContentState($state->device_id, $state->folder_id, $state->contentid); $this->_contentStateBackend->update($state); } catch(Exception $zdse) { $this->_contentStateBackend->create($state); } } // remove contentstates for entries to be deleted on client foreach($deletedContentStates as $state) { $this->_contentStateBackend->delete($state); } Syncroton_Registry::getTransactionManager()->commitTransaction($transactionId); break; } catch (Syncroton_Exception_DeadlockDetected $zdse) { $retryCounter++; if ($retryCounter > 60) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' exception while storing new synckey. Aborting after 5 retries.'); } // something went wrong // maybe another parallel request added a new synckey // we must remove data added from client if (!empty($clientModifications['added']) && isset($dataController)) { foreach ($clientModifications['added'] as $added) { $this->_contentStateBackend->delete($added['contentState']); $dataController->deleteEntry($collectionData->collectionId, $added['serverId']); } } Syncroton_Registry::getTransactionManager()->rollBack(); throw $zdse; } Syncroton_Registry::getTransactionManager()->rollBack(); // Give the other transactions some time before we try again sleep(1); if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' error during transaction, trying again.'); } } catch (Exception $zdse) { if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' exception while storing new synckey.'); } // something went wrong // maybe another parallel request added a new synckey // we must remove data added from client if (!empty($clientModifications['added']) && isset($dataController)) { foreach ($clientModifications['added'] as $added) { $this->_contentStateBackend->delete($added['contentState']); $dataController->deleteEntry($collectionData->collectionId, $added['serverId']); } } Syncroton_Registry::getTransactionManager()->rollBack(); throw $zdse; } } } // store current filter type try { /** @var Syncroton_Model_Folder $folderState */ $folderState = $this->_folderBackend->get($collectionData->folder); $folderState->lastfiltertype = $collectionData->options['filterType']; if ($folderState->isDirty()) { $this->_folderBackend->update($folderState); } } catch (Syncroton_Exception_NotFound $senf) { // failed to get folderstate => should not happen but is also no problem in this state if ($this->_logger instanceof Zend_Log) { $this->_logger->warn(__METHOD__ . '::' . __LINE__ . ' failed to get folder state for: ' . $collectionData->collectionId); } } } if ($collections->hasChildNodes() === true) { $sync->appendChild($collections); } if ($sync->hasChildNodes()) { return $this->_outputDom; } return null; } /** * remove Commands and Supported from collections XML tree * * @param DOMDocument $document * @return DOMDocument */ protected function _cleanUpXML(DOMDocument $document) { $cleanedDocument = clone $document; $xpath = new DomXPath($cleanedDocument); $xpath->registerNamespace('AirSync', 'uri:AirSync'); $collections = $xpath->query("//AirSync:Sync/AirSync:Collections/AirSync:Collection"); // remove Commands and Supported elements foreach ($collections as $collection) { foreach (['Commands', 'Supported'] as $element) { /** @var DOMElement $collection */ $childrenToRemove = $collection->getElementsByTagName($element); foreach ($childrenToRemove as $childToRemove) { $collection->removeChild($childToRemove); } } } return $cleanedDocument; } /** * merge a partial XML document with the XML document from the previous request * * @param DOMDocument|null $requestBody * @return DOMDocument */ protected function _mergeSyncRequest($requestBody, Syncroton_Model_Device $device) { $lastSyncCollection = []; if (!empty($device->lastsynccollection)) { $lastSyncCollection = Zend_Json::decode($device->lastsynccollection); if (!empty($lastSyncCollection['lastXML'])) { $lastXML = new DOMDocument(); $lastXML->loadXML($lastSyncCollection['lastXML']); } } if (! $requestBody instanceof DOMDocument) { if (!empty($lastXML)) { $requestBody = $lastXML; } else { throw new Syncroton_Exception_UnexpectedValue('no xml body found'); } } if ($requestBody->getElementsByTagName('Partial')->length > 0) { $partialBody = clone $requestBody; $requestBody = $lastXML ?? (new DOMDocument()); $xpath = new DomXPath($requestBody); $xpath->registerNamespace('AirSync', 'uri:AirSync'); foreach ($partialBody->documentElement->childNodes as $child) { if (! $child instanceof DOMElement) { continue; } /** @var DOMElement $child */ if ($child->tagName == 'Partial') { continue; } if ($child->tagName == 'Collections') { foreach ($child->getElementsByTagName('Collection') as $updatedCollection) { $collectionId = $updatedCollection->getElementsByTagName('CollectionId')->item(0)->nodeValue; $existingCollections = $xpath->query("//AirSync:Sync/AirSync:Collections/AirSync:Collection[AirSync:CollectionId='$collectionId']"); if ($existingCollections->length > 0) { /** @var DOMElement $existingCollection */ $existingCollection = $existingCollections->item(0); foreach ($updatedCollection->childNodes as $updatedCollectionChild) { if (! $updatedCollectionChild instanceof DOMElement) { continue; } $duplicateChild = $existingCollection->getElementsByTagName($updatedCollectionChild->tagName); if ($duplicateChild->length > 0) { $existingCollection->replaceChild($requestBody->importNode($updatedCollectionChild, true), $duplicateChild->item(0)); } else { $existingCollection->appendChild($requestBody->importNode($updatedCollectionChild, true)); } } } else { $importedCollection = $requestBody->importNode($updatedCollection, true); } } } else { $duplicateChild = $xpath->query("//AirSync:Sync/AirSync:{$child->tagName}"); if ($duplicateChild->length > 0) { $requestBody->documentElement->replaceChild($requestBody->importNode($child, true), $duplicateChild->item(0)); } else { $requestBody->documentElement->appendChild($requestBody->importNode($child, true)); } } } } $lastSyncCollection['lastXML'] = $this->_cleanUpXML($requestBody)->saveXML(); $device->lastsynccollection = Zend_Json::encode($lastSyncCollection); return $requestBody; } } diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php index c9fc819..5308d5e 100644 --- a/lib/kolab_sync_data_email.php +++ b/lib/kolab_sync_data_email.php @@ -1,1566 +1,1571 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Email data class for Syncroton */ class kolab_sync_data_email extends kolab_sync_data implements Syncroton_Data_IDataSearch { public const MAX_SEARCH_RESULT = 200; /** * Mapping from ActiveSync Email namespace fields */ protected $mapping = [ 'cc' => 'cc', //'contentClass' => 'contentclass', 'dateReceived' => 'internaldate', //'displayTo' => 'displayto', //? //'flag' => 'flag', 'from' => 'from', //'importance' => 'importance', 'internetCPID' => 'charset', //'messageClass' => 'messageclass', 'replyTo' => 'replyto', //'read' => 'read', 'subject' => 'subject', //'threadTopic' => 'threadtopic', 'to' => 'to', ]; public static $memory_accumulated = 0; /** * Special folder type/name map * * @var array */ protected $folder_types = [ 2 => 'Inbox', 3 => 'Drafts', 4 => 'Deleted Items', 5 => 'Sent Items', 6 => 'Outbox', ]; /** * Kolab object type * * @var string */ protected $modelName = 'mail'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_INBOX; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'INBOX'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED; protected $storage; /** * the constructor * * @param Syncroton_Model_IDevice $device * @param DateTime $syncTimeStamp */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { parent::__construct($device, $syncTimeStamp); $this->storage = rcube::get_instance()->get_storage(); // Outlook 2013 support multi-folder $this->ext_devices[] = 'windowsoutlook15'; } /** * Encode a globalObjId according to https://interoperability.blob.core.windows.net/files/MS-ASEMAIL/%5bMS-ASEMAIL%5d-150526.pdf 2.2.2.3 * * @param array $data An array with the data to encode * * @return string the encoded globalObjId */ public static function encodeGlobalObjId(array $data): string { $classid = "040000008200e00074c5b7101a82e008"; if (!empty($data['data'])) { $payload = $data['data']; } else { $uid = $data['uid']; $payload = "vCal-Uid\1\0\0\0{$uid}\0"; } $packed = pack( "H32nCCPx8Va*", $classid, $data['year'] ?? 0, $data['month'] ?? 0, $data['day'] ?? 0, $data['now'] ?? 0, strlen($payload), $payload ); return base64_encode($packed); } /** * Decode a globalObjId according to https://interoperability.blob.core.windows.net/files/MS-ASEMAIL/%5bMS-ASEMAIL%5d-150526.pdf 2.2.2.3 * * @param string $globalObjId The encoded globalObjId * * @return array An array with the decoded data */ public static function decodeGlobalObjId(string $globalObjId): array { $unpackString = 'H32classid/nyear/Cmonth/Cday/Pnow/x8/Vbytecount/a*data'; $decoded = unpack($unpackString, base64_decode($globalObjId)); $decoded['uid'] = substr($decoded['data'], strlen("vCal-Uid\1\0\0\0"), -1); return $decoded; } /** * Creates model object * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * * @return Syncroton_Model_Email Email object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { $message = $this->getObject($serverId); // error (message doesn't exist?) if (empty($message)) { throw new Syncroton_Exception_NotFound("Message $serverId not found"); } $headers = $message->headers; // rcube_message_header $this->storage->set_folder($message->folder); $this->logger->debug(sprintf("Processing message %s (size: %.2f MB)", $serverId, $headers->size / 1024 / 1024)); // Calendar namespace fields foreach ($this->mapping as $key => $name) { $value = null; switch ($name) { case 'internaldate': $value = self::date_from_kolab(rcube_utils::strtotime($headers->internaldate)); break; case 'cc': case 'to': case 'replyto': case 'from': $addresses = rcube_mime::decode_address_list($headers->$name, null, true, $headers->charset); foreach ($addresses as $idx => $part) { // @FIXME: set name + address or address only? $addresses[$idx] = format_email_recipient($part['mailto'], $part['name']); } $value = implode(',', $addresses); break; case 'subject': $value = $headers->get('subject'); break; case 'charset': $value = self::charset_to_cp($headers->charset); break; } if (empty($value) || is_array($value)) { continue; } if (is_string($value)) { $value = rcube_charset::clean($value); } $result[$key] = $value; } // $result['ConversationId'] = 'FF68022058BD485996BE15F6F6D99320'; // $result['ConversationIndex'] = 'CA2CFA8A23'; // Read flag $result['read'] = intval(!empty($headers->flags['SEEN'])); // Flagged message if (!empty($headers->flags['FLAGGED'])) { // Use FollowUp flag which is used in Android when message is marked with a star $result['flag'] = new Syncroton_Model_EmailFlag([ 'flagType' => 'FollowUp', 'status' => Syncroton_Model_EmailFlag::STATUS_ACTIVE, ]); } else { $result['flag'] = new Syncroton_Model_EmailFlag(); } // Importance/Priority if ($headers->priority) { if ($headers->priority < 3) { $result['importance'] = 2; // High } elseif ($headers->priority > 3) { $result['importance'] = 0; // Low } } + // Categories (Tags) + $result['categories'] = $headers->others['categories'] ?? []; + + //The body never changes, so make sure we don't re-send it to the client on a change + if ($collection->options['_changesOnly'] ?? false) { + return new Syncroton_Model_Email($result); + } + // get truncation and body type $airSyncBaseType = Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT; $truncateAt = null; $opts = $collection->options; $prefs = $opts['bodyPreferences']; if ($opts['mimeSupport'] == Syncroton_Command_Sync::MIMESUPPORT_SEND_MIME) { $airSyncBaseType = Syncroton_Command_Sync::BODY_TYPE_MIME; if (isset($prefs[Syncroton_Command_Sync::BODY_TYPE_MIME]['truncationSize'])) { $truncateAt = $prefs[Syncroton_Command_Sync::BODY_TYPE_MIME]['truncationSize']; } elseif (isset($opts['mimeTruncation']) && $opts['mimeTruncation'] < Syncroton_Command_Sync::TRUNCATE_NOTHING) { switch ($opts['mimeTruncation']) { case Syncroton_Command_Sync::TRUNCATE_ALL: $truncateAt = 0; break; case Syncroton_Command_Sync::TRUNCATE_4096: $truncateAt = 4096; break; case Syncroton_Command_Sync::TRUNCATE_5120: $truncateAt = 5120; break; case Syncroton_Command_Sync::TRUNCATE_7168: $truncateAt = 7168; break; case Syncroton_Command_Sync::TRUNCATE_10240: $truncateAt = 10240; break; case Syncroton_Command_Sync::TRUNCATE_20480: $truncateAt = 20480; break; case Syncroton_Command_Sync::TRUNCATE_51200: $truncateAt = 51200; break; case Syncroton_Command_Sync::TRUNCATE_102400: $truncateAt = 102400; break; } } } else { // The spec is not very clear, but it looks that if MimeSupport is not set // we can't add Syncroton_Command_Sync::BODY_TYPE_MIME to the supported types // list below (Bug #1688) $types = [ Syncroton_Command_Sync::BODY_TYPE_HTML, Syncroton_Command_Sync::BODY_TYPE_PLAIN_TEXT, ]; // @TODO: if client can support both HTML and TEXT use one of // them which is better according to the real message body type foreach ($types as $type) { if (!empty($prefs[$type])) { if (!empty($prefs[$type]['truncationSize'])) { $truncateAt = $prefs[$type]['truncationSize']; } $preview = (int) ($prefs[$type]['preview'] ?? 0); $airSyncBaseType = $type; break; } } } $body_params = ['type' => $airSyncBaseType]; // Message body // In Sync examples there's one in which bodyPreferences is not defined // in such case Truncated=1 and there's no body sent to the client // only it's estimated size $isTruncated = 0; if (empty($prefs)) { $messageBody = ''; $real_length = $headers->size; $truncateAt = 0; $body_length = 0; $isTruncated = 1; } elseif ($airSyncBaseType == Syncroton_Command_Sync::BODY_TYPE_MIME) { // Check if we have enough memory to handle the message $messageBody = $this->message_mem_check($message, $headers->size); static::$memory_accumulated += $headers->size; if (empty($messageBody)) { $messageBody = $this->storage->get_raw_body($message->uid); } // make the source safe (Bug #2715, #2757) $messageBody = kolab_sync_message::recode_message($messageBody); // strip out any non utf-8 characters $messageBody = rcube_charset::clean($messageBody); $real_length = $body_length = strlen($messageBody); } else { $messageBody = $this->getMessageBody($message, $airSyncBaseType == Syncroton_Command_Sync::BODY_TYPE_HTML); // strip out any non utf-8 characters $messageBody = rcube_charset::clean($messageBody); $real_length = $body_length = strlen($messageBody); } // add Preview element to the Body result if (!empty($preview) && $body_length) { $body_params['preview'] = $this->getPreview($messageBody, $airSyncBaseType, $preview); } // truncate the body if needed if ($truncateAt && $body_length > $truncateAt) { $messageBody = mb_strcut($messageBody, 0, $truncateAt); $body_length = strlen($messageBody); $isTruncated = 1; } if ($isTruncated) { $body_params['truncated'] = 1; $body_params['estimatedDataSize'] = $real_length; } // add Body element to the result $result['body'] = $this->setBody($messageBody, $body_params); // original body type // @TODO: get this value from getMessageBody() $result['nativeBodyType'] = $message->has_html_part() ? 2 : 1; // Message class $result['messageClass'] = 'IPM.Note'; $result['contentClass'] = 'urn:content-classes:message'; if ($headers->ctype == 'multipart/signed' && !empty($message->parts[1]) && $message->parts[1]->mimetype == 'application/pkcs7-signature' ) { $result['messageClass'] = 'IPM.Note.SMIME.MultipartSigned'; } elseif ($headers->ctype == 'application/pkcs7-mime' || $headers->ctype == 'application/x-pkcs7-mime') { $result['messageClass'] = 'IPM.Note.SMIME'; } elseif ($event = $this->get_invitation_event_from_message($message)) { // Note: Depending on MessageClass a client will display a proper set of buttons // Either Accept/Maybe/Decline (REQUEST), or "Remove from Calendar" (CANCEL) or none (REPLY). $result['messageClass'] = 'IPM.Schedule.Meeting.Request'; $result['contentClass'] = 'urn:content-classes:calendarmessage'; $meeting = []; $meeting['allDayEvent'] = $event['allday'] ?? null ? 1 : 0; $meeting['startTime'] = self::date_from_kolab($event['start']); $meeting['dtStamp'] = self::date_from_kolab($event['dtstamp'] ?? null); $meeting['endTime'] = self::date_from_kolab($event['end'] ?? null); $meeting['location'] = $event['location'] ?? null; $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_NORMAL; if (!empty($event['recurrence_date'])) { $meeting['recurrenceId'] = self::date_from_kolab($event['recurrence_date']); if (!empty($event['status']) && $event['status'] == 'CANCELLED') { $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_EXCEPTION; } else { $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_SINGLE; } } elseif (!empty($event['recurrence'])) { $meeting['instanceType'] = Syncroton_Model_EmailMeetingRequest::TYPE_RECURRING_MASTER; // TODO: MeetingRequest recurrence is different that the one in Calendar // $this->recurrence_from_kolab($collection, $event, $meeting); } // Organizer if (!empty($event['attendees'])) { foreach ($event['attendees'] as $attendee) { if (!empty($attendee['role']) && $attendee['role'] == 'ORGANIZER' && !empty($attendee['email'])) { $meeting['organizer'] = $attendee['email']; break; } } } // Current time as a number of 100-nanosecond units since 1601-01-01 $fileTime = ($event['start']->getTimestamp() + 11644473600) * 10000000; // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows // only one timezone per-event. We'll use timezone of the start date $meeting['timeZone'] = kolab_sync_timezone_converter::encodeTimezoneFromDate($event['start']); $meeting['globalObjId'] = self::encodeGlobalObjId([ 'uid' => $event['uid'], 'year' => intval($event['start']->format('Y')), 'month' => intval($event['start']->format('n')), 'day' => intval($event['start']->format('j')), 'now' => $fileTime, ]); if ($event['_method'] == 'REQUEST') { $meeting['meetingMessageType'] = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_REQUEST; // Some clients (iOS) without this flag do not send the invitation reply to the organizer. // Note: Microsoft says "the value of the ResponseRequested element comes from the PARTSTAT // parameter value of "NEEDS-ACTION" in the request". I think it is safe to do this for all requests. // Note: This does not have impact on the existence of Accept/Decline buttons in the client. $meeting['responseRequested'] = 1; } else { // REPLY or CANCEL $meeting['meetingMessageType'] = Syncroton_Model_EmailMeetingRequest::MESSAGE_TYPE_NORMAL; $itip_processing = kolab_sync::get_instance()->config->get('activesync_itip_processing'); $attendeeStatus = null; if ($itip_processing && empty($headers->flags['SEEN'])) { // Optionally process the message and update the event in recipient's calendar // Warning: Only for development purposes, for now it's better to use wallace $calendar_class = new kolab_sync_data_calendar($this->device, $this->syncTimeStamp); $attendeeStatus = $calendar_class->processItipReply($event); } elseif ($event['_method'] == 'CANCEL') { $attendeeStatus = kolab_sync_data_calendar::ITIP_CANCELLED; } elseif (!empty($event['attendees'])) { // Get the attendee/status in the REPLY foreach ($event['attendees'] as $attendee) { if (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') { if (!empty($attendee['email']) && !empty($attendee['status'])) { // Per iTip spec. there should be only one (non-organizer) attendee here // FIXME: Verify is it realy the case with e.g. Kolab webmail, If not, we should // probably use the message sender from the From: header $attendeeStatus = strtoupper($attendee['status']); break; } } } } switch ($attendeeStatus) { case kolab_sync_data_calendar::ITIP_CANCELLED: $result['messageClass'] = 'IPM.Schedule.Meeting.Canceled'; break; case kolab_sync_data_calendar::ITIP_DECLINED: $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Neg'; break; case kolab_sync_data_calendar::ITIP_TENTATIVE: $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Tent'; break; case kolab_sync_data_calendar::ITIP_ACCEPTED: $result['messageClass'] = 'IPM.Schedule.Meeting.Resp.Pos'; break; default: $skipRequest = true; } } // New time proposals aren't supported by Kolab. // This disables the UI elements related to this on the client side $meeting['disallowNewTimeProposal'] = 1; if (empty($skipRequest)) { $result['meetingRequest'] = new Syncroton_Model_EmailMeetingRequest($meeting); } } - // Categories (Tags) - $result['categories'] = $message->headers->others['categories'] ?? []; - $is_ios = preg_match('/(iphone|ipad)/i', $this->device->devicetype); // attachments $attachments = array_merge($message->attachments, $message->inline_parts); if (!empty($attachments)) { $result['attachments'] = []; foreach ($attachments as $attachment) { $att = []; if ($is_ios && !empty($event) && $attachment->mime_id == $event['_mime_id']) { continue; } $filename = rcube_charset::clean($attachment->filename); if (empty($filename) && $attachment->mimetype == 'text/html') { $filename = 'HTML Part'; } $att['displayName'] = $filename; $att['fileReference'] = $serverId . '::' . $attachment->mime_id; $att['method'] = 1; $att['estimatedDataSize'] = $attachment->size; if (!empty($attachment->content_id)) { $att['contentId'] = rcube_charset::clean($attachment->content_id); } if (!empty($attachment->content_location)) { $att['contentLocation'] = rcube_charset::clean($attachment->content_location); } if (in_array($attachment, $message->inline_parts)) { $att['isInline'] = 1; } $result['attachments'][] = new Syncroton_Model_EmailAttachment($att); } } return new Syncroton_Model_Email($result); } /** * Returns properties of a message for Search response * * @param string $longId Message identifier * @param array $options Search options * * @return Syncroton_Model_Email Email object */ public function getSearchEntry($longId, $options) { $collection = new Syncroton_Model_SyncCollection([ 'options' => $options, ]); return $this->getEntry($collection, $longId); } /** * convert email from xml to libkolab array * * @param Syncroton_Model_Email $data Email to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * * @return array */ public function toKolab($data, $folderid, $entry = null) { // does nothing => you can't add emails via ActiveSync return []; } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @return array Filter query */ protected function filter($filter_type = 0) { $filter = []; switch ($filter_type) { case Syncroton_Command_Sync::FILTER_1_DAY_BACK: $mod = '-1 day'; break; case Syncroton_Command_Sync::FILTER_3_DAYS_BACK: $mod = '-3 days'; break; case Syncroton_Command_Sync::FILTER_1_WEEK_BACK: $mod = '-1 week'; break; case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); // RFC3501: IMAP SEARCH $filter[] = 'SINCE ' . $dt->format('d-M-Y'); } return $filter; } /** * Return list of supported folders for this backend * * @return array */ public function getAllFolders() { $list = $this->listFolders(); if (!is_array($list)) { throw new Syncroton_Exception_Status_FolderSync(Syncroton_Exception_Status_FolderSync::FOLDER_SERVER_ERROR); } // device doesn't support multiple folders if (!$this->isMultiFolder()) { // We'll return max. one folder of supported type $result = []; $types = $this->folder_types; foreach ($list as $idx => $folder) { $type = $folder['type'] == 12 ? 2 : $folder['type']; // unknown to Inbox if ($folder_id = $types[$type]) { $result[$folder_id] = [ 'displayName' => $folder_id, 'serverId' => $folder_id, 'parentId' => 0, 'type' => $type, ]; } } $list = $result; } foreach ($list as $idx => $folder) { $list[$idx] = new Syncroton_Model_Folder($folder); } return $list; } /** * Return list of folders for specified folder ID * * @param string $folder_id Folder identifier * * @return array Folder identifiers list */ protected function extractFolders($folder_id) { $list = $this->listFolders(); $result = []; if (!is_array($list)) { throw new Syncroton_Exception_NotFound('Folder not found'); } // device supports multiple folders? if ($this->isMultiFolder()) { if ($list[$folder_id]) { $result[] = $folder_id; } } elseif ($type = array_search($folder_id, $this->folder_types)) { foreach ($list as $id => $folder) { if ($folder['type'] == $type || ($folder_id == 'Inbox' && $folder['type'] == 12)) { $result[] = $id; } } } if (empty($result)) { throw new Syncroton_Exception_NotFound('Folder not found'); } return $result; } /** * Moves object into another location (folder) * * @param string $srcFolderId Source folder identifier * @param string $serverId Object identifier * @param string $dstFolderId Destination folder identifier * * @throws Syncroton_Exception_Status * @return string New object identifier */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { $msg = $this->parseMessageId($serverId); $dest = $this->extractFolders($dstFolderId); $dest_id = array_shift($dest); if (empty($msg)) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } $uid = $this->backend->moveItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], $dest_id); return $uid ? $this->serverId($uid, $dest_id) : null; } /** * add entry from xml data * * @param string $folderId Folder identifier * @param Syncroton_Model_Email $entry Entry * * @return string ID of the created entry */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { $params = ['flags' => [!empty($entry->read) ? 'SEEN' : 'UNSEEN']]; $uid = $this->backend->createItem($folderId, $this->device->deviceid, $this->modelName, $entry->body->data, $params); if (!$uid) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } return $this->serverId($uid, $folderId); } /** * Update existing message * * @param string $folderId Folder identifier * @param string $serverId Entry identifier * @param Syncroton_Model_IEntry $entry Entry */ public function updateEntry($folderId, $serverId, Syncroton_Model_IEntry $entry) { $msg = $this->parseMessageId($serverId); if (empty($msg)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } $params = ['flags' => []]; if (isset($entry->categories)) { $params['categories'] = $entry->categories; } // Read status change if (isset($entry->read)) { $params['flags'][] = !empty($entry->read) ? 'SEEN' : 'UNSEEN'; } // Flag change if (isset($entry->flag)) { if (empty($entry->flag) || empty($entry->flag->flagType)) { $params['flags'][] = 'UNFLAGGED'; } elseif (preg_match('/follow\s*up/i', $entry->flag->flagType)) { $params['flags'][] = 'FLAGGED'; } } $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); return $serverId; } /** * Delete an email (or move to Trash) * * @param string $folderId * @param string $serverId * @param ?Syncroton_Model_SyncCollection $collection */ public function deleteEntry($folderId, $serverId, $collection = null) { $trash = kolab_sync::get_instance()->config->get('trash_mbox'); $msg = $this->parseMessageId($serverId); if (empty($msg)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } // Note: If DeletesAsMoves is not specified in the request, its default is 1 (true). $moveToTrash = !isset($collection->deletesAsMoves) || !empty($collection->deletesAsMoves); $deleted = $this->backend->deleteItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], $moveToTrash); if (!$deleted) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } } /** * Send an email * * @param mixed $message MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * * @throws Syncroton_Exception_Status */ public function sendEmail($message, $saveInSent) { if (!($message instanceof kolab_sync_message)) { $message = new kolab_sync_message($message); } $sent = $message->send($smtp_error); if (!$sent) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::MAIL_SUBMISSION_FAILED); } // Save sent message in Sent folder if ($saveInSent) { $sent_folder = kolab_sync::get_instance()->config->get('sent_mbox'); if (strlen($sent_folder) && $this->storage->folder_exists($sent_folder)) { return $this->storage->save_message($sent_folder, $message->source(), '', false, ['SEEN']); } } } /** * Forward an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function forwardEmail($itemId, $body, $saveInSent, $replaceMime) { /* @TODO: The SmartForward command can be applied to a meeting. When SmartForward is applied to a recurring meeting, the InstanceId element (section 2.2.3.83.2) specifies the ID of a particular occurrence in the recurring meeting. If SmartForward is applied to a recurring meeting and the InstanceId element is absent, the server SHOULD forward the entire recurring meeting. If the value of the InstanceId element is invalid, the server responds with Status element (section 2.2.3.162.15) value 104, as specified in section 2.2.4. When the SmartForward command is used for an appointment, the original message is included by the server as an attachment to the outgoing message. When the SmartForward command is used for a normal message or a meeting, the behavior of the SmartForward command is the same as that of the SmartReply command (section 2.2.2.18). */ $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } // Parse message $sync_msg = new kolab_sync_message($body); // forward original message as attachment if (!$replaceMime) { $this->storage->set_folder($message->folder); $attachment = $this->storage->get_raw_body($msg['uid']); if (empty($attachment)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg->add_attachment($attachment, [ 'encoding' => '8bit', 'content_type' => 'message/rfc822', 'disposition' => 'inline', //'name' => 'message.eml', ]); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set FORWARDED flag on the replied message if (empty($message->headers->flags['FORWARDED'])) { $params = ['flags' => ['FORWARDED']]; $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); } } /** * Reply to an email * * @param array|string $itemId A string LongId or an array with following properties: * collectionId, itemId and instanceId * @param resource|string $body MIME message * @param boolean $saveInSent Enables saving the sent message in Sent folder * @param boolean $replaceMime If enabled, original message would be appended * * @throws Syncroton_Exception_Status */ public function replyEmail($itemId, $body, $saveInSent, $replaceMime) { $msg = $this->parseMessageId($itemId); $message = $this->getObject($itemId); if (empty($message)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::ITEM_NOT_FOUND); } $sync_msg = new kolab_sync_message($body); $headers = $sync_msg->headers(); // Add References header if (empty($headers['References'])) { $sync_msg->set_header('References', trim($message->headers->references . ' ' . $message->headers->messageID)); } // Get original message body if (!$replaceMime) { // @TODO: here we're assuming that reply message is in text/plain format // So, original message will be converted to plain text if needed $message_body = $this->getMessageBody($message, false); // Quote original message body $message_body = self::wrap_and_quote(trim($message_body), 72); // Join bodies $sync_msg->append("\n" . ltrim($message_body)); } // Send message $this->sendEmail($sync_msg, $saveInSent); // Set ANSWERED flag on the replied message if (empty($message->headers->flags['ANSWERED'])) { $params = ['flags' => ['ANSWERED']]; $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); } } /** * ActiveSync Search handler * * @param Syncroton_Model_StoreRequest $store Search query * * @return Syncroton_Model_StoreResponse Complete Search response */ public function search(Syncroton_Model_StoreRequest $store) { [$folders, $search_str] = $this->parse_search_query($store); if (empty($search_str)) { throw new Exception('Empty/invalid search request'); } if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $result = []; // @TODO: caching with Options->RebuildResults support foreach ($folders as $folderid) { $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); if ($foldername === null) { continue; } // $this->storage->set_folder($foldername); // $this->storage->folder_sync($foldername); $search = $this->storage->search_once($foldername, $search_str); if (!($search instanceof rcube_result_index)) { continue; } $search->revert(); $uids = $search->get(); foreach ($uids as $idx => $uid) { $uids[$idx] = new Syncroton_Model_StoreResponseResult([ 'longId' => $this->serverId($uid, $folderid), 'collectionId' => $folderid, 'class' => 'Email', ]); } $result = array_merge($result, $uids); // We don't want to search all folders if we've got already a lot messages if (count($result) >= self::MAX_SEARCH_RESULT) { break; } } $result = array_values($result); $response = new Syncroton_Model_StoreResponse(); // Calculate requested range $start = (int) $store->options['range'][0]; $limit = (int) $store->options['range'][1] + 1; $total = count($result); $response->total = $total; // Get requested chunk of data set if ($total) { if ($start > $total) { $start = $total; } if ($limit > $total) { $limit = max($start + 1, $total); } if ($start > 0 || $limit < $total) { $result = array_slice($result, $start, $limit - $start); } $response->range = [$start, $start + count($result) - 1]; } // Build result array, convert to ActiveSync format foreach ($result as $idx => $rec) { $rec->properties = $this->getSearchEntry($rec->longId, $store->options); $response->result[] = $rec; unset($result[$idx]); } return $response; } /** * Converts ActiveSync search parameters into IMAP search string */ protected function parse_search_query($store) { $options = $store->options; $query = $store->query; $search_str = ''; $folders = []; if (empty($query) || !is_array($query)) { return []; } if (!empty($query['and']['collections'])) { foreach ($query['and']['collections'] as $collection) { $folders = array_merge($folders, $this->extractFolders($collection)); } } if (!empty($query['and']['greaterThan']) && !empty($query['and']['greaterThan']['dateReceived']) && !empty($query['and']['greaterThan']['value']) ) { $search_str .= ' SINCE ' . $query['and']['greaterThan']['value']->format('d-M-Y'); } if (!empty($query['and']['lessThan']) && !empty($query['and']['lessThan']['dateReceived']) && !empty($query['and']['lessThan']['value']) ) { $search_str .= ' BEFORE ' . $query['and']['lessThan']['value']->format('d-M-Y'); } if (isset($query['and']['freeText']) && strlen($query['and']['freeText'])) { // @FIXME: Should we use TEXT/BODY search? ActiveSync protocol specification says "indexed fields" $search = $query['and']['freeText']; $search_keys = ['SUBJECT', 'TO', 'FROM', 'CC']; $search_str .= str_repeat(' OR', count($search_keys) - 1); foreach ($search_keys as $key) { $search_str .= sprintf(" %s {%d}\r\n%s", $key, strlen($search), $search); } } if (!strlen($search_str)) { return []; } $search_str = 'ALL UNDELETED ' . trim($search_str); // @TODO: DeepTraversal if (empty($folders)) { $folders = $this->listFolders(); if (!is_array($folders)) { throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } $folders = array_keys($folders); } return [$folders, $search_str]; } /** * Fetches the entry from the backend */ protected function getObject($entryid, $dummy = null) { $message = $this->parseMessageId($entryid); if (empty($message)) { // @TODO: exception? return null; } return $this->backend->getItem($message['folderId'], $this->device->deviceid, $this->modelName, $message['uid']); } /** * Get attachment data from the server. * * @param string $fileReference * * @return Syncroton_Model_FileReference */ public function getFileReference($fileReference) { [$folderid, $uid, $part_id] = explode('::', $fileReference); $message = $this->getObject($fileReference); if (!$message) { throw new Syncroton_Exception_NotFound('Message not found'); } $part = $message->mime_parts[$part_id]; $body = $message->get_part_body($part_id); return new Syncroton_Model_FileReference([ 'contentType' => $part->mimetype, 'data' => $body, ]); } /** * Parses entry ID to get folder name and UID of the message */ protected function parseMessageId($entryid) { // replyEmail/forwardEmail if (is_array($entryid)) { $entryid = $entryid['itemId']; } if (!is_string($entryid) || !strpos($entryid, '::')) { return; } // Note: the id might be in a form of ::[::] [$folderid, $uid] = explode('::', $entryid); return [ 'uid' => $uid, 'folderId' => $folderid, ]; } /** * Creates entry ID of the message */ protected function serverId($uid, $folderid) { return $folderid . '::' . $uid; } /** * Returns body of the message in specified format * * @param rcube_message $message * @param bool $html */ protected function getMessageBody($message, $html = false) { if (!is_array($message->parts) && empty($message->body)) { return ''; } if (!empty($message->parts)) { foreach ($message->parts as $part) { // skip no-content and attachment parts (#1488557) if ($part->type != 'content' || !$part->size || $message->is_attachment($part)) { continue; } return $this->getMessagePartBody($message, $part, $html); } } return $this->getMessagePartBody($message, $message, $html); } /** * Returns body of the message part in specified format * * @param rcube_message $message * @param rcube_message_part $part * @param bool $html */ protected function getMessagePartBody($message, $part, $html = false) { if (empty($part->size) || empty($part->mime_id)) { // TODO: Throw an exception? return ''; } // Check if we have enough memory to handle the message in it $body = $this->message_mem_check($message, $part->size, false); if ($body !== false) { $body = $message->get_part_body($part->mime_id, true); } // message is cached but not exists, or other error if ($body === false) { return ''; } $ctype_secondary = !empty($part->ctype_secondary) ? $part->ctype_secondary : null; if ($html) { if ($ctype_secondary == 'html') { // charset was converted to UTF-8 in rcube_storage::get_message_part(), // change/add charset specification in HTML accordingly $meta = ''; // remove old meta tag and add the new one, making sure // that it is placed in the head $body = preg_replace('/]+charset=[a-z0-9-_]+[^>]*>/Ui', '', $body); $body = preg_replace('/(]*>)/Ui', '\\1' . $meta, $body, -1, $rcount); if (!$rcount) { $body = '' . $meta . '' . $body; } } elseif ($ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); } else { // Roundcube >= 1.2 if (class_exists('rcube_text2html')) { $flowed = isset($part->ctype_parameters['format']) && $part->ctype_parameters['format'] == 'flowed'; $delsp = isset($part->ctype_parameters['delsp']) && $part->ctype_parameters['delsp'] == 'yes'; $options = ['flowed' => $flowed, 'wrap' => false, 'delsp' => $delsp]; $text2html = new rcube_text2html($body, false, $options); $body = '' . $text2html->get_html() . ''; } else { $body = '
' . $body . '
'; } } } else { if ($ctype_secondary == 'enriched') { $body = rcube_enriched::to_html($body); $part->ctype_secondary = 'html'; } if ($ctype_secondary == 'html') { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } else { if ($ctype_secondary == 'plain' && !empty($part->ctype_parameters['format']) && $part->ctype_parameters['format'] == 'flowed' ) { $body = rcube_mime::unfold_flowed($body); } } } return $body; } /** * Converts and truncates message body for use in * * @return string Truncated plain text message */ protected function getPreview($body, $type, $size) { if ($type == Syncroton_Command_Sync::BODY_TYPE_HTML) { $txt = new rcube_html2text($body, false, true); $body = $txt->get_text(); } // size limit defined in ActiveSync protocol if ($size > 255) { $size = 255; } return mb_strcut(trim($body), 0, $size); } public static function charset_to_cp($charset) { // @TODO: ????? // The body is converted to utf-8 in get_part_body(), what about headers? return 65001; // UTF-8 /* $aliases = array( 'asmo708' => 708, 'shiftjis' => 932, 'gb2312' => 936, 'ksc56011987' => 949, 'big5' => 950, 'utf16' => 1200, 'utf16le' => 1200, 'unicodefffe' => 1201, 'utf16be' => 1201, 'johab' => 1361, 'macintosh' => 10000, 'macjapanese' => 10001, 'macchinesetrad' => 10002, 'mackorean' => 10003, 'macarabic' => 10004, 'machebrew' => 10005, 'macgreek' => 10006, 'maccyrillic' => 10007, 'macchinesesimp' => 10008, 'macromanian' => 10010, 'macukrainian' => 10017, 'macthai' => 10021, 'macce' => 10029, 'macicelandic' => 10079, 'macturkish' => 10081, 'maccroatian' => 10082, 'utf32' => 12000, 'utf32be' => 12001, 'chinesecns' => 20000, 'chineseeten' => 20002, 'ia5' => 20105, 'ia5german' => 20106, 'ia5swedish' => 20107, 'ia5norwegian' => 20108, 'usascii' => 20127, 'ibm273' => 20273, 'ibm277' => 20277, 'ibm278' => 20278, 'ibm280' => 20280, 'ibm284' => 20284, 'ibm285' => 20285, 'ibm290' => 20290, 'ibm297' => 20297, 'ibm420' => 20420, 'ibm423' => 20423, 'ibm424' => 20424, 'ebcdickoreanextended' => 20833, 'ibmthai' => 20838, 'koi8r' => 20866, 'ibm871' => 20871, 'ibm880' => 20880, 'ibm905' => 20905, 'ibm00924' => 20924, 'cp1025' => 21025, 'koi8u' => 21866, 'iso88591' => 28591, 'iso88592' => 28592, 'iso88593' => 28593, 'iso88594' => 28594, 'iso88595' => 28595, 'iso88596' => 28596, 'iso88597' => 28597, 'iso88598' => 28598, 'iso88599' => 28599, 'iso885913' => 28603, 'iso885915' => 28605, 'xeuropa' => 29001, 'iso88598i' => 38598, 'iso2022jp' => 50220, 'csiso2022jp' => 50221, 'iso2022jp' => 50222, 'iso2022kr' => 50225, 'eucjp' => 51932, 'euccn' => 51936, 'euckr' => 51949, 'hzgb2312' => 52936, 'gb18030' => 54936, 'isciide' => 57002, 'isciibe' => 57003, 'isciita' => 57004, 'isciite' => 57005, 'isciias' => 57006, 'isciior' => 57007, 'isciika' => 57008, 'isciima' => 57009, 'isciigu' => 57010, 'isciipa' => 57011, 'utf7' => 65000, 'utf8' => 65001, ); $charset = strtolower($charset); $charset = preg_replace(array('/^x-/', '/[^a-z0-9]/'), '', $charset); if (isset($aliases[$charset])) { return $aliases[$charset]; } if (preg_match('/^(ibm|dos|cp|windows|win)[0-9]+/', $charset, $m)) { return substr($charset, strlen($m[1]) + 1); } */ } /** * Wrap text to a given number of characters per line * but respect the mail quotation of replies messages (>). * Finally add another quotation level by prepending the lines * with > * * @param string $text Text to wrap * @param int $length The line width * * @return string The wrapped text */ protected static function wrap_and_quote($text, $length = 72) { // Function stolen from Roundcube ;) // Rebuild the message body with a maximum of $max chars, while keeping quoted message. $max = min(77, $length + 8); $lines = preg_split('/\r?\n/', trim($text)); $out = ''; foreach ($lines as $line) { // don't wrap already quoted lines if (isset($line[0]) && $line[0] == '>') { $line = '>' . rtrim($line); } elseif (mb_strlen($line) > $max) { $newline = ''; foreach (explode("\n", rcube_mime::wordwrap($line, $length - 2)) as $l) { if (strlen($l)) { $newline .= '> ' . $l . "\n"; } else { $newline .= ">\n"; } } $line = rtrim($newline); } else { $line = '> ' . $line; } // Append the line $out .= $line . "\n"; } return $out; } /** * Returns calendar event data from the iTip invitation attached to a mail message */ public function get_invitation_event_from_message($message) { // Parse the message and find iTip attachments $libcal = libcalendaring::get_instance(); $libcal->mail_message_load(['object' => $message]); $ical_objects = $libcal->get_mail_ical_objects(); // Skip methods we do not support here if (!in_array($ical_objects->method, ['REQUEST', 'CANCEL', 'REPLY'])) { return null; } // We support only one event in the iTip foreach ($ical_objects as $mime_id => $event) { if ($event['_type'] == 'event') { $event['_method'] = $ical_objects->method; $event['_mime_id'] = $ical_objects->mime_id; return $event; } } return null; } /** * Returns calendar event data from the iTip invitation attached to a mail message */ public function get_invitation_event($messageId) { // Get the mail message object if ($message = $this->getObject($messageId)) { return $this->get_invitation_event_from_message($message); } return null; } private function mem_check($need) { $mem_limit = (int) parse_bytes(ini_get('memory_limit')); $memory = static::$memory_accumulated; // @phpstan-ignore-next-line return ($mem_limit > 0 && $memory + $need > $mem_limit) ? false : true; } /** * Checks if the message can be processed, depending on its size and * memory_limit, otherwise throws an exception or returns fake body. */ protected function message_mem_check($message, $size, $result = null) { static $memory_rised; // @FIXME: we need up to 5x more memory than the body // Note: Biggest memory multiplication happens in recode_message() // and the Syncroton engine (which also does not support passing bodies // as streams). It also happens when parsing the plain/html text body // in getMessagePartBody() though the footprint there is probably lower. if (!$this->mem_check($size * 5)) { // If we already rised the memory we throw an exception, so the message // will be synchronized in the next run (then we might have enough memory) if ($memory_rised) { throw new Syncroton_Exception_MemoryExhausted(); } $memory_rised = true; $memory_max = 512; // maximum in MB $memory_limit = round(parse_bytes(ini_get('memory_limit')) / 1024 / 1024); // current limit (in MB) $memory_add = round($size * 5 / 1024 / 1024); // how much we need (in MB) $memory_needed = min($memory_limit + $memory_add, $memory_max) . "M"; if ($memory_limit < $memory_max) { $this->logger->debug("Setting memory_limit=$memory_needed"); if (ini_set('memory_limit', $memory_needed) !== false) { // Memory has been rised, check again // @phpstan-ignore-next-line if ($this->mem_check($size * 5)) { return; } } } $this->logger->warn("Not enough memory. Using fake email body."); if ($result !== null) { return $result; } // Let's return a fake message. If we return an empty body Outlook // will not list the message at all. This way user can do something // with the message (flag, delete, move) and see the reason why it's fake // and importantly see its subject, sender, etc. // TODO: Localization? $msg = "This message is too large for ActiveSync."; // $msg .= "See https://kb.kolabenterprise.com/documentation/some-place for more information."; // Get original message headers $headers = $this->storage->get_raw_headers($message->uid); // Build a fake message with original headers, but changed body return kolab_sync_message::fake_message($headers, $msg); } } } diff --git a/tests/Sync/Sync/EmailTest.php b/tests/Sync/Sync/EmailTest.php index 414968d..0ee6f16 100644 --- a/tests/Sync/Sync/EmailTest.php +++ b/tests/Sync/Sync/EmailTest.php @@ -1,468 +1,473 @@ emptyTestFolder('INBOX', 'mail'); $this->registerDevice(); // Test invalid collection identifier $request = << 0 1111111111 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $this->assertSame('12', $xpath->query("//ns:Sync/ns:Status")->item(0)->nodeValue); // Test INBOX $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $syncKey = 0; $request = << {$syncKey} {$folderId} EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $this->assertSame('1', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:SyncKey")->item(0)->nodeValue); $this->assertSame('Email', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Class")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:CollectionId")->item(0)->nodeValue); // Test listing mail in INBOX, use WindowSize=1 // Append two mail messages $this->appendMail('INBOX', 'mail.sync1'); $this->appendMail('INBOX', 'mail.sync2'); $request = << {$syncKey} {$folderId} 1 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); // Note: We assume messages are in IMAP default order, it may change in future $root .= "/ns:Commands/ns:Add"; $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); $this->assertSame('test sync', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue); // List the rest of the mail $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); // Note: We assume messages are in IMAP default order, it may change in future $root .= "/ns:Commands/ns:Add"; $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); $this->assertSame('sync test with attachment', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("{$root}/ns:ApplicationData/AirSyncBase:Body")->count()); return $syncKey; } /** * Test empty sync response * * @depends testSync */ public function testEmptySync($syncKey) { $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); // We expect an empty response without a change $this->assertEquals(0, $response->getBody()->getSize()); return $syncKey; } /** * Test flag change * * @depends testEmptySync */ public function testFlagChange($syncKey) { $this->assertTrue($this->markMailAsRead('INBOX', '*')); $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); + $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Change/ns:ApplicationData/Email:Read")->count()); + $this->assertSame('0', $xpath->query("{$root}/ns:Commands/ns:Change/ns:ApplicationData/Email:Read")->item(0)->nodeValue); + $this->assertSame('0', $xpath->query("{$root}/ns:Commands/ns:Change/ns:ApplicationData/Email:Read")->item(1)->nodeValue); + $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Change/ns:ApplicationData/AirSyncBase:Body")->count()); return $syncKey; } /** * Retry flag change * Resending the same syncKey should result in the same changes. * * @depends testFlagChange */ public function testRetryFlagChange($syncKey) { $syncKey--; $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); //FIXME I'm not sure why we get syncKey + 2, I suppose we just always increase the current synckey by one. $this->assertSame(strval($syncKey += 2), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); $serverId1 = $xpath->query("{$root}/ns:Commands/ns:Change/ns:ServerId")->item(0)->nodeValue; return [ 'syncKey' => $syncKey, 'serverId' => $serverId1, ]; } /** * Test updating message properties from client * * @depends testRetryFlagChange */ public function testChangeFromClient($values) { $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $syncKey = $values['syncKey']; $serverId = $values['serverId']; $request = << {$syncKey} {$folderId} {$serverId} 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); //The server doesn't have to report back successful changes $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); $emails = $this->listEmails('INBOX', '*'); $uid = explode("::", $serverId)[1]; $this->assertSame(2, count($emails)); $this->assertTrue(!array_key_exists('SEEN', $emails[$uid])); return [ 'syncKey' => $syncKey, 'serverId' => $serverId, ]; } /** * Test deleting messages from client * * @depends testChangeFromClient */ public function testDeleteFromClient($values) { $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $syncKey = $values['syncKey']; $serverId = $values['serverId']; $request = << {$syncKey} {$folderId} {$serverId} EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Change")->count()); $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Delete")->count()); $emails = $this->listEmails('INBOX', '*'); $uid = explode("::", $serverId)[1]; $this->assertSame(2, count($emails)); $this->assertTrue($emails[$uid]['DELETED']); return $syncKey; } /** * Test a sync key that doesn't exist yet. * @depends testDeleteFromClient */ public function testInvalidSyncKey($syncKey) { $syncKey++; $folderId = '38b950ebd62cd9a66929c89615d0fc04'; $request = << {$syncKey} {$folderId} 1 1 0 1 2 51200 0 EOF; $response = $this->request($request, 'Sync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); // print($dom->saveXML()); $root = "//ns:Sync/ns:Collections/ns:Collection"; $this->assertSame('3', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue); $this->assertSame('0', $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue); //We have to start over after this. The sync state was removed. return 0; } } diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php index 9cab2ca..3868f63 100644 --- a/tests/SyncTestCase.php +++ b/tests/SyncTestCase.php @@ -1,404 +1,405 @@ markTestSkipped('Not setup'); } self::$deviceType = null; } /** * {@inheritDoc} */ public static function setUpBeforeClass(): void { $sync = \kolab_sync::get_instance(); $config = $sync->config; $db = $sync->get_dbh(); self::$username = $config->get('activesync_test_username'); self::$password = $config->get('activesync_test_password'); self::$host = $config->get('activesync_test_host', 'http://localhost:8000'); if (empty(self::$username)) { return; } self::$deviceId = 'test' . time(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); $db->query('DELETE FROM syncroton_data'); $db->query('DELETE FROM syncroton_data_folder'); $db->query('DELETE FROM syncroton_content'); self::$client = new \GuzzleHttp\Client([ 'http_errors' => false, 'base_uri' => self::$host, 'verify' => false, 'auth' => [self::$username, self::$password], 'connect_timeout' => 10, 'timeout' => 10, 'headers' => [ 'Content-Type' => 'application/xml; charset=utf-8', 'Depth' => '1', ], ]); // TODO: execute: php -S localhost:8000 } /** * {@inheritDoc} */ public static function tearDownAfterClass(): void { if (self::$deviceId) { $sync = \kolab_sync::get_instance(); if (self::$authenticated || $sync->authenticate(self::$username, self::$password)) { $sync->password = self::$password; $storage = $sync->storage(); $storage->device_delete(self::$deviceId); } $db = $sync->get_dbh(); $db->query('DELETE FROM syncroton_device'); $db->query('DELETE FROM syncroton_synckey'); $db->query('DELETE FROM syncroton_folder'); } } /** * Append an email message to the IMAP folder */ protected function appendMail($folder, $filename, $replace = []) { $imap = $this->getImapStorage(); $source = __DIR__ . '/src/' . $filename; if (!file_exists($source)) { exit("File does not exist: {$source}"); } $is_file = true; if (!empty($replace)) { $is_file = false; $source = file_get_contents($source); foreach ($replace as $token => $value) { $source = str_replace($token, $value, $source); } } $uid = $imap->save_message($folder, $source, '', $is_file); if ($uid === false) { exit("Failed to append mail into {$folder}"); } return $uid; } /** * Run A SQL query */ protected function runSQLQuery($query) { $sync = \kolab_sync::get_instance(); $db = $sync->get_dbh(); $db->query($query); } /** * Mark an email message as read over IMAP */ protected function markMailAsRead($folder, $uids) { $imap = $this->getImapStorage(); return $imap->set_flag($uids, 'SEEN', $folder); } /** * List emails over IMAP */ protected function listEmails($folder, $uids) { $imap = $this->getImapStorage(); return $imap->list_flags($folder, $uids); } /** * Append an DAV object to a DAV/IMAP folder */ protected function appendObject($foldername, $filename, $type) { $path = __DIR__ . '/src/' . $filename; if (!file_exists($path)) { exit("File does not exist: {$path}"); } $content = file_get_contents($path); $uid = preg_match('/UID:(?:urn:uuid:)?([a-z0-9-]+)/', $content, $m) ? $m[1] : null; if (empty($uid)) { exit("Filed to find UID in {$path}"); } if ($this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($foldername)) { // TODO exit("Not implemented for Kolab v3 storage driver"); } return; } $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $foldername) { $dav_type = $folder->get_dav_type(); $location = $folder->object_location($uid); if ($folder->dav->create($location, $content, $dav_type) !== false) { return; } } } exit("Failed to append object into {$foldername}"); } /** * Delete a folder */ protected function deleteTestFolder($name, $type) { // Deleting IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); if ($imap->folder_exists($name)) { $imap->delete_folder($name); } return; } // Deleting DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $dav->folder_delete($folder->id, $type); } } } /** * Remove all objects from a folder */ protected function emptyTestFolder($name, $type) { // Deleting in IMAP folders if ($type == 'mail' || $this->isStorageDriver('kolab')) { $imap = $this->getImapStorage(); $imap->delete_message('*', $name); return; } // Deleting in DAV folders $dav = $this->getDavStorage(); foreach ($dav->get_folders($type) as $folder) { if ($folder->get_name() === $name) { $folder->delete_all(); } } } /** * Convert WBXML binary content into XML */ protected function fromWbxml($binary) { $stream = fopen('php://memory', 'r+'); fwrite($stream, $binary); rewind($stream); $decoder = new \Syncroton_Wbxml_Decoder($stream); return $decoder->decode(); } /** * Initialize DAV storage */ protected function getDavStorage() { $sync = \kolab_sync::get_instance(); $url = $sync->config->get('activesync_dav_server', 'http://localhost'); if (strpos($url, '://') === false) { $url = 'http://' . $url; } // Inject user+password to the URL, there's no other way to pass it to the DAV client $url = str_replace('://', '://' . rawurlencode(self::$username) . ':' . rawurlencode(self::$password) . '@', $url); // Make sure user is authenticated $this->getImapStorage(); if (!empty($sync->user)) { // required e.g. for DAV client cache use \rcube::get_instance()->user = $sync->user; } return new \kolab_storage_dav($url); } /** * Initialize IMAP storage */ protected function getImapStorage() { $sync = \kolab_sync::get_instance(); if (!self::$authenticated) { if ($sync->authenticate(self::$username, self::$password)) { self::$authenticated = true; $sync->password = self::$password; } } return $sync->get_storage(); } /** * Check the configured activesync_storage driver */ protected function isStorageDriver($name) { return $name === \kolab_sync::get_instance()->config->get('activesync_storage', 'kolab'); } /** * Make a HTTP request to the ActiveSync server */ protected function request($body, $cmd, $type = 'POST') { $username = self::$username; $deviceId = self::$deviceId; $deviceType = self::$deviceType ?: 'WindowsOutlook15'; $body = $this->toWbxml($body); return self::$client->request( $type, "?Cmd={$cmd}&User={$username}&DeviceId={$deviceId}&DeviceType={$deviceType}", [ 'headers' => [ 'Content-Type' => 'application/vnd.ms-sync.wbxml', 'MS-ASProtocolVersion' => '14.0', ], 'body' => $body, ] ); } /** * Register the device for tests, some commands do not work until device/folders are registered */ protected function registerDevice() { // Execute initial FolderSync, it is required before executing some commands $request = << 0 EOF; $response = $this->request($request, 'FolderSync'); $this->assertEquals(200, $response->getStatusCode()); $dom = $this->fromWbxml($response->getBody()); $xpath = $this->xpath($dom); foreach ($xpath->query("//ns:FolderSync/ns:Changes/ns:Add") as $idx => $folder) { $serverId = $folder->getElementsByTagName('ServerId')->item(0)->nodeValue; $displayName = $folder->getElementsByTagName('DisplayName')->item(0)->nodeValue; $this->folders[$serverId] = $displayName; } } /** * Convert XML into WBXML binary content */ protected function toWbxml($xml) { $outputStream = fopen('php://temp', 'r+'); $encoder = new \Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3); $dom = new \DOMDocument(); $dom->loadXML($xml); $encoder->encode($dom); rewind($outputStream); return stream_get_contents($outputStream); } /** * Get XPath from a DOM */ protected function xpath($dom) { $xpath = new \DOMXpath($dom); $xpath->registerNamespace("ns", $dom->documentElement->namespaceURI); $xpath->registerNamespace("AirSync", "uri:AirSync"); + $xpath->registerNamespace("AirSyncBase", "uri:AirSyncBase"); $xpath->registerNamespace("Calendar", "uri:Calendar"); $xpath->registerNamespace("Contacts", "uri:Contacts"); $xpath->registerNamespace("Email", "uri:Email"); $xpath->registerNamespace("Email2", "uri:Email2"); $xpath->registerNamespace("Settings", "uri:Settings"); $xpath->registerNamespace("Tasks", "uri:Tasks"); return $xpath; } /** * adapter for phpunit < 9 */ public static function assertMatchesRegularExpression(string $arg1, string $arg2, string $message = ''): void { if (method_exists("PHPUnit\Framework\TestCase", "assertMatchesRegularExpression")) { parent::assertMatchesRegularExpression($arg1, $arg2, $message); } else { parent::assertRegExp($arg1, $arg2); } } }