diff --git a/composer.json-dist b/composer.json-dist --- a/composer.json-dist +++ b/composer.json-dist @@ -18,11 +18,14 @@ "pear/net_smtp": "~1.7.1", "pear/net_ldap2": "~2.2.0", "pear/net_sieve": "~1.4.0", + "sabre/vobject": "~4.5.1", "kolab/net_ldap3": "dev-master", + "zf1s/zend-controller": "~1.12.20", "zf1s/zend-json": "~1.12.20", "zf1s/zend-log": "~1.12.20" }, "require-dev": { + "guzzlehttp/guzzle": "^7.3.0", "phpunit/phpunit": "^4.8 || ^5.7 || ^6 || ^7 || ^9" } } diff --git a/lib/ext/Syncroton/Server.php b/lib/ext/Syncroton/Server.php --- a/lib/ext/Syncroton/Server.php +++ b/lib/ext/Syncroton/Server.php @@ -435,6 +435,7 @@ $device->useragent = $requestParameters['userAgent']; $device->acsversion = $requestParameters['protocolVersion']; + $device->devicetype = $requestParameters['deviceType']; if ($device->isDirty()) { $device = $this->_deviceBackend->update($device); diff --git a/lib/ext/rtf.php b/lib/ext/rtf.php --- a/lib/ext/rtf.php +++ b/lib/ext/rtf.php @@ -150,7 +150,6 @@ "222" => "Thai", "238" => "Eastern European", "255" => "PC 437", - "255" => "OEM", ); /* note: the only conversion table used */ @@ -176,7 +175,7 @@ $this->rtf_len = strlen($this->rtf); }; if($this->rtf_len == 0) { - debugLog("No data in stream found"); + //debugLog("No data in stream found"); return false; }; return true; @@ -197,12 +196,12 @@ $header = unpack("LcSize/LuSize/Lmagic/Lcrc32",substr($src,0,16)); $in = 16; if ($header['cSize'] != strlen($src)-4) { - debugLog("Stream too short"); + //debugLog("Stream too short"); return false; } if ($header['crc32'] != $this->LZRTFCalcCRC32($src,16,(($header['cSize']+4))-16)) { - debugLog("CRC MISMATCH"); + //debugLog("CRC MISMATCH"); return false; } @@ -234,7 +233,7 @@ $src = $dst; $dest = substr($src,$this->LZRTF_HDR_LEN,$header['uSize']); } else { // unknown magic - returfn false (please report if this ever happens) - debugLog("Unknown Magic"); + //debugLog("Unknown Magic"); return false; } @@ -426,7 +425,7 @@ function checkHtmlSpanContent($command) { reset($this->fontmodifier_table); - while(list($rtf, $html) = each($this->fontmodifier_table)) { + foreach ($this->fontmodifier_table as $rtf => $html) { if($this->flags[$rtf] == true) { if($command == "start") $this->out .= "<".$html.">"; @@ -558,7 +557,7 @@ if(count($this->err) > 0) { if($this->wantXML) { $this->out .= ""; - while(list($num,$value) = each($this->err)) { + foreach($this->err as $num => $value) { $this->out .= "".$value.""; } $this->out .= ""; @@ -569,7 +568,7 @@ function makeStyles() { $this->outstyles = "\n"; diff --git a/lib/kolab_sync.php b/lib/kolab_sync.php --- a/lib/kolab_sync.php +++ b/lib/kolab_sync.php @@ -31,9 +31,12 @@ /** @var string Application name */ public $app_name = 'ActiveSync for Kolab'; // no double quotes inside +<<<<<<< HEAD /** @var rcube_user|null Current user */ public $user; +======= +>>>>>>> 04bb982... DAV storage /** @var string|null Request user name */ public $username; @@ -44,6 +47,7 @@ protected $per_user_log_dir; protected $log_dir; + protected $logger; const CHARSET = 'UTF-8'; const VERSION = "2.4.2"; @@ -80,8 +84,8 @@ // Get list of plugins // WARNING: We can use only plugins that are prepared for this - // e.g. are not using output or rcmail objects or - // doesn't throw errors when using them + // e.g. are not using output or rcmail objects and + // do not throw errors when using them $plugins = (array)$this->config->get('activesync_plugins', array('kolab_auth')); $plugins = array_unique(array_merge($plugins, array('libkolab', 'libcalendaring'))); @@ -187,7 +191,7 @@ * @param string $username User name * @param string $password User password * - * @param int User ID + * @return null|int User ID */ public function authenticate($username, $password) { @@ -211,7 +215,7 @@ } // LDAP server failure... send 503 error - if ($auth['kolab_ldap_error'] ?? null) { + if (!empty($auth['kolab_ldap_error'])) { self::server_error(); } @@ -229,12 +233,13 @@ $err = null; // Authenticate - get Roundcube user ID - if (!($auth['abort'] ?? false) && ($userid = $this->login($auth['user'], $auth['pass'], $auth['host'], $err))) { + if (empty($auth['abort']) && ($userid = $this->login($auth['user'], $auth['pass'], $auth['host'], $err))) { // set real username $this->username = $auth['user']; return $userid; } - else if ($err) { + + if ($err) { $err_str = $this->get_storage()->get_error_str(); } @@ -251,8 +256,9 @@ if ($err == rcube_imap_generic::ERROR_BAD) { self::server_error(); } - } + return null; + } /** * Storage host selection @@ -260,7 +266,7 @@ private function select_host($username) { // Get IMAP host - $host = $this->config->get('default_host'); + $host = $this->config->get('imap_host', $this->config->get('default_host')); if (is_array($host)) { list($user, $domain) = explode('@', $username); @@ -281,8 +287,8 @@ // take the first entry if $host is not found if (is_array($host)) { - list($key, $val) = each($host); - $host = is_numeric($key) ? $val : $key; + $key = key($host); + $host = is_numeric($key) ? $host[$key] : $key; } } @@ -391,6 +397,22 @@ } + /** + * Initializes and returns the storage backend object + */ + public static function storage() + { + $class = 'kolab_sync_storage'; + $self = self::get_instance(); + + if (($name = $self->config->get('activesync_storage')) && $name != 'kolab') { + $class .= '_' . strtolower($name); + } + + return $class::get_instance(); + } + + /** * Set logging directory per-user */ @@ -474,6 +496,10 @@ */ public static function server_error() { + if (php_sapi_name() == 'cli') { + throw new Exception("LDAP/IMAP error on authentication"); + } + header("HTTP/1.1 503 Service Temporarily Unavailable"); header("Retry-After: 120"); exit; @@ -499,12 +525,13 @@ // make sure logged numbers use unified format setlocale(LC_NUMERIC, 'en_US.utf8', 'en_US.UTF-8', 'en_US', 'C'); + $mem = ''; if (function_exists('memory_get_usage')) $mem = round(memory_get_usage() / 1048576, 1); if (function_exists('memory_get_peak_usage')) $mem .= '/' . round(memory_get_peak_usage() / 1048576, 1); - $query = $_SERVER['QUERY_STRING']; + $query = $_SERVER['QUERY_STRING'] ?? ''; $log = $query . ($mem ? ($query ? ' ' : '') . "[$mem]" : ''); if (defined('KOLAB_SYNC_START')) @@ -533,5 +560,8 @@ kolab_sync_data_gal::$address_books = array(); } + + // Reset internal cache of the storage class + self::storage()->reset(); } } diff --git a/lib/kolab_sync_backend.php b/lib/kolab_sync_backend.php deleted file mode 100644 --- a/lib/kolab_sync_backend.php +++ /dev/null @@ -1,1057 +0,0 @@ - | - | | - | 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 | - +--------------------------------------------------------------------------+ -*/ - -class kolab_sync_backend -{ - /** - * Singleton instace of kolab_sync_backend - * - * @var kolab_sync_backend - */ - static protected $instance; - - protected $storage; - protected $folder_meta; - protected $folder_uids; - protected $root_meta; - - static protected $types = array( - 1 => '', - 2 => 'mail.inbox', - 3 => 'mail.drafts', - 4 => 'mail.wastebasket', - 5 => 'mail.sentitems', - 6 => 'mail.outbox', - 7 => 'task.default', - 8 => 'event.default', - 9 => 'contact.default', - 10 => 'note.default', - 11 => 'journal.default', - 12 => 'mail', - 13 => 'event', - 14 => 'contact', - 15 => 'task', - 16 => 'journal', - 17 => 'note', - ); - - static protected $classes = array( - Syncroton_Data_Factory::CLASS_CALENDAR => 'event', - Syncroton_Data_Factory::CLASS_CONTACTS => 'contact', - Syncroton_Data_Factory::CLASS_EMAIL => 'mail', - Syncroton_Data_Factory::CLASS_NOTES => 'note', - Syncroton_Data_Factory::CLASS_TASKS => 'task', - ); - - const ROOT_MAILBOX = 'INBOX'; -// const ROOT_MAILBOX = ''; - const ASYNC_KEY = '/private/vendor/kolab/activesync'; - const UID_KEY = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; - - - /** - * This implements the 'singleton' design pattern - * - * @return kolab_sync_backend The one and only instance - */ - static function get_instance() - { - if (!self::$instance) { - self::$instance = new kolab_sync_backend; - self::$instance->startup(); // init AFTER object was linked with self::$instance - } - - return self::$instance; - } - - - /** - * Class initialization - */ - public function startup() - { - $this->storage = rcube::get_instance()->get_storage(); - - // @TODO: reset cache? if we do this for every request the cache would be useless - // There's no session here - //$this->storage->clear_cache('mailboxes.', true); - - // set additional header used by libkolab - $this->storage->set_options(array( - // @TODO: there can be Roundcube plugins defining additional headers, - // we maybe would need to add them here - 'fetch_headers' => 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION', - 'skip_deleted' => true, - 'threading' => false, - )); - - // Disable paging - $this->storage->set_pagesize(999999); - } - - - /** - * List known devices - * - * @return array Device list as hash array - */ - public function devices_list() - { - if ($this->root_meta === null) { - // @TODO: consider server annotation instead of INBOX - if ($meta = $this->storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) { - $this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]); - } - else { - $this->root_meta = array(); - } - } - - if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) { - return $this->root_meta['DEVICE']; - } - - return array(); - } - - - /** - * Get list of folders available for sync - * - * @param string $deviceid Device identifier - * @param string $type Folder type - * @param bool $flat_mode Enables flat-list mode - * - * @return array|bool List of mailbox folders, False on backend failure - */ - public function folders_list($deviceid, $type, $flat_mode = false) - { - // get all folders of specified type - $folders = kolab_storage::list_folders('', '*', $type, false, $typedata); - - // get folders activesync config - $folderdata = $this->folder_meta(); - - if (!is_array($folders) || !is_array($folderdata)) { - return false; - } - - $folders_list = array(); - - // check if folders are "subscribed" for activesync - foreach ($folderdata as $folder => $meta) { - if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) - || empty($meta['FOLDER'][$deviceid]['S']) - ) { - continue; - } - - // force numeric folder name to be a string (T1283) - $folder = (string) $folder; - - if (!empty($type) && !in_array($folder, $folders)) { - continue; - } - - // Activesync folder identifier (serverId) - $folder_type = !empty($typedata[$folder]) ? $typedata[$folder] : 'mail'; - $folder_id = self::folder_id($folder, $folder_type); - - $folders_list[$folder_id] = $this->folder_data($folder, $folder_type); - } - - if ($flat_mode) { - $folders_list = $this->folders_list_flat($folders_list, $type, $typedata); - } - - return $folders_list; - } - - /** - * Converts list of folders to a "flat" list - */ - private function folders_list_flat($folders, $type, $typedata) - { - $delim = $this->storage->get_hierarchy_delimiter(); - - foreach ($folders as $idx => $folder) { - if ($folder['parentId']) { - // for non-mail folders we make the list completely flat - if ($type != 'mail') { - $display_name = kolab_storage::object_name($folder['imap_name']); - $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); - - $folders[$idx]['parentId'] = 0; - $folders[$idx]['displayName'] = $display_name; - } - // for mail folders we modify only folders with non-existing parents - else if (!isset($folders[$folder['parentId']])) { - $items = explode($delim, $folder['imap_name']); - $parent = 0; - - // find existing parent - while (count($items) > 0) { - array_pop($items); - - $parent_name = implode($delim, $items); - $parent_type = !empty($typedata[$parent_name]) ? $typedata[$parent_name] : 'mail'; - $parent_id = self::folder_id($parent_name, $parent_type); - - if (isset($folders[$parent_id])) { - $parent = $parent_id; - break; - } - } - - if (!$parent) { - $display_name = kolab_storage::object_name($folder['imap_name']); - $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); - } - else { - $parent_name = $folders[$parent_id]['imap_name']; - $display_name = substr($folder['imap_name'], strlen($parent_name)+1); - $display_name = rcube_charset::convert($display_name, 'UTF7-IMAP'); - $display_name = str_replace($delim, ' ยป ', $display_name); - } - - $folders[$idx]['parentId'] = $parent; - $folders[$idx]['displayName'] = $display_name; - } - } - } - - return $folders; - } - - /** - * Getter for folder metadata - * - * @return array|bool Hash array with meta data for each folder, False on backend failure - */ - public function folder_meta() - { - if (!isset($this->folder_meta)) { - // get folders activesync config - $folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY); - - if (!is_array($folderdata)) { - return $this->folder_meta = false; - } - - $this->folder_meta = array(); - - foreach ($folderdata as $folder => $meta) { - if ($asyncdata = $meta[self::ASYNC_KEY]) { - if ($metadata = $this->unserialize_metadata($asyncdata)) { - $this->folder_meta[$folder] = $metadata; - } - } - } - } - - return $this->folder_meta; - } - - - /** - * Creates folder and subscribes to the device - * - * @param string $name Folder name (UTF7-IMAP) - * @param int $type Folder (ActiveSync) type - * @param string $deviceid Device identifier - * - * @return bool True on success, False on failure - */ - public function folder_create($name, $type, $deviceid) - { - if ($this->storage->folder_exists($name)) { - $created = true; - } - else { - $type = self::type_activesync2kolab($type); - $created = kolab_storage::folder_create($name, $type, true); - } - - if ($created) { - // Set ActiveSync subscription flag - $this->folder_set($name, $deviceid, 1); - - return true; - } - - return false; - } - - - /** - * Renames a folder - * - * @param string $old_name Old folder name (UTF7-IMAP) - * @param string $new_name New folder name (UTF7-IMAP) - * @param int $type Folder (ActiveSync) type - * - * @return bool True on success, False on failure - */ - public function folder_rename($old_name, $new_name, $type) - { - $this->folder_meta = null; - - $type = self::type_activesync2kolab($type); - - // don't use kolab_storage for moving mail folders - if (preg_match('/^mail/', $type)) { - return $this->storage->rename_folder($old_name, $new_name); - } - else { - return kolab_storage::folder_rename($old_name, $new_name); - } - } - - - /** - * Deletes folder - * - * @param string $name Folder name (UTF7-IMAP) - * @param string $deviceid Device identifier - * - */ - public function folder_delete($name, $deviceid) - { - unset($this->folder_meta[$name]); - - return kolab_storage::folder_delete($name); - } - - - /** - * Sets ActiveSync subscription flag on a folder - * - * @param string $name Folder name (UTF7-IMAP) - * @param string $deviceid Device identifier - * @param int $flag Flag value (0|1|2) - */ - public function folder_set($name, $deviceid, $flag) - { - if (empty($deviceid)) { - return false; - } - - // get folders activesync config - $metadata = $this->folder_meta(); - - if (!is_array($metadata)) { - return false; - } - - $metadata = isset($metadata[$name]) ? $metadata[$name] : array(); - - if ($flag) { - if (empty($metadata)) { - $metadata = array(); - } - - if (empty($metadata['FOLDER'])) { - $metadata['FOLDER'] = array(); - } - - if (empty($metadata['FOLDER'][$deviceid])) { - $metadata['FOLDER'][$deviceid] = array(); - } - - // Z-Push uses: - // 1 - synchronize, no alarms - // 2 - synchronize with alarms - $metadata['FOLDER'][$deviceid]['S'] = $flag; - } - else { - unset($metadata['FOLDER'][$deviceid]['S']); - - if (empty($metadata['FOLDER'][$deviceid])) { - unset($metadata['FOLDER'][$deviceid]); - } - - if (empty($metadata['FOLDER'])) { - unset($metadata['FOLDER']); - } - - if (empty($metadata)) { - $metadata = null; - } - } - - // Return if nothing's been changed - if (!self::data_array_diff(isset($this->folder_meta[$name]) ? $this->folder_meta[$name] : null, $metadata)) { - return true; - } - - $this->folder_meta[$name] = $metadata; - - return $this->storage->set_metadata($name, array( - self::ASYNC_KEY => $this->serialize_metadata($metadata))); - } - - - public function device_get($id) - { - $devices_list = $this->devices_list(); - return $devices_list[$id] ?? null; - } - - /** - * Registers new device on server - * - * @param array $device Device data - * @param string $id Device ID - * - * @return bool True on success, False on failure - */ - public function device_create($device, $id) - { - // Fill local cache - $this->devices_list(); - - // Some devices create dummy devices with name "validate" (#1109) - // This device entry is used in two initial requests, but later - // the device registers a real name. We can remove this dummy entry - // on new device creation - $this->device_delete('validate'); - - // Old Kolab_ZPush device parameters - // MODE: -1 | 0 | 1 (not set | flatmode | foldermode) - // TYPE: device type string - // ALIAS: user-friendly device name - - // Syncroton (kolab_sync_backend_device) uses - // ID: internal identifier in syncroton database - // TYPE: device type string - // ALIAS: user-friendly device name - - $metadata = $this->root_meta; - $metadata['DEVICE'][$id] = $device; - $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata)); - - $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); - - if ($result) { - // Update local cache - $this->root_meta['DEVICE'][$id] = $device; - - // subscribe default set of folders - $this->device_init_subscriptions($id); - } - - return $result; - } - - /** - * Device update. - * - * @param array $device Device data - * @param string $id Device ID - * - * @return bool True on success, False on failure - */ - public function device_update($device, $id) - { - $devices_list = $this->devices_list(); - $old_device = $devices_list[$id]; - - if (!$old_device) { - return false; - } - - // Do nothing if nothing is changed - if (!self::data_array_diff($old_device, $device)) { - return true; - } - - $device = array_merge($old_device, $device); - - $metadata = $this->root_meta; - $metadata['DEVICE'][$id] = $device; - $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata)); - - $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); - - if ($result) { - // Update local cache - $this->root_meta['DEVICE'][$id] = $device; - } - - return $result; - } - - - /** - * Device delete. - * - * @param string $id Device ID - * - * @return bool True on success, False on failure - */ - public function device_delete($id) - { - $device = $this->device_get($id); - - if (!$device) { - return false; - } - - unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]); - - if (empty($this->root_meta['DEVICE'])) { - unset($this->root_meta['DEVICE']); - } - if (empty($this->root_meta['FOLDER'])) { - unset($this->root_meta['FOLDER']); - } - - $metadata = $this->serialize_metadata($this->root_meta); - $metadata = array(self::ASYNC_KEY => $metadata); - - // update meta data - $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); - - if ($result) { - // remove device annotation for every folder - foreach ($this->folder_meta() as $folder => $meta) { - // skip root folder (already handled above) - if ($folder == self::ROOT_MAILBOX) - continue; - - if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) { - unset($meta['FOLDER'][$id]); - - if (empty($meta['FOLDER'])) { - unset($this->folder_meta[$folder]['FOLDER']); - unset($meta['FOLDER']); - } - if (empty($meta)) { - unset($this->folder_meta[$folder]); - $meta = null; - } - - $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($meta)); - $res = $this->storage->set_metadata($folder, $metadata); - - if ($res && $meta) { - $this->folder_meta[$folder] = $meta; - } - } - } - } - - return $result; - } - - /** - * Subscribe default set of folders on device registration - */ - private function device_init_subscriptions($deviceid) - { - // INBOX always exists - $this->folder_set('INBOX', $deviceid, 1); - - $supported_types = array( - 'mail.drafts', - 'mail.wastebasket', - 'mail.sentitems', - 'mail.outbox', - 'event.default', - 'contact.default', - 'note.default', - 'task.default', - 'event', - 'contact', - 'note', - 'task', - 'event.confidential', - 'event.private', - 'task.confidential', - 'task.private', - ); - - // This default set can be extended by adding following values: - $modes = array( - 'SUB_PERSONAL' => 1, // all subscribed folders in personal namespace - 'ALL_PERSONAL' => 2, // all folders in personal namespace - 'SUB_OTHER' => 4, // all subscribed folders in other users namespace - 'ALL_OTHER' => 8, // all folders in other users namespace - 'SUB_SHARED' => 16, // all subscribed folders in shared namespace - 'ALL_SHARED' => 32, // all folders in shared namespace - ); - - $rcube = rcube::get_instance(); - $config = $rcube->config; - $mode = (int) $config->get('activesync_init_subscriptions'); - $folders = array(); - - // Subscribe to default folders - $foldertypes = kolab_storage::folders_typedata(); - - if (!empty($foldertypes)) { - $_foldertypes = array_intersect($foldertypes, $supported_types); - - // get default folders - foreach ($_foldertypes as $folder => $type) { - // only personal folders - if ($this->storage->folder_namespace($folder) == 'personal') { - $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; - $this->folder_set($folder, $deviceid, $flag); - $folders[] = $folder; - } - } - } - - // we're in default mode, exit - if (!$mode) { - return; - } - - // below we support additionally all mail folders - $supported_types[] = 'mail'; - $supported_types[] = 'mail.junkemail'; - - // get configured special folders - $special_folders = array(); - $map = array( - 'drafts' => 'mail.drafts', - 'junk' => 'mail.junkemail', - 'sent' => 'mail.sentitems', - 'trash' => 'mail.wastebasket', - ); - - foreach ($map as $folder => $type) { - if ($folder = $config->get($folder . '_mbox')) { - $special_folders[$folder] = $type; - } - } - - // get folders list(s) - if (($mode & $modes['ALL_PERSONAL']) || ($mode & $modes['ALL_OTHER']) || ($mode & $modes['ALL_SHARED'])) { - $all_folders = $this->storage->list_folders(); - if (($mode & $modes['SUB_PERSONAL']) || ($mode & $modes['SUB_OTHER']) || ($mode & $modes['SUB_SHARED'])) { - $subscribed_folders = $this->storage->list_folders_subscribed(); - } - } - else { - $all_folders = $this->storage->list_folders_subscribed(); - } - - foreach ($all_folders as $folder) { - // folder already subscribed - if (in_array($folder, $folders)) { - continue; - } - - $type = ($foldertypes[$folder] ?? null) ?: 'mail'; - if ($type == 'mail' && isset($special_folders[$folder])) { - $type = $special_folders[$folder]; - } - - if (!in_array($type, $supported_types)) { - continue; - } - - $ns = strtoupper($this->storage->folder_namespace($folder)); - - // subscribe the folder according to configured mode - // and folder namespace/subscription status - if (($mode & $modes["ALL_$ns"]) - || (($mode & $modes["SUB_$ns"]) - && (!isset($subscribed_folders) || in_array($folder, $subscribed_folders))) - ) { - $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; - $this->folder_set($folder, $deviceid, $flag); - } - } - } - - /** - * Helper method to decode saved IMAP metadata - */ - private function unserialize_metadata($str) - { - if (!empty($str)) { - // Support old Z-Push annotation format - if ($str[0] != '{') { - $str = base64_decode($str); - } - $data = json_decode($str, true); - return $data; - } - - return null; - } - - /** - * Helper method to encode IMAP metadata for saving - */ - private function serialize_metadata($data) - { - if (!empty($data) && is_array($data)) { - $data = json_encode($data); -// $data = base64_encode($data); - return $data; - } - - return null; - } - - /** - * Returns Kolab folder type for specified ActiveSync type ID - */ - public static function type_activesync2kolab($type) - { - if (!empty(self::$types[$type])) { - return self::$types[$type]; - } - - return ''; - } - - /** - * Returns ActiveSync folder type for specified Kolab type - */ - public static function type_kolab2activesync($type) - { - $type = preg_replace('/\.(confidential|private)$/i', '', $type); - - if ($key = array_search($type, self::$types)) { - return $key; - } - - return key(self::$types); - } - - /** - * Returns Kolab folder type for specified ActiveSync class name - */ - public static function class_activesync2kolab($class) - { - if (!empty(self::$classes[$class])) { - return self::$classes[$class]; - } - - return ''; - } - - /** - * Returns folder data in Syncroton format - */ - private function folder_data($folder, $type) - { - // Folder name parameters - $delim = $this->storage->get_hierarchy_delimiter(); - $items = explode($delim, $folder); - $name = array_pop($items); - - // Folder UID - $folder_id = $this->folder_id($folder, $type); - - // Folder type - if (strcasecmp($folder, 'INBOX') === 0) { - // INBOX is always inbox, prevent from issues related with a change of - // folder type annotation (it can be initially unset). - $type = 2; - } - else { - $type = self::type_kolab2activesync($type); - - // fix type, if there's no type annotation it's detected as UNKNOWN we'll use 'mail' (12) - if ($type == 1) { - $type = 12; - } - } - - // Syncroton folder data array - return array( - 'serverId' => $folder_id, - 'parentId' => count($items) ? self::folder_id(implode($delim, $items)) : 0, - 'displayName' => rcube_charset::convert($name, 'UTF7-IMAP', kolab_sync::CHARSET), - 'type' => $type, - // for internal use - 'imap_name' => $folder, - ); - } - - /** - * Builds folder ID based on folder name - */ - public function folder_id($name, $type = null) - { - // ActiveSync expects folder identifiers to be max.64 characters - // So we can't use just folder name - - $name = (string) $name; - - if ($name === '') { - return null; - } - - if (isset($this->folder_uids[$name])) { - return $this->folder_uids[$name]; - } - -/* - @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves. - There's one inconvenience of this solution: folder name/type change - would be handled in ActiveSync as delete + create. - - // get folders unique identifier - $folderdata = $this->storage->get_metadata($name, self::UID_KEY); - - if ($folderdata && !empty($folderdata[$name])) { - $uid = $folderdata[$name][self::UID_KEY]; - return $this->folder_uids[$name] = $uid; - } -*/ - if (strcasecmp($name, 'INBOX') === 0) { - // INBOX is always inbox, prevent from issues related with a change of - // folder type annotation (it can be initially unset). - $type = 'mail.inbox'; - } - else { - if ($type === null) { - $type = kolab_storage::folder_type($name); - } - - if ($type != null) { - $type = preg_replace('/\.(confidential|private)$/i', '', $type); - } - } - - // Add type to folder UID hash, so type change can be detected by Syncroton - $uid = $name . '!!' . $type; - $uid = md5($uid); - - return $this->folder_uids[$name] = $uid; - } - - /** - * Returns IMAP folder name - * - * @param string $id Folder identifier - * @param string $deviceid Device dentifier - * - * @return string Folder name (UTF7-IMAP) - */ - public function folder_id2name($id, $deviceid) - { - // check in cache first - if (!empty($this->folder_uids)) { - if (($name = array_search($id, $this->folder_uids)) !== false) { - return $name; - } - } - -/* - @TODO: see folder_id() - - // get folders unique identifier - $folderdata = $this->storage->get_metadata('*', self::UID_KEY); - - foreach ((array)$folderdata as $folder => $data) { - if (!empty($data[self::UID_KEY])) { - $uid = $data[self::UID_KEY]; - $this->folder_uids[$folder] = $uid; - if ($uid == $id) { - $name = $folder; - } - } - } -*/ - // get all folders of specified type - $folderdata = $this->folder_meta(); - - if (!is_array($folderdata) || $id === null) { - return null; - } - - // check if folders are "subscribed" for activesync - foreach ($folderdata as $folder => $meta) { - if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) - || empty($meta['FOLDER'][$deviceid]['S']) - ) { - continue; - } - - if ($uid = self::folder_id($folder)) { - $this->folder_uids[$folder] = $uid; - } - - if ($uid === $id) { - $name = $folder; - } - } - - return $name; - } - - /** - */ - public function modseq_set($deviceid, $folderid, $synctime, $data) - { - $synctime = $synctime->format('Y-m-d H:i:s'); - $rcube = rcube::get_instance(); - $db = $rcube->get_dbh(); - $old_data = $this->modseq[$folderid][$synctime] ?? null; - - if (empty($old_data)) { - $this->modseq[$folderid][$synctime] = $data; - $data = json_encode($data); - - $db->set_option('ignore_key_errors', true); - $db->query("INSERT INTO `syncroton_modseq` (`device_id`, `folder_id`, `synctime`, `data`)" - ." VALUES (?, ?, ?, ?)", - $deviceid, $folderid, $synctime, $data); - $db->set_option('ignore_key_errors', false); - } - } - - public function modseq_get($deviceid, $folderid, $synctime) - { - $synctime = $synctime->format('Y-m-d H:i:s'); - - if (empty($this->modseq[$folderid][$synctime])) { - $this->modseq[$folderid] = array(); - - $rcube = rcube::get_instance(); - $db = $rcube->get_dbh(); - - $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_modseq`" - ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" - ." ORDER BY `synctime` DESC", - 0, 1, $deviceid, $folderid, $synctime); - - if ($row = $db->fetch_assoc()) { - $synctime = $row['synctime']; - // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format - $this->modseq[$folderid][$synctime] = json_decode($row['data'], true); - } - - // Cleanup: remove all records except the current one - $db->query("DELETE FROM `syncroton_modseq`" - ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", - $deviceid, $folderid, $synctime); - } - - return @$this->modseq[$folderid][$synctime]; - } - - /** - * Set state of relation objects at specified point in time - */ - public function relations_state_set($deviceid, $folderid, $synctime, $relations) - { - $synctime = $synctime->format('Y-m-d H:i:s'); - $rcube = rcube::get_instance(); - $db = $rcube->get_dbh(); - $old_data = $this->relations[$folderid][$synctime] ?? null; - - if (empty($old_data)) { - $this->relations[$folderid][$synctime] = $relations; - $data = rcube_charset::clean(json_encode($relations)); - - $db->set_option('ignore_key_errors', true); - $db->query("INSERT INTO `syncroton_relations_state`" - ." (`device_id`, `folder_id`, `synctime`, `data`)" - ." VALUES (?, ?, ?, ?)", - $deviceid, $folderid, $synctime, $data); - $db->set_option('ignore_key_errors', false); - } - } - - /** - * Get state of relation objects at specified point in time - */ - public function relations_state_get($deviceid, $folderid, $synctime) - { - $synctime = $synctime->format('Y-m-d H:i:s'); - - if (empty($this->relations[$folderid][$synctime])) { - $this->relations[$folderid] = array(); - - $rcube = rcube::get_instance(); - $db = $rcube->get_dbh(); - - $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_relations_state`" - ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" - ." ORDER BY `synctime` DESC", - 0, 1, $deviceid, $folderid, $synctime); - - if ($row = $db->fetch_assoc()) { - $synctime = $row['synctime']; - // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format - $this->relations[$folderid][$synctime] = json_decode($row['data'], true); - } - - // Cleanup: remove all records except the current one - $db->query("DELETE FROM `syncroton_relations_state`" - ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", - $deviceid, $folderid, $synctime); - } - - return @$this->relations[$folderid][$synctime]; - } - - /** - * Return last storage error - */ - public static function last_error() - { - return kolab_storage::$last_error; - } - - /** - * Compares two arrays - * - * @param array $array1 - * @param array $array2 - * - * @return bool True if arrays differs, False otherwise - */ - private static function data_array_diff($array1, $array2) - { - if (!is_array($array1) || !is_array($array2)) { - return $array1 != $array2; - } - - if (count($array1) != count($array2)) { - return true; - } - - foreach ($array1 as $key => $val) { - if (!array_key_exists($key, $array2)) { - return true; - } - if ($val !== $array2[$key]) { - return true; - } - } - - return false; - } -} diff --git a/lib/kolab_sync_backend_common.php b/lib/kolab_sync_backend_common.php --- a/lib/kolab_sync_backend_common.php +++ b/lib/kolab_sync_backend_common.php @@ -79,9 +79,9 @@ /** * Creates new Syncroton object in database * - * @param Syncroton_Model_* $object Object + * @param object $object Object * - * @return Syncroton_Model_* Object + * @return object Object * @throws InvalidArgumentException|Syncroton_Exception_DeadlockDetected|Exception */ public function create($object) @@ -119,9 +119,10 @@ /** * Returns Syncroton data object * - * @param string $id + * @param string $id + * * @throws Syncroton_Exception_NotFound - * @return Syncroton_Model_* + * @return object */ public function get($id) { @@ -142,7 +143,7 @@ /** * Deletes Syncroton data object * - * @param string|Syncroton_Model_* $id Object or identifier + * @param string|object $id Object or identifier * * @return bool True on success, False on failure * @throws Syncroton_Exception_DeadlockDetected|Exception @@ -162,7 +163,7 @@ if ($this->db->error_info()[0] == '40001') { throw new Syncroton_Exception_DeadlockDetected($err); } else { - throw new Exception($rr); + throw new Exception($err); } } @@ -172,9 +173,9 @@ /** * Updates Syncroton data object * - * @param Syncroton_Model_* $object + * @param object $object * - * @return Syncroton_Model_* Object + * @return object Object * @throws InvalidArgumentException|Syncroton_Exception_DeadlockDetected|Exception */ public function update($object) @@ -215,6 +216,7 @@ public function userAccounts($device) { // this method is overwritten by kolab_sync_backend class + return []; } /** diff --git a/lib/kolab_sync_backend_device.php b/lib/kolab_sync_backend_device.php --- a/lib/kolab_sync_backend_device.php +++ b/lib/kolab_sync_backend_device.php @@ -32,9 +32,9 @@ protected $interface_name = 'Syncroton_Model_IDevice'; /** - * Kolab Sync backend + * Kolab Sync storage backend * - * @var kolab_sync_backend + * @var kolab_sync_storage */ protected $backend; @@ -45,7 +45,7 @@ public function __construct() { parent::__construct(); - $this->backend = kolab_sync_backend::get_instance(); + $this->backend = kolab_sync::storage(); } /** @@ -311,7 +311,7 @@ if (class_exists('managesieve')) { $plugin = $engine->plugins->get_plugin('managesieve'); - $vacation = $plugin->get_engine('vacation'); + $vacation = $plugin->get_engine('vacation'); // @phpstan-ignore-line if ($vacation->connect($engine->username, $engine->password)) { throw new Exception("Connection to managesieve server failed"); diff --git a/lib/kolab_sync_data.php b/lib/kolab_sync_data.php --- a/lib/kolab_sync_data.php +++ b/lib/kolab_sync_data.php @@ -35,6 +35,13 @@ */ protected $asversion = 0; + /** + * The storage backend + * + * @var kolab_sync_storage + */ + protected $backend; + /** * information about the current device * @@ -71,25 +78,25 @@ protected $defaultFolder; /** - * type of user created folders + * default root folder * - * @var int + * @var string */ - protected $folderType; + protected $defaultRootFolder; /** - * Internal cache for kolab_storage folder objects + * type of user created folders * - * @var array + * @var int */ - protected $folders = array(); + protected $folderType; /** - * Internal cache for IMAP folders list + * Internal cache for storage folders list * * @var array */ - protected $imap_folders = array(); + protected $folders = []; /** * Logger instance. @@ -120,6 +127,9 @@ 'playbook', ); + protected $lastsync_folder = null; + protected $lastsync_time = null; + const RESULT_OBJECT = 0; const RESULT_UID = 1; const RESULT_COUNT = 2; @@ -185,10 +195,10 @@ */ public function __construct(Syncroton_Model_IDevice $device, DateTime $syncTimeStamp) { - $this->backend = kolab_sync_backend::get_instance(); + $this->backend = kolab_sync::storage(); $this->device = $device; $this->asversion = floatval($device->acsversion); - $this->syncTimeStamp = $syncTimeStamp; + $this->syncTimeStamp = $this->backend->syncTimeStamp = $syncTimeStamp; $this->logger = Syncroton_Registry::get(Syncroton_Registry::LOGGERBACKEND); $this->defaultRootFolder = $this->defaultFolder . '::Syncroton'; @@ -246,6 +256,8 @@ */ public function getChangedFolders(DateTime $startTimeStamp, DateTime $endTimeStamp) { + // FIXME/TODO: Can we get mtime of a DAV folder? + // Without this, we have a problem if folder ID does not change on rename return array(); } @@ -289,8 +301,7 @@ // Return first on the list if there's no default if (empty($default)) { - $key = array_shift(array_keys($folders)); - $default = $folders[$key]; + $default = array_first($folders); // make sure the type is default here $default['type'] = $this->defaultFolderType; } @@ -308,47 +319,16 @@ */ public function createFolder(Syncroton_Model_IFolder $folder) { - $parentid = $folder->parentId; - $type = $folder->type; - $display_name = $folder->displayName; - $parent = null; - - if ($parentid) { - $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); - - if ($parent === null) { - throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); - } - } - - $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); - - if ($parent !== null) { - $rcube = rcube::get_instance(); - $storage = $rcube->get_storage(); - $delim = $storage->get_hierarchy_delimiter(); - $name = $parent . $delim . $name; - } - - // Create IMAP folder - $result = $this->backend->folder_create($name, $type, $this->device->deviceid); + $result = $this->backend->folder_create($folder->displayName, $folder->type, $this->device->deviceid, $folder->parentId); if ($result) { - $folder->serverId = $this->backend->folder_id($name); + $folder->serverId = $result; return $folder; } - $errno = Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR; - - // Special case when client tries to create a subfolder of INBOX - // which is not possible on Cyrus-IMAP (T2223) - if ($parent == 'INBOX' && stripos($this->backend->last_error(), 'invalid') !== false) { - $errno = Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER; - } - // Note: Looks like Outlook 2013 ignores any errors on FolderCreate command - throw new Syncroton_Exception_Status_FolderCreate($errno); + throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::UNKNOWN_ERROR); } /** @@ -356,32 +336,7 @@ */ public function updateFolder(Syncroton_Model_IFolder $folder) { - $parentid = $folder->parentId; - $type = $folder->type; - $display_name = $folder->displayName; - $old_name = $this->backend->folder_id2name($folder->serverId, $this->device->deviceid); - - if ($parentid) { - $parent = $this->backend->folder_id2name($parentid, $this->device->deviceid); - } - - $name = rcube_charset::convert($display_name, kolab_sync::CHARSET, 'UTF7-IMAP'); - - if ($parent !== null) { - $rcube = rcube::get_instance(); - $storage = $rcube->get_storage(); - $delim = $storage->get_hierarchy_delimiter(); - $name = $parent . $delim . $name; - } - - // Rename/move IMAP folder - if ($name == $old_name) { - $result = true; - // @TODO: folder type change? - } - else { - $result = $this->backend->folder_rename($old_name, $name, $type); - } + $result = $this->backend->folder_rename($folder->serverId, $this->device->deviceid, $folder->displayName, $folder->parentId); if ($result) { return $folder; @@ -399,53 +354,28 @@ $folder = $folder->serverId; } - $name = $this->backend->folder_id2name($folder, $this->device->deviceid); - // @TODO: throw exception - return $this->backend->folder_delete($name, $this->device->deviceid); + return $this->backend->folder_delete($folder, $this->device->deviceid); } /** * Empty folder (remove all entries and optionally subfolders) * - * @param string $folderId Folder identifier + * @param string $folderid Folder identifier * @param array $options Options */ public function emptyFolderContents($folderid, $options) { - $folders = $this->extractFolders($folderid); - - foreach ($folders as $folderid) { - $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); - $folder = $this->getFolderObject($foldername); - - if (!$folder || !$folder->valid) { + // ActiveSync spec.: Clients use EmptyFolderContents to empty the Deleted Items folder. + // The client can clear out all items in the Deleted Items folder when the user runs out of storage quota + // (indicated by the return of an MailboxQuotaExceeded (113) status code from the server. + // FIXME: Does that mean we don't need this to work on any other folder? + // TODO: Respond with MailboxQuotaExceeded status. Where exactly? + + foreach ($this->extractFolders($folderid) as $folderid) { + if (!$this->backend->folder_empty($folderid, $this->device->deviceid, !empty($options['deleteSubFolders']))) { throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); } - - // Remove all entries - $folder->delete_all(); - - // Remove subfolders - if (!empty($options['deleteSubFolders'])) { - $list = $this->listFolders($folderid); - - if (!is_array($list)) { - throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); - } - - foreach ($list as $folderid => $folder) { - $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); - $folder = $this->getFolderObject($foldername); - - if (!$folder || !$folder->valid) { - throw new Syncroton_Exception_Status_ItemOperations(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR); - } - - // Remove all entries - $folder->delete_all(); - } - } } } @@ -461,23 +391,16 @@ */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { - $item = $this->getObject($srcFolderId, $serverId, $folder); + // TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it. + $item = $this->getObject($srcFolderId, $serverId); - if (!$item || !$folder) { + if (!$item) { throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); } - $dstname = $this->backend->folder_id2name($dstFolderId, $this->device->deviceid); - - if ($dstname === null) { - throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); - } - - if (!$folder->move($serverId, $dstname)) { - throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); - } + $uid = $this->backend->moveItem($item['folderId'], $this->device->deviceid, $this->modelName, $item['uid'], $dstFolderId); - return $item['uid']; + return $this->serverId($uid, $dstFolderId); } /** @@ -491,21 +414,32 @@ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { $entry = $this->toKolab($entry, $folderId); - $entry = $this->createObject($folderId, $entry); - if (empty($entry)) { + if ($folderId == $this->defaultRootFolder) { + $default = $this->getDefaultFolder(); + + if (!is_array($default)) { + return null; + } + + $folderId = isset($default['realid']) ? $default['realid'] : $default['serverId']; + } + + $uid = $this->backend->createItem($folderId, $this->device->deviceid, $this->modelName, $entry); + + if (empty($uid)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } - return $entry['_serverId']; + return $this->serverId($uid, $folderId); } /** * update existing entry * - * @param string $folderId - * @param string $serverId - * @param SimpleXMLElement $entry + * @param string $folderId + * @param string $serverId + * @param Syncroton_Model_IEntry $entry * * @return string ID of the updated entry */ @@ -518,17 +452,17 @@ } $entry = $this->toKolab($entry, $folderId, $oldEntry); - $entry = $this->updateObject($folderId, $serverId, $entry); + $uid = $this->backend->updateItem($oldEntry['folderId'], $this->device->deviceid, $this->modelName, $oldEntry['uid'], $entry); - if (empty($entry)) { + if (empty($uid)) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } - return $entry['_serverId']; + return $this->serverId($uid, $oldEntry['folderId']); } /** - * delete entry + * Delete entry * * @param string $folderId * @param string $serverId @@ -536,21 +470,31 @@ */ public function deleteEntry($folderId, $serverId, $collectionData) { - $deleted = $this->deleteObject($folderId, $serverId); + // TODO: Optimize, we just need to find the folder ID and UID, we do not need to "fetch" it. + $object = $this->getObject($folderId, $serverId); - if (!$deleted) { - throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); + if ($object) { + $deleted = $this->backend->deleteItem($object['folderId'], $this->device->deviceid, $this->modelName, $object['uid']); + + if (!$deleted) { + throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); + } } } - + /** + * Get attachment data from the server. + * + * @param string $fileReference + * + * @return Syncroton_Model_FileReference + */ public function getFileReference($fileReference) { // to be implemented by Email data class - // @TODO: throw "unimplemented" exception here? + throw new Syncroton_Exception_NotFound('File references not supported'); } - /** * Search for existing entries * @@ -558,108 +502,31 @@ * @param array $filter Search filter * @param int $result_type Type of the result (see RESULT_* constants) * - * @return array|int Search result as count or array of uids/objects + * @return array|int Search result as count or array of uids/objects */ protected function searchEntries($folderid, $filter = array(), $result_type = self::RESULT_UID) { - if ($folderid == $this->defaultRootFolder) { - $folders = $this->listFolders(); - - if (!is_array($folders)) { - throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); - } - - $folders = array_keys($folders); - } - else { - $folders = array($folderid); - } - - // there's a PHP Warning from kolab_storage if $filter isn't an array - if (empty($filter)) { - $filter = array(); - } - else { - $changed_objects = $this->getChangesByRelations($folderid, $filter); - } + $result = $result_type == self::RESULT_COUNT ? 0 : []; + $ts = time(); + $force = $this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout(); + $found = false; - $result = $result_type == self::RESULT_COUNT ? 0 : array(); - $found = 0; - - foreach ($folders as $folder_id) { - $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); - $folder = $this->getFolderObject($foldername); - - if (!$folder || !$folder->valid) { - throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); - } - - $found++; - $error = false; + foreach ($this->extractFolders($folderid) as $fid) { + $search = $this->backend->searchEntries($fid, $this->device->deviceid, $this->modelName, $filter, $result_type, $force); + $found = true; switch ($result_type) { case self::RESULT_COUNT: - $count = $folder->count($filter); - - if ($count === null || $count === false) { - $error = true; - } - else { - $result += (int) $count; - } + $result += $search; break; case self::RESULT_UID: - $uids = $folder->get_uids($filter); - - if (!is_array($uids)) { - $error = true; - } - else if (!empty($uids)) { - $result = array_merge($result, $this->applyServerId($uids, $folder)); - } - break; - } - - if ($error) { - throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); - } - - // handle tag modifications - if (!empty($changed_objects)) { - // build new filter - // search objects mathing current filter, - // relations may contain members of many types, we need to - // search them by UID in all requested folders to get - // only these with requested type (and that really exist - // in specified folders) - $tag_filter = array(array('uid', '=', $changed_objects)); - foreach ($filter as $f) { - if ($f[0] != 'changed') { - $tag_filter[] = $f; - } + foreach ($search as $idx => $uid) { + $search[$idx] = $this->serverId($uid, $fid); } - switch ($result_type) { - case self::RESULT_COUNT: - // Note: this way we're potentally counting the same objects twice - // I'm not sure if this is a problem, we most likely do not - // need a precise result here - $count = $folder->count($tag_filter); - if ($count !== null && $count !== false) { - $result += (int) $count; - } - - break; - - case self::RESULT_UID: - $uids = $folder->get_uids($tag_filter); - if (is_array($uids) && !empty($uids)) { - $result = array_unique(array_merge($result, $this->applyServerId($uids, $folder))); - } - - break; - } + $result = array_unique(array_merge($result, $search)); + break; } } @@ -667,138 +534,8 @@ throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } - return $result; - } - - /** - * Detect changes of relation (tag) objects data and assigned objects - * Returns relation member identifiers - */ - protected function getChangesByRelations($folderid, $filter) - { - if (isset($this->tag_categories) && !$this->tag_categories) { - return; - } - - // get period filter, create new objects filter - foreach ($filter as $f) { - if ($f[0] == 'changed' && $f[1] == '>') { - $since = $f[2]; - } - } - - // this is not search for changes, do nothing - if (empty($since)) { - return; - } - - // get relations state from the last sync - $last_state = (array) $this->backend->relations_state_get($this->device->id, $folderid, $since); - - // get current relations state - $config = kolab_storage_config::get_instance(); - $default = true; - $filter = array( - array('type', '=', 'relation'), - array('category', '=', 'tag') - ); - - $relations = $config->get_objects($filter, $default, 100); - - $result = array(); - $changed = false; - - // compare states, get members of changed relations - foreach ($relations as $relation) { - $rel_id = $relation['uid']; - - if ($relation['changed']) { - $relation['changed']->setTimezone(new DateTimeZone('UTC')); - } - - // last state unknown... - if (empty($last_state[$rel_id])) { - // ...get all members - if (!empty($relation['members'])) { - $changed = true; - $result = array_merge($result, $relation['members']); - } - } - // last state known, changed tag name... - else if ($last_state[$rel_id]['name'] != $relation['name']) { - // ...get all (old and new) members - $members_old = explode("\n", $last_state[$rel_id]['members']); - $changed = true; - $members = array_unique(array_merge($relation['members'], $members_old)); - $result = array_merge($result, $members); - } - // last state known, any other change change... - else if ($last_state[$rel_id]['changed'] < $relation['changed']->format('U')) { - // ...find new and removed members - $members_old = explode("\n", $last_state[$rel_id]['members']); - $new = array_diff($relation['members'], $members_old); - $removed = array_diff($members_old, $relation['members']); - - if (!empty($new) || !empty($removed)) { - $changed = true; - $result = array_merge($result, $new, $removed); - } - } - - unset($last_state[$rel_id]); - } - - // get members of deleted relations - if (!empty($last_state)) { - $changed = true; - foreach ($last_state as $relation) { - $members = explode("\n", $relation['members']); - $result = array_merge($result, $members); - } - } - - // save current state - if ($changed) { - $data = array(); - foreach ($relations as $relation) { - $data[$relation['uid']] = array( - 'name' => $relation['name'], - 'changed' => $relation['changed']->format('U'), - 'members' => implode("\n", (array)$relation['members']), - ); - } - - $now = new DateTime('now', new DateTimeZone('UTC')); - - $this->backend->relations_state_set($this->device->id, $folderid, $now, $data); - } - - // in mail mode return only message URIs - if ($this->modelName == 'mail') { - // lambda function to skip email members - $filter_func = function($value) { - return strpos($value, 'imap://') === 0; - }; - - $result = array_filter(array_unique($result), $filter_func); - } - // otherwise return only object UIDs - else { - // lambda function to skip email members - $filter_func = function($value) { - return strpos($value, 'urn:uuid:') === 0; - }; - - // lambda function to parse member URI - $member_func = function($value) { - if (strpos($value, 'urn:uuid:') === 0) { - $value = substr($value, 9); - } - return $value; - }; - - $result = array_map($member_func, array_filter(array_unique($result), $filter_func)); - } + $this->lastsync_folder = $folderid; + $this->lastsync_time = $ts; return $result; } @@ -808,7 +545,7 @@ * * @param int $filter_type Filter type * - * @param array Filter query + * @return array Filter query */ protected function filter($filter_type = 0) { @@ -822,7 +559,7 @@ * @param string $folderId * @param DateTime $start * @param DateTime $end - * @param int $filterType + * @param int $filter_type * * @return array */ @@ -844,7 +581,7 @@ * @param string $folderId * @param DateTime $start * @param DateTime $end - * @param int $filterType + * @param int $filter_type * * @return int */ @@ -863,8 +600,8 @@ /** * get id's of all entries available on the server * - * @param string $folderId - * @param int $filterType + * @param string $folder_id + * @param int $filter_type * * @return array */ @@ -879,8 +616,8 @@ /** * get count of all entries available on the server * - * @param string $folderId - * @param int $filterType + * @param string $folder_id + * @param int $filter_type * * @return int */ @@ -903,6 +640,7 @@ */ public function getCountOfChanges(Syncroton_Backend_IContent $contentBackend, Syncroton_Model_IFolder $folder, Syncroton_Model_ISyncState $syncState) { + // @phpstan-ignore-next-line $allClientEntries = $contentBackend->getFolderState($this->device, $folder, $syncState->counter); $allServerEntries = $this->getServerEntries($folder->serverId, $folder->lastfiltertype); $changedEntries = $this->getChangedEntriesCount($folder->serverId, $syncState->lastsync, null, $folder->lastfiltertype); @@ -928,6 +666,7 @@ return true; } + // @phpstan-ignore-next-line $allClientEntries = $contentBackend->getFolderState($this->device, $folder, $syncState->counter); // @TODO: Consider looping over all folders here, not in getServerEntries() and @@ -949,147 +688,42 @@ /** * Fetches the entry from the backend */ - protected function getObject($folderid, $entryid, &$folder = null) + protected function getObject($folderid, $entryid) { - $folders = $this->extractFolders($folderid); - - if (empty($folders)) { - return null; - } - - foreach ($folders as $folderid) { - $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); - $folder = $this->getFolderObject($foldername); - - if ($folder && $folder->valid) { - $crc = null; - $uid = $entryid; - - // See self::serverId() for full explanation - // Use (slower) UID prefix matching... - if (preg_match('/^CRC([0-9A-Fa-f]{8})(.+)$/', $uid, $matches)) { - $crc = $matches[1]; - $uid = $matches[2]; - - if (strlen($entryid) >= 64) { - foreach ($folder->select(array(array('uid', '~*', $uid))) as $object) { - if (($object['uid'] == $uid || strpos($object['uid'], $uid) === 0) - && $crc == $this->objectCRC($object['uid'], $folder) - ) { - $object['_folderid'] = $folderid; - return $object; - } + foreach ($this->extractFolders($folderid) as $fid) { + $crc = null; + $uid = $entryid; + + // See self::serverId() for full explanation + // Use (slower) UID prefix matching... + if (preg_match('/^CRC([0-9A-Fa-f]{8})(.+)$/', $uid, $matches)) { + $crc = $matches[1]; + $uid = $matches[2]; + + if (strlen($entryid) >= 64) { + $objects = $this->backend->getItemsByUidPrefix($fid, $this->device->deviceid, $this->modelName, $uid); + + foreach ($objects as $object) { + if (($object['uid'] === $uid || strpos($object['uid'], $uid) === 0) + && $crc == $this->objectCRC($object['uid'], $fid) + ) { + $object['folderId'] = $fid; + return $object; } - - continue; } - } - - // Or (faster) strict UID matching... - if (($object = $folder->get_object($uid)) - && ($crc === null || $crc == $this->objectCRC($object['uid'], $folder)) - ) { - $object['_folderid'] = $folderid; - return $object; - } - } - } - } - - /** - * Saves the entry on the backend - */ - protected function createObject($folderid, $data) - { - if ($folderid == $this->defaultRootFolder) { - $default = $this->getDefaultFolder(); - - if (!is_array($default)) { - return null; - } - - $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; - } - - // convert categories into tags, save them after creating an object - if (!empty($data['categories']) && isset($this->tag_categories) && $this->tag_categories) { - $tags = $data['categories']; - unset($data['categories']); - } - - $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); - $folder = $this->getFolderObject($foldername); - - // Set User-Agent for saved objects - $app = kolab_sync::get_instance(); - $app->config->set('useragent', $app->app_name . ' ' . kolab_sync::VERSION); - - if ($folder && $folder->valid && $folder->save($data)) { - if (!empty($tags)) { - $this->setKolabTags($data['uid'], $tags); - } - - $data['_serverId'] = $this->serverId($data['uid'], $folder); - return $data; - } - } - - /** - * Updates the entry on the backend - */ - protected function updateObject($folderid, $entryid, $data) - { - $object = $this->getObject($folderid, $entryid); - - if ($object) { - $folder = $this->getFolderObject($object['_mailbox']); - - // convert categories into tags, save them after updating an object - if (isset($this->tag_categories) && $this->tag_categories && array_key_exists('categories', $data)) { - $tags = (array) $data['categories']; - unset($data['categories']); - } - - // Set User-Agent for saved objects - $app = kolab_sync::get_instance(); - $app->config->set('useragent', $app->app_name . ' ' . kolab_sync::VERSION); - - if ($folder && $folder->valid && $folder->save($data)) { - if (isset($tags)) { - $this->setKolabTags($data['uid'], $tags); + continue; } - - $data['_serverId'] = $this->serverId($object['uid'], $folder); - - return $data; } - } - } - /** - * Removes the entry from the backend - */ - protected function deleteObject($folderid, $entryid) - { - $object = $this->getObject($folderid, $entryid); - - if ($object) { - $folder = $this->getFolderObject($object['_mailbox']); + // Or (faster) strict UID matching... + $object = $this->backend->getItem($fid, $this->device->deviceid, $this->modelName, $uid); - if ($folder && $folder->valid && $folder->delete($object['uid'])) { - if (isset($this->tag_categories) && $this->tag_categories) { - $this->setKolabTags($object['uid'], null); - } - - return true; + if (!empty($object) && ($crc === null || $crc == $this->objectCRC($object['uid'], $fid))) { + $object['folderId'] = $fid; + return $object; } - - return false; } - - // object doesn't exist, confirm deletion - return true; } /** @@ -1105,11 +739,11 @@ $folderid = $folderid->serverId; } - if ($folderid == $this->defaultRootFolder) { + if ($folderid === $this->defaultRootFolder) { $folders = $this->listFolders(); if (!is_array($folders)) { - return null; + throw new Syncroton_Exception_NotFound('Folder not found'); } $folders = array_keys($folders); @@ -1130,19 +764,19 @@ */ protected function listFolders($parentid = null) { - if (empty($this->imap_folders)) { - $this->imap_folders = $this->backend->folders_list( + if (empty($this->folders)) { + $this->folders = $this->backend->folders_list( $this->device->deviceid, $this->modelName, $this->isMultiFolder()); } - if ($parentid === null || !is_array($this->imap_folders)) { - return $this->imap_folders; + if ($parentid === null || !is_array($this->folders)) { + return $this->folders; } - $folders = array(); - $parents = array($parentid); + $folders = []; + $parents = [$parentid]; - foreach ($this->imap_folders as $folder_id => $folder) { + foreach ($this->folders as $folder_id => $folder) { if ($folder['parentId'] && in_array($folder['parentId'], $parents)) { $folders[$folder_id] = $folder; $parents[] = $folder_id; @@ -1152,89 +786,38 @@ return $folders; } - /** - * Returns Folder object (uses internal cache) - * - * @param string $name Folder name (UTF7-IMAP) - * - * @return kolab_storage_folder Folder object - */ - protected function getFolderObject($name) - { - if ($name === null || $name === '') { - return null; - } - - if (!isset($this->folders[$name])) { - $this->folders[$name] = kolab_storage::get_folder($name, $this->modelName); - } - - return $this->folders[$name]; - } - /** * Returns ActiveSync settings of specified folder * - * @param string $name Folder name (UTF7-IMAP) + * @param string $folderid Folder identifier * * @return array Folder settings */ - protected function getFolderConfig($name) - { - $metadata = $this->backend->folder_meta(); - - if (!is_array($metadata)) { - return array(); - } - - $deviceid = $this->device->deviceid; - $config = $metadata[$name]['FOLDER'][$deviceid]; - - return array( - 'ALARMS' => $config['S'] == 2, - ); - } - - /** - * Returns real folder name for specified folder ID - */ - protected function getFolderName($folderid) + protected function getFolderConfig($folderid) { if ($folderid == $this->defaultRootFolder) { - $default = $this->getDefaultFolder(); + $default = $this->getDefaultFolder(); if (!is_array($default)) { - return null; + return []; } $folderid = isset($default['realid']) ? $default['realid'] : $default['serverId']; } - return $this->backend->folder_id2name($folderid, $this->device->deviceid); - } - - /** - * Returns folder ID from Kolab folder object - */ - protected function getFolderId($folder) - { - if (!$this->isMultiFolder()) { - return $this->defaultRootFolder; - } - - return $this->backend->folder_id($folder->get_name(), $folder->get_type()); + return $this->backend->getFolderConfig($folderid, $this->device->deviceid, $this->modelName); } /** * Convert contact from xml to kolab format * - * @param Syncroton_Model_IEntry $data Contact data - * @param string $folderId Folder identifier - * @param array $entry Old Contact data for merge + * @param mixed $data Contact data + * @param string $folderId Folder identifier + * @param array $entry Old Contact data for merge * * @return array */ - abstract function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null); + abstract function toKolab($data, $folderId, $entry = null); /** * Extracts data from kolab data array @@ -1431,8 +1014,8 @@ /** * Setter for Body attribute according to client version * - * @param string $value Body - * @param array $param Body parameters + * @param string $value Body + * @param array $params Body parameters * * @reurn Syncroton_Model_EmailBody Body element */ @@ -1546,7 +1129,7 @@ * * @param DateTime|int|string $date Unix timestamp, date (YYYY-MM-DD) or PHP DateTime object * - * @return DateTime Datetime object + * @return DateTime|null Datetime object */ protected static function date_from_kolab($date) { @@ -1567,7 +1150,7 @@ $utc = new DateTimeZone('UTC'); // safe dateonly object conversion to UTC // note: _dateonly flag is set by libkolab e.g. for birthdays - if ($date->_dateonly) { + if (!empty($date->_dateonly)) { // avoid time change $date = new DateTime($date->format('Y-m-d'), $utc); // set time to noon to avoid timezone troubles @@ -1584,6 +1167,8 @@ return $date; } + + return null; } /** @@ -1783,7 +1368,7 @@ foreach ((array)$data['recurrence']['EXCEPTIONS'] as $exception) { $exception['_mailbox'] = $data['_mailbox']; - $ex = $this->getEntry($collection, $exception, true); + $ex = $this->getEntry($collection, $exception, true); // @phpstan-ignore-line $date = clone ($exception['recurrence_date'] ?: $ex['startTime']); $ex['exceptionStartTime'] = self::set_exception_time($date, $data['_start'] ?? null); @@ -1838,11 +1423,11 @@ $rrule['EXDATE'][] = $date; } else { - $ex = $this->toKolab($exception, $folderid, null, $timezone); + $ex = $this->toKolab($exception, $folderid, null, $timezone); // @phpstan-ignore-line $ex['recurrence_date'] = $date; - if ($data->allDayEvent) { + if (!empty($data->allDayEvent)) { $ex['allday'] = 1; } @@ -1876,32 +1461,6 @@ } } - /** - * Returns list of tag names assigned to kolab object - */ - protected function getKolabTags($uid, $categories = null) - { - $config = kolab_storage_config::get_instance(); - $tags = $config->get_tags($uid); - $tags = array_filter(array_map(function($v) { return $v['name']; }, $tags)); - - // merge result with old categories - if (!empty($categories)) { - $tags = array_unique(array_merge($tags, (array) $categories)); - } - - return $tags; - } - - /** - * Set tags to kolab object - */ - protected function setKolabTags($uid, $tags) - { - $config = kolab_storage_config::get_instance(); - $config->save_tags($uid, $tags); - } - /** * Converts string of days (TU,TH) to bitmask used by ActiveSync * @@ -1980,10 +1539,6 @@ */ protected function serverId($uid, $folder) { - if ($this->modelName == 'mail') { - return $uid; - } - // When ActiveSync communicates with the client, it refers to objects with a ServerId // We can't use object UID for ServerId because: // - ServerId is limited to 64 chars, @@ -2014,25 +1569,11 @@ protected function objectCRC($uid, $folder) { if (!is_object($folder)) { - $folder = $this->getFolderObject($folder); + $folder = $this->backend->getFolder($folder, $this->device->deviceid, $this->modelName); } $folder_uid = $folder->get_uid(); return strtoupper(hash('crc32b', $folder_uid . $uid)); // always 8 chars } - - /** - * Apply serverId() on a set of uids - */ - protected function applyServerId($uids, $folder) - { - if (!empty($uids) && $this->modelName != 'mail') { - $self = $this; - $func = function($uid) use ($self, $folder) { return $self->serverId($uid, $folder); }; - $uids = array_map($func, $uids); - } - - return $uids; - } } diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -175,17 +175,18 @@ * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier - * @param boolean $as_array Return entry as array + * @param bool $as_array Return entry as array * * @return array|Syncroton_Model_Event|array Event object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false) { $event = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); - $config = $this->getFolderConfig($event['_mailbox']); + $config = $this->getFolderConfig($event['folderId']); $result = array(); - $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; - $is_android = stripos($this->device->devicetype, 'android') !== false; + + $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; + $is_android = stripos($this->device->devicetype, 'android') !== false; // 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 @@ -203,9 +204,9 @@ // At least Android doesn't display such event as all-day event if ($value && is_a($value, 'DateTime')) { $date = clone $value; - if ($event['allday']) { + if (!empty($event['allday'])) { // need this for self::date_from_kolab() - $date->_dateonly = false; + $date->_dateonly = false; // @phpstan-ignore-line if ($name == 'start') { $date->setTime(0, 0, 0); @@ -252,7 +253,7 @@ } // Event reminder time - if ($config['ALARMS']) { + if (!empty($config['ALARMS'])) { $result['reminder'] = $this->from_kolab_alarm($event); } @@ -268,11 +269,11 @@ if (!empty($event['attendees'])) { foreach ($event['attendees'] as $idx => $attendee) { if ($attendee['role'] == 'ORGANIZER') { - if ($name = $attendee['name']) { - $result['organizerName'] = $name; + if (!empty($attendee['name'])) { + $result['organizerName'] = $attendee['name']; } - if ($email = $attendee['email']) { - $result['organizerEmail'] = $email; + if (!empty($attendee['email'])) { + $result['organizerEmail'] = $attendee['email']; } unset($event['attendees'][$idx]); @@ -357,25 +358,23 @@ } /** - * convert contact from xml to libkolab array + * Convert an event from xml to libkolab array * - * @param Syncroton_Model_IEntry $data Contact to convert - * @param string $folderid Folder identifier - * @param array $entry Existing entry - * @param DateTimeZone $timezone Timezone of the event + * @param Syncroton_Model_Event|Syncroton_Model_EventException $data Event or event exception to convert + * @param string $folderid Folder identifier + * @param array $entry Existing entry + * @param DateTimeZone $timezone Timezone of the event * * @return array */ - public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null) + public function toKolab($data, $folderid, $entry = null, $timezone = null) { - $foldername = isset($entry['_mailbox']) ? $entry['_mailbox'] : $this->getFolderName($folderid); - if (empty($entry)) { + if (empty($entry) && !empty($data->uID)) { // If we don't have an existing event (not a modification) we nevertheless check for conflicts. // This is necessary so we don't overwrite the server-side copy in case the client did not have it available // when generating an Add command. try { - $folder = $this->getFolderObject($foldername); - $entry = $folder->get_object($data->uID); + $entry = $this->getObject($folderid, $data->uID); if ($entry) { $this->logger->debug('Found and existing event for UID: ' . $data->uID); @@ -384,8 +383,9 @@ // uID is not available on exceptions, so we guard for that and silently ignore. } } - $event = !empty($entry) ? $entry : array(); - $config = $this->getFolderConfig($foldername); + + $config = $this->getFolderConfig($entry ? $entry['folderId'] : $folderid); + $event = !empty($entry) ? $entry : []; $is_exception = $data instanceof Syncroton_Model_EventException; $dummy_tz = str_repeat('A', 230) . '=='; $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; @@ -502,7 +502,7 @@ // Reminder // @TODO: should alarms be used when importing event from phone? - if ($config['ALARMS']) { + if (!empty($config['ALARMS'])) { $event['valarms'] = $this->to_kolab_alarm($data->reminder, $event); } @@ -636,12 +636,13 @@ // Unfortunately Outlook also sends an update when no SEQUENCE bump // is needed, e.g. when updating attendee status. // We try our best to bump the SEQUENCE only when expected + // @phpstan-ignore-next-line if (!empty($entry) && !$is_exception && !empty($data->attendees) && $data->timezone != $dummy_tz) { if ($last_update = $this->getKolabDataItem($event, self::KEY_DTSTAMP)) { $last_update = new DateTime($last_update); } - if ($data->dtStamp && $data->dtStamp != $last_update) { + if (!empty($data->dtStamp) && $data->dtStamp != $last_update) { if ($this->has_significant_changes($event, $entry)) { $event['sequence']++; $this->logger->debug('Found significant changes in the updated event. Bumping SEQUENCE to ' . $event['sequence']); @@ -652,7 +653,7 @@ // Because we use last event modification time above, we make sure // the event modification time is not (re)set by the server, // we use the original Outlook's timestamp. - if ($is_outlook && $data->dtStamp) { + if ($is_outlook && !empty($data->dtStamp)) { $this->setKolabDataItem($event, self::KEY_DTSTAMP, $data->dtStamp->format(DateTime::ATOM)); } @@ -679,9 +680,14 @@ 3 => 'DECLINED', ); - if ($status = $status_map[$request->userResponse]) { - // extract event from the invitation - list($event, $existing) = $this->get_event_from_invitation($request); + $status = $status_map[$request->userResponse] ?? null; + + if (empty($status)) { + throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); + } + + // extract event from the invitation + list($event, $existing) = $this->get_event_from_invitation($request); /* switch ($status) { case 'ACCEPTED': $event['free_busy'] = 'busy'; break; @@ -689,58 +695,55 @@ case 'DECLINED': $event['free_busy'] = 'free'; break; } */ - // Store response timestamp for further use - $reply_time = new DateTime('now', new DateTimeZone('UTC')); - $this->setKolabDataItem($event, self::KEY_REPLYTIME, $reply_time->format('Ymd\THis\Z')); - - // Update/Save the event - if (empty($existing)) { - $folder = $this->save_event($event, $status); - - // Create SyncState for the new event, so it is not synced twice - if ($folder) { - $folderId = $this->getFolderId($folder); - - try { - $syncBackend = Syncroton_Registry::getSyncStateBackend(); - $folderBackend = Syncroton_Registry::getFolderBackend(); - $contentBackend = Syncroton_Registry::getContentStateBackend(); - $syncFolder = $folderBackend->getFolder($this->device->id, $folderId); - $syncState = $syncBackend->getSyncState($this->device->id, $syncFolder->id); - - $contentBackend->create(new Syncroton_Model_Content(array( - 'device_id' => $this->device->id, - 'folder_id' => $syncFolder->id, - 'contentid' => $this->serverId($event['uid'], $folder), - 'creation_time' => $syncState->lastsync, - 'creation_synckey' => $syncState->counter, - ))); - } - catch (Exception $e) { - // ignore - } + // Store response timestamp for further use + $reply_time = new DateTime('now', new DateTimeZone('UTC')); + $this->setKolabDataItem($event, self::KEY_REPLYTIME, $reply_time->format('Ymd\THis\Z')); + + // Update/Save the event + if (empty($existing)) { + $folderId = $this->save_event($event, $status); + + // Create SyncState for the new event, so it is not synced twice + if ($folderId) { + try { + $syncBackend = Syncroton_Registry::getSyncStateBackend(); + $folderBackend = Syncroton_Registry::getFolderBackend(); + $contentBackend = Syncroton_Registry::getContentStateBackend(); + $syncFolder = $folderBackend->getFolder($this->device->id, $folderId); + $syncState = $syncBackend->getSyncState($this->device->id, $syncFolder->id); + + $contentBackend->create(new Syncroton_Model_Content(array( + 'device_id' => $this->device->id, + 'folder_id' => $syncFolder->id, + 'contentid' => $this->serverId($event['uid'], $folderId), + 'creation_time' => $syncState->lastsync, + 'creation_synckey' => $syncState->counter, + ))); + } + catch (Exception $e) { + // ignore } } - else { - $folder = $this->update_event($event, $existing, $status, $request->instanceId); - } + } + else { + $folderId = $this->update_event($event, $existing, $status, $request->instanceId); + } - if (!$folder) { - throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); - } + if (!$folderId) { + throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); + } - // TODO: ActiveSync version >= 16, send the iTip response. - if (isset($request->sendResponse)) { - // SendResponse can contain Body to use as email body (can be empty) - // TODO: Activesync >= 16.1 proposedStartTime and proposedEndTime. - } + // TODO: ActiveSync version >= 16, send the iTip response. + if (isset($request->sendResponse)) { + // SendResponse can contain Body to use as email body (can be empty) + // TODO: Activesync >= 16.1 proposedStartTime and proposedEndTime. } // FIXME: We should not return an UID when status=DECLINED // as it's expected by the specification. Server // should delete an event in such a case, but we // keep the event copy with appropriate attendee status instead. - return empty($status) ? null : $this->serverId($event['uid'], $folder); + return $this->serverId($event['uid'], $folderId); } /** @@ -762,7 +765,7 @@ } // Event from calendar folder - if ($event = $this->getObject($request->collectionId, $request->requestId, $folder)) { + if ($event = $this->getObject($request->collectionId, $request->requestId)) { return array($event, $event); } @@ -783,12 +786,17 @@ // TODO: should we check every existing event folder even if not subscribed for sync? - foreach ($this->listFolders() as $folder) { - $storage_folder = $this->getFolderObject($folder['imap_name']); - if ($storage_folder->get_namespace() == 'personal' - && ($result = $storage_folder->get_object($uid)) - ) { - return $result; + if ($folders = $this->listFolders()) { + foreach ($folders as $_folder) { + $folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName); + + if ($folder + && $folder->get_namespace() == 'personal' + && ($result = $this->backend->getItem($_folder['serverId'], $this->device->deviceid, $this->modelName, $uid)) + ) { + $result['folderId'] = $_folder['serverId']; + return $result; + } } } } @@ -809,7 +817,7 @@ // Updating an existing event is most-likely a response // to an iTip request with bumped SEQUENCE - $old['sequence'] += 1; + $old['sequence'] = ($old['sequence'] ?? 0) + 1; // Copy new custom properties if (!empty($event['x-custom'])) { @@ -828,25 +836,23 @@ */ protected function save_event(&$event, $status = null) { - // Find default folder to which we'll save the event - if (!isset($event['_mailbox'])) { - $folders = $this->listFolders(); - $storage = rcube::get_instance()->get_storage(); - - // find the default - foreach ($folders as $folder) { - if ($folder['type'] == 8 && $storage->folder_namespace($folder['imap_name']) == 'personal') { - $event['_mailbox'] = $folder['imap_name']; - break; - } - } - - // if there's no folder marked as default, use any - if (!isset($event['_mailbox']) && !empty($folders)) { - foreach ($folders as $folder) { - if ($storage->folder_namespace($folder['imap_name']) == 'personal') { - $event['_mailbox'] = $folder['imap_name']; - break; + $first = null; + $default = null; + + if (!isset($event['folderId'])) { + // Find the folder to which we'll save the event + if ($folders = $this->listFolders()) { + foreach ($folders as $_folder) { + $folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName); + + if ($folder && $folder->get_namespace() == 'personal') { + if ($_folder['type'] == 8) { + $default = $_folder['serverId']; + break; + } + if (!$first) { + $first = $_folder['serverId']; + } } } } @@ -861,12 +867,12 @@ // TODO: Free/busy trigger? - if (isset($event['_mailbox'])) { - $folder = $this->getFolderObject($event['_mailbox']); + $old_uid = isset($event['folderId']) ? $event['uid'] : null; + $folder_id = $event['folderId'] ?? ($default ?? $first); + $folder = $this->backend->getFolder($folder_id, $this->device->deviceid, $this->modelName); - if ($folder && $folder->valid && $folder->save($event)) { - return $folder; - } + if (!empty($folder) && $folder->valid && $folder->save($event, $this->modelName, $old_uid)) { + return $folder_id; } return false; @@ -917,7 +923,7 @@ * * @param int $filter_type Filter type * - * @param array Filter query + * @return array Filter query */ protected function filter($filter_type = 0) { diff --git a/lib/kolab_sync_data_contacts.php b/lib/kolab_sync_data_contacts.php --- a/lib/kolab_sync_data_contacts.php +++ b/lib/kolab_sync_data_contacts.php @@ -209,13 +209,13 @@ /** * convert contact from xml to libkolab array * - * @param Syncroton_Model_IEntry $data Contact to convert - * @param string $folderId Folder identifier - * @param array $entry Existing entry + * @param Syncroton_Model_Contact $data Contact to convert + * @param string $folderId Folder identifier + * @param array $entry Existing entry * * @return array Kolab object array */ - public function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null) + public function toKolab($data, $folderId, $entry = null) { $contact = !empty($entry) ? $entry : array(); @@ -347,7 +347,7 @@ /** * Empty folder (remove all entries and optionally subfolders) * - * @param string $folderId Folder identifier + * @param string $folderid Folder identifier * @param array $options Options */ public function emptyFolderContents($folderid, $options) @@ -406,9 +406,9 @@ /** * update existing entry * - * @param string $folderId - * @param string $serverId - * @param SimpleXMLElement $entry + * @param string $folderId + * @param string $serverId + * @param Syncroton_Model_IEntry $entry * * @return string ID of the updated entry */ @@ -442,7 +442,7 @@ * * @param int $filter_type Filter type * - * @param array Filter query + * @return array Filter query */ protected function filter($filter_type = 0) { @@ -495,13 +495,13 @@ /** * Fetches the entry from the backend */ - protected function getObject($folderid, $entryid, &$folder = null) + protected function getObject($folderid, $entryid) { if (strpos($entryid, $this->galPrefix) === 0 && $this->hasGAL()) { return $this->getGALEntry($entryid); } - return parent::getObject($folderid, $entryid, $folder); + return parent::getObject($folderid, $entryid); } /** @@ -537,7 +537,7 @@ $book->set_pagesize(10000); $set = $book->list_records(); - while ($contact = $set->next()) { + foreach ($set as $contact) { $result[] = $this->createGALEntryUID($contact, $source['id']); } } @@ -555,7 +555,7 @@ * * @param string $serverId Entry identifier * - * @return array Contact data + * @return array|null Contact data */ protected function getGALEntry($serverId) { @@ -577,6 +577,8 @@ return $result; } } + + return null; } /** diff --git a/lib/kolab_sync_data_email.php b/lib/kolab_sync_data_email.php --- a/lib/kolab_sync_data_email.php +++ b/lib/kolab_sync_data_email.php @@ -93,8 +93,7 @@ */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED; - private $lastsync_folder = null; - private $lastsync_time = null; + protected $storage; /** @@ -111,10 +110,6 @@ // Outlook 2013 support multi-folder $this->ext_devices[] = 'windowsoutlook15'; - - if ($this->asversion >= 14) { - $this->tag_categories = true; - } } /** @@ -151,7 +146,7 @@ /** * 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 the encoded globalObjId + * @param string $globalObjId The encoded globalObjId * * @return array An array with the decoded data */ @@ -468,10 +463,7 @@ } // Categories (Tags) - if (isset($this->tag_categories) && $this->tag_categories) { - // convert kolab tags into categories - $result['categories'] = $this->getKolabTags($message); - } + $result['categories'] = $message->headers->others['categories'] ?? []; $is_ios = preg_match('/(iphone|ipad)/i', $this->device->devicetype); @@ -533,17 +525,18 @@ } /** - * convert contact from xml to libkolab array + * convert email from xml to libkolab array * - * @param Syncroton_Model_IEntry $data Contact to convert - * @param string $folderid Folder identifier - * @param array $entry Existing entry + * @param Syncroton_Model_Email $data Email to convert + * @param string $folderid Folder identifier + * @param array $entry Existing entry * * @return array */ - public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null) + public function toKolab($data, $folderid, $entry = null) { // does nothing => you can't add emails via ActiveSync + return []; } /** @@ -551,7 +544,7 @@ * * @param int $filter_type Filter type * - * @param array Filter query + * @return array Filter query */ protected function filter($filter_type = 0) { @@ -629,6 +622,8 @@ /** * Return list of folders for specified folder ID * + * @param string $folder_id Folder identifier + * * @return array Folder identifiers list */ protected function extractFolders($folder_id) @@ -673,29 +668,17 @@ */ public function moveItem($srcFolderId, $serverId, $dstFolderId) { - $msg = $this->parseMessageId($serverId); - $dest = $this->extractFolders($dstFolderId); - $dest_id = array_shift($dest); - $dest_name = $this->backend->folder_id2name($dest_id, $this->device->deviceid); + $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); } - if ($dest_name === null) { - throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); - } + $uid = $this->backend->moveItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], $dest_id); - if (!$this->storage->move_message($msg['uid'], $dest_name, $msg['foldername'])) { - throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); - } - - // Use COPYUID feature (RFC2359) to get the new UID of the copied message - $copyuid = isset($this->storage->conn->data['COPYUID']) ? $this->storage->conn->data['COPYUID'] : null; - - if (is_array($copyuid) && ($uid = $copyuid[1])) { - return $this->createMessageId($dest_id, $uid); - } + return $uid ? $this->serverId($uid, $dest_id) : null; } /** @@ -708,18 +691,15 @@ */ public function createEntry($folderId, Syncroton_Model_IEntry $entry) { - // Creating emails is not normally supported like this, but is implemented for testing purposes - $foldername = $this->backend->folder_id2name($folderId, $this->device->deviceid); + $params = ['flags' => [!empty($entry->read) ? 'SEEN' : 'UNSEEN']]; - $flag = !empty($entry->read) ? 'SEEN' : 'UNSEEN'; - $uid = $this->storage->save_message($foldername, $entry->body->data, '', false, [$flag]); + $uid = $this->backend->createItem($folderId, $this->device->deviceid, $this->modelName, $entry->body->data, $params); if (!$uid) { - $this->logger->error("Error while storing the message " . $this->storage->get_error_str()); throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } - return $this->createMessageId($folderId, $uid); + return $this->serverId($uid, $folderId); } /** @@ -737,36 +717,30 @@ throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); } - if (isset($entry->categories)) { - // Read the message headers only when they are needed - $message = $this->getObject($serverId); + $params = ['flags' => []]; - if (empty($message)) { - throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); - } + if (isset($entry->categories)) { + $params['categories'] = $entry->categories; } // Read status change if (isset($entry->read)) { - // here we update only Read flag - $flag = !empty($entry->read) ? 'SEEN' : 'UNSEEN'; - $this->storage->set_flag($msg['uid'], $flag, $msg['foldername']); + $params['flags'][] = !empty($entry->read) ? 'SEEN' : 'UNSEEN'; } // Flag change if (isset($entry->flag)) { if (empty($entry->flag) || empty($entry->flag->flagType)) { - $this->storage->set_flag($msg['uid'], 'UNFLAGGED', $msg['foldername']); + $params['flags'][] = 'UNFLAGGED'; } else if (preg_match('/follow\s*up/i', $entry->flag->flagType)) { - $this->storage->set_flag($msg['uid'], 'FLAGGED', $msg['foldername']); + $params['flags'][] = 'FLAGGED'; } } - // Categories (Tags) change - if (isset($entry->categories)) { - $this->setKolabTags($message, $entry->categories); - } + $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); + + return $serverId; } /** @@ -786,25 +760,12 @@ } // Note: If DeletesAsMoves is not specified in the request, its default is 1 (true). + $moveToTrash = !isset($collection->deletesAsMoves) || !empty($collection->deletesAsMoves); - // move message to trash folder - if ((!isset($collection->deletesAsMoves) || !empty($collection->deletesAsMoves)) - && strlen($trash) - && $trash != $msg['foldername'] - && $this->storage->folder_exists($trash) - ) { - $this->storage->move_message($msg['uid'], $trash, $msg['foldername']); - } - // delete the message - else { - // According to the ActiveSync spec. "If the DeletesAsMoves element is set to false, - // the deletion is PERMANENT.", therefore we delete the message, and not flag as deleted. - $this->storage->delete_message($msg['uid'], $msg['foldername']); + $deleted = $this->backend->deleteItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], $moveToTrash); - // FIXME: We could consider acting according to the 'flag_for_deletion' setting. - // Don't forget about 'read_when_deleted' setting then. - // $this->storage->set_flag($msg['uid'], 'DELETED', $msg['foldername']); - // $this->storage->set_flag($msg['uid'], 'SEEN', $msg['foldername']); + if (!$deleted) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); } } @@ -876,7 +837,7 @@ // forward original message as attachment if (!$replaceMime) { - $this->storage->set_folder($msg['foldername']); + $this->storage->set_folder($message->folder); $attachment = $this->storage->get_raw_body($msg['uid']); if (empty($attachment)) { @@ -896,7 +857,8 @@ // Set FORWARDED flag on the replied message if (empty($message->headers->flags['FORWARDED'])) { - $this->storage->set_flag($msg['uid'], 'FORWARDED', $msg['foldername']); + $params = ['flags' => ['FORWARDED']]; + $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); } } @@ -946,245 +908,9 @@ // Set ANSWERED flag on the replied message if (empty($message->headers->flags['ANSWERED'])) { - $this->storage->set_flag($msg['uid'], 'ANSWERED', $msg['foldername']); - } - } - - /** - * Search for existing entries - * - * @param string $folderid - * @param array $filter - * @param int $result_type Type of the result (see RESULT_* constants) - * - * @return array|int Search result as count or array of uids/objects - */ - protected function searchEntries($folderid, $filter = array(), $result_type = self::RESULT_UID) - { - $folders = $this->extractFolders($folderid); - $filter_str = 'ALL UNDELETED'; - - // convert filter into one IMAP search string - foreach ($filter as $idx => $filter_item) { - if (is_array($filter_item)) { - // This is a request for changes since last time - // we'll use HIGHESTMODSEQ value from the last Sync - if ($filter_item[0] == 'changed' && $filter_item[1] == '>') { - $modseq_lasttime = $filter_item[2]; - $modseq_data = array(); - $modseq = (array) $this->backend->modseq_get($this->device->id, $folderid, $modseq_lasttime); - } - } - else { - $filter_str .= ' ' . $filter_item; - } - } - - // get members of modified relations - $changed_msgs = $this->getChangesByRelations($folderid, $filter); - - $result = $result_type == self::RESULT_COUNT ? 0 : array(); - $found = 0; - $ts = time(); - - foreach ($folders as $folder_id) { - $foldername = $this->backend->folder_id2name($folder_id, $this->device->deviceid); - - if ($foldername === null) { - continue; - } - - $found++; - - $this->storage->set_folder($foldername); - - // Synchronize folder (if it wasn't synced in this request already) - if ($this->lastsync_folder != $folderid - || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout() - ) { - $this->storage->folder_sync($foldername); - } - - // We're in "get changes" mode - if (isset($modseq_data)) { - $folder_data = $this->storage->folder_data($foldername); - $modified = false; - - // If previous HIGHESTMODSEQ doesn't exist we can't get changes - // We can only get folder's HIGHESTMODSEQ value and store it for the next try - // Skip search if HIGHESTMODSEQ didn't change - if (!empty($folder_data['HIGHESTMODSEQ'])) { - $modseq_data[$foldername] = $folder_data['HIGHESTMODSEQ']; - $modseq_old = isset($modseq[$foldername]) ? $modseq[$foldername] : null; - if ($modseq_data[$foldername] != $modseq_old) { - $modseq_update = true; - if ($modseq && $modseq_old) { - $modified = true; - $filter_str .= " MODSEQ " . ($modseq_old + 1); - } - } - } - } - else { - $modified = true; - } - - // We could use messages cache by replacing search() with index() - // in some cases. This however is possible only if user has skip_deleted=true, - // in his Roundcube preferences, otherwise we'd make often cache re-initialization, - // because Roundcube message cache can work only with one skip_deleted - // setting at a time. We'd also need to make sure folder_sync() was called - // before (see above). - // - // if ($filter_str == 'ALL UNDELETED') - // $search = $this->storage->index($foldername, null, null, true, true); - // else - - if ($modified) { - $search = $this->storage->search_once($foldername, $filter_str); - - if (!($search instanceof rcube_result_index) || $search->is_error()) { - throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); - } - - switch ($result_type) { - case self::RESULT_COUNT: - $result += (int) $search->count(); - break; - - case self::RESULT_UID: - if ($uids = $search->get()) { - foreach ($uids as $idx => $uid) { - $uids[$idx] = $this->createMessageId($folder_id, $uid); - } - $result = array_merge($result, $uids); - } - break; - } - } - - // handle relation changes - if (!empty($changed_msgs)) { - $uids = $this->findRelationMembersInFolder($foldername, $changed_msgs, $filter); - - switch ($result_type) { - case self::RESULT_COUNT: - $result += (int) count($uids); - break; - - case self::RESULT_UID: - foreach ($uids as $idx => $uid) { - $uids[$idx] = $this->createMessageId($folder_id, $uid); - } - $result = array_unique(array_merge($result, $uids)); - break; - } - } - } - - if (!$found) { - throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); - } - - $this->lastsync_folder = $folderid; - $this->lastsync_time = $ts; - - if (!empty($modseq_update)) { - $this->backend->modseq_set($this->device->id, $folderid, - $this->syncTimeStamp, $modseq_data); - - // if previous modseq information does not exist save current set as it, - // we would at least be able to detect changes since now - if (empty($result) && empty($modseq)) { - $this->backend->modseq_set($this->device->id, $folderid, - $modseq_lasttime, $modseq_data); - } - } - - return $result; - } - - /** - * Find members (messages) in specified folder - */ - protected function findRelationMembersInFolder($foldername, $members, $filter) - { - foreach ($members as $member) { - // IMAP URI members - if ($url = kolab_storage_config::parse_member_url($member)) { - $result[$url['folder']][$url['uid']] = $url['params']; - } - } - - // convert filter into one IMAP search string - $filter_str = 'ALL UNDELETED'; - foreach ($filter as $filter_item) { - if (is_string($filter_item)) { - $filter_str .= ' ' . $filter_item; - } + $params = ['flags' => ['ANSWERED']]; + $this->backend->updateItem($msg['folderId'], $this->device->deviceid, $this->modelName, $msg['uid'], null, $params); } - - $rcube = rcube::get_instance(); - $storage = $rcube->get_storage(); - $found = array(); - - // first find messages by UID - if (!empty($result[$foldername])) { - $index = $storage->search_once($foldername, 'UID ' - . rcube_imap_generic::compressMessageSet(array_keys($result[$foldername]))); - $found = $index->get(); - - // remove found messages from the $result - if (!empty($found)) { - $result[$foldername] = array_diff_key($result[$foldername], array_flip($found)); - - if (empty($result[$foldername])) { - unset($result[$foldername]); - } - - // now apply the current filter to the found messages - $index = $storage->search_once($foldername, $filter_str . ' UID ' - . rcube_imap_generic::compressMessageSet($found)); - $found = $index->get(); - } - } - - // search by message parameters - if (!empty($result)) { - // @TODO: do this search in chunks (for e.g. 25 messages)? - $search = ''; - $search_count = 0; - - foreach ($result as $data) { - foreach ($data as $p) { - $search_params = array(); - $search_count++; - - foreach ($p as $key => $val) { - $key = strtoupper($key); - // don't search by subject, we don't want false-positives - if ($key != 'SUBJECT') { - $search_params[] = 'HEADER ' . $key . ' ' . rcube_imap_generic::escape($val); - } - } - - $search .= ' (' . implode(' ', $search_params) . ')'; - } - } - - $search_str = str_repeat(' OR', $search_count-1) . $search; - - // search messages in current folder - $search = $storage->search_once($foldername, $search_str); - $uids = $search->get(); - - if (!empty($uids)) { - // add UIDs into the result - $found = array_unique(array_merge($found, $uids)); - } - } - - return $found; } /** @@ -1229,7 +955,7 @@ $uids = $search->get(); foreach ($uids as $idx => $uid) { $uids[$idx] = new Syncroton_Model_StoreResponseResult(array( - 'longId' => $this->createMessageId($folderid, $uid), + 'longId' => $this->serverId($uid, $folderid), 'collectionId' => $folderid, 'class' => 'Email', )); @@ -1290,10 +1016,6 @@ return array(); } - if (isset($query['and']['freeText']) && strlen($query['and']['freeText'])) { - $search = $query['and']['freeText']; - } - if (!empty($query['and']['collections'])) { foreach ($query['and']['collections'] as $collection) { $folders = array_merge($folders, $this->extractFolders($collection)); @@ -1314,17 +1036,18 @@ $search_str .= ' BEFORE ' . $query['and']['lessThan']['value']->format('d-M-Y'); } - if ($search !== null) { - // @FIXME: should we use TEXT/BODY search? - // ActiveSync protocol specification says "indexed fields" + 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 = array('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 (empty($search_str)) { + if (!strlen($search_str)) { return array(); } @@ -1347,7 +1070,7 @@ /** * Fetches the entry from the backend */ - protected function getObject($entryid, $dummy = null, &$folder = null) + protected function getObject($entryid, $dummy = null) { $message = $this->parseMessageId($entryid); @@ -1356,10 +1079,7 @@ return null; } - // get message - $message = new rcube_message($message['uid'], $message['foldername']); - - return $message && !empty($message->headers) ? $message : null; + return $this->backend->getItem($message['folderId'], $this->device->deviceid, $this->modelName, $message['uid']); } /** @@ -1401,29 +1121,25 @@ // Note: the id might be in a form of ::[::] list($folderid, $uid) = explode('::', $entryid); - $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid); - - if ($foldername === null || $foldername === false) { - return; - } - - return array( - 'uid' => $uid, - 'folderid' => $folderid, - 'foldername' => $foldername, - ); + return [ + 'uid' => $uid, + 'folderId' => $folderid, + ]; } /** * Creates entry ID of the message */ - public function createMessageId($folderid, $uid) + 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) { @@ -1447,6 +1163,10 @@ /** * 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) { @@ -1543,106 +1263,6 @@ return mb_strcut(trim($body), 0, $size); } - /** - * Returns list of tag names assigned to an email message - */ - protected function getKolabTags($message, $dummy = null) - { - // support only messages with message-id - if (!($msg_id = $message->headers->get('message-id', false))) { - return null; - } - - $config = kolab_storage_config::get_instance(); - $delta = Syncroton_Registry::getPingTimeout(); - $folder = $message->folder; - $uid = $message->uid; - - // get tag objects raleted to specified message-id - $tags = $config->get_tags($msg_id); - - foreach ($tags as $idx => $tag) { - // resolve members if it wasn't done recently - $force = empty($this->tag_rts[$tag['uid']]) || $this->tag_rts[$tag['uid']] <= time() - $delta; - $members = $config->resolve_members($tag, $force); - - if (empty($members[$folder]) || !in_array($uid, $members[$folder])) { - unset($tags[$idx]); - } - - if ($force) { - $this->tag_rts[$tag['uid']] = time(); - } - } - - $tags = array_filter(array_map(function($v) { return $v['name']; }, $tags)); - - // make sure current folder is set correctly again - $this->storage->set_folder($folder); - - return !empty($tags) ? $tags : null; - } - - /** - * Set tags to an email message - */ - protected function setKolabTags($message, $tags) - { - $config = kolab_storage_config::get_instance(); - $delta = Syncroton_Registry::getPingTimeout(); - $folder = $message->folder; - $uri = kolab_storage_config::get_message_uri($message->headers, $folder); - - // for all tag objects... - foreach ($config->get_tags() as $relation) { - // resolve members if it wasn't done recently - $uid = $relation['uid']; - $force = empty($this->tag_rts[$uid]) || $this->tag_rts[$uid] <= time() - $delta; - - if ($force) { - $config->resolve_members($relation, $force); - $this->tag_rts[$relation['uid']] = time(); - } - - $selected = !empty($tags) && in_array($relation['name'], $tags); - $found = !empty($relation['members']) && in_array($uri, $relation['members']); - $update = false; - - // remove member from the relation - if ($found && !$selected) { - $relation['members'] = array_diff($relation['members'], (array) $uri); - $update = true; - } - // add member to the relation - else if (!$found && $selected) { - $relation['members'][] = $uri; - $update = true; - } - - if ($update) { - $config->save($relation, 'relation'); - } - - $tags = array_diff($tags, (array) $relation['name']); - } - - // create new relations - if (!empty($tags)) { - foreach ($tags as $tag) { - $relation = array( - 'name' => $tag, - 'members' => (array) $uri, - 'category' => 'tag', - ); - - $config->save($relation, 'relation'); - } - } - - // make sure current folder is set correctly again - $this->storage->set_folder($folder); - } - public static function charset_to_cp($charset) { // @TODO: ????? @@ -1838,9 +1458,11 @@ private function mem_check($need) { - $mem_limit = parse_bytes(ini_get('memory_limit')); + $mem_limit = (int) parse_bytes(ini_get('memory_limit')); $memory = static::$memory_accumulated; - return $mem_limit > 0 && $memory + $need > $mem_limit ? false : true; + + // @phpstan-ignore-next-line + return ($mem_limit > 0 && $memory + $need > $mem_limit) ? false : true; } /** diff --git a/lib/kolab_sync_data_gal.php b/lib/kolab_sync_data_gal.php --- a/lib/kolab_sync_data_gal.php +++ b/lib/kolab_sync_data_gal.php @@ -103,8 +103,9 @@ // Use configured fields mapping $rcube = rcube::get_instance(); $fieldmap = (array) $rcube->config->get('activesync_gal_fieldmap'); + if (!empty($fieldmap)) { - $fieldmap = array_intersec_key($fieldmap, array_keys($this->mapping)); + $fieldmap = array_intersect_key($fieldmap, array_keys($this->mapping)); $this->mapping = array_merge($this->mapping, $fieldmap); } } @@ -112,8 +113,9 @@ /** * Not used but required by parent class */ - public function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null) + public function toKolab($data, $folderId, $entry = null) { + return []; } /** @@ -121,6 +123,7 @@ */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId) { + return []; } /** @@ -220,7 +223,7 @@ // get records $result = $book->list_records(); - while ($row = $result->next()) { + foreach ($result as $row) { $row['sourceid'] = $idx; // make sure 'email' item is there, convert all email:* into one @@ -282,7 +285,7 @@ * * @param string $id Address book identifier * - * @return rcube_contacts Address book object + * @return rcube_addressbook Address book object */ public static function get_address_book($id) { @@ -298,7 +301,7 @@ $config->mail_domain($_SESSION['storage_host'])); } - if (!$book) { + if (empty($book)) { rcube::raise_error(array( 'code' => 700, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, diff --git a/lib/kolab_sync_data_notes.php b/lib/kolab_sync_data_notes.php --- a/lib/kolab_sync_data_notes.php +++ b/lib/kolab_sync_data_notes.php @@ -67,11 +67,6 @@ */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_NOTE_USER_CREATED; - /** - * Enable mapping Activesync categories into Kolab tags (relations) - */ - protected $tag_categories = true; - /** * Appends note data to xml element @@ -85,7 +80,6 @@ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false) { $note = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); -// $config = $this->getFolderConfig($note['_mailbox']); $result = array(); // Calendar namespace fields @@ -111,26 +105,21 @@ $result['messageClass'] = 'IPM.StickyNote'; - // convert kolab tags into categories - $result['categories'] = $this->getKolabTags($note['uid'], $result['categories']); - return $as_array ? $result : new Syncroton_Model_Note($result); } /** * convert note from xml to libkolab array * - * @param Syncroton_Model_IEntry $data Note to convert - * @param string $folderid Folder identifier - * @param array $entry Existing entry + * @param Syncroton_Model_Note $data Note to convert + * @param string $folderid Folder identifier + * @param array $entry Existing entry * * @return array */ - public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null) + public function toKolab($data, $folderid, $entry = null) { - $note = !empty($entry) ? $entry : array(); - $foldername = isset($note['_mailbox']) ? $note['_mailbox'] : $this->getFolderName($folderid); -// $config = $this->getFolderConfig($foldername); + $note = !empty($entry) ? $entry : array(); // Calendar namespace fields foreach ($this->mapping as $key => $name) { diff --git a/lib/kolab_sync_data_tasks.php b/lib/kolab_sync_data_tasks.php --- a/lib/kolab_sync_data_tasks.php +++ b/lib/kolab_sync_data_tasks.php @@ -95,11 +95,6 @@ */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_TASK_USER_CREATED; - /** - * Enable mapping Activesync categories into Kolab tags (relations) - */ - protected $tag_categories = true; - /** * Appends contact data to xml element @@ -113,7 +108,6 @@ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false) { $task = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); -// $config = $this->getFolderConfig($task['_mailbox']); $result = array(); // Completion status (required) @@ -157,19 +151,12 @@ $result[$key] = $value; } - // convert kolab tags into categories - if (!empty($result['categories'])) { - $result['categories'] = $this->getKolabTags($task['uid'], $result['categories']); - } - // Recurrence $this->recurrence_from_kolab($collection, $task, $result, 'Task'); return $as_array ? $result : new Syncroton_Model_Task($result); } - - /** * Apply a timezone matching the utc offset. */ @@ -188,17 +175,15 @@ /** * convert contact from xml to libkolab array * - * @param Syncroton_Model_IEntry $data Contact to convert - * @param string $folderid Folder identifier - * @param array $entry Existing entry + * @param Syncroton_Model_Task $data Contact to convert + * @param string $folderid Folder identifier + * @param array $entry Existing entry * * @return array */ - public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null) + public function toKolab($data, $folderid, $entry = null) { - $task = !empty($entry) ? $entry : array(); - $foldername = isset($task['_mailbox']) ? $task['_mailbox'] : $this->getFolderName($folderid); -// $config = $this->getFolderConfig($foldername); + $task = !empty($entry) ? $entry : array(); $task['allday'] = 0; @@ -216,10 +201,10 @@ } if ($value) { if ($name =='due' && $data->utcDueDate) { - $value = static::applyTimezone($value, $data->utcDueDate); + $value = self::applyTimezone($value, $data->utcDueDate); } if ($name =='start' && $data->utcStartDate) { - $value = static::applyTimezone($value, $data->utcStartDate); + $value = self::applyTimezone($value, $data->utcStartDate); } } break; @@ -269,7 +254,7 @@ * * @param int $filter_type Filter type * - * @param array Filter query + * @return array Filter query */ protected function filter($filter_type = 0) { diff --git a/lib/kolab_sync_message.php b/lib/kolab_sync_message.php --- a/lib/kolab_sync_message.php +++ b/lib/kolab_sync_message.php @@ -89,7 +89,7 @@ * Adds attachment to the message * * @param string $body Attachment body (not encoded) - * @param string $params Attachment parameters (Mail_mimePart format) + * @param array $params Attachment parameters (Mail_mimePart format) */ public function add_attachment($body, $params = array()) { @@ -153,11 +153,11 @@ * @param array $smtp_error SMTP error array (reference) * @param array $smtp_opts SMTP options (e.g. DSN request) * - * @return boolean Send status. + * @return bool Send status. */ public function send(&$smtp_error = null, $smtp_opts = null) { - $rcube = rcube::get_instance(); + $rcube = kolab_sync::get_instance(); $headers = $this->headers; $mailto = $headers['To']; @@ -331,10 +331,7 @@ /** * MIME message parser * - * @param string|resource $message MIME message source - * @param bool $decode_body Enables body decoding - * - * @return array Message headers array and message body + * @param string|resource $message MIME message source */ protected function parse_mime($message) { @@ -429,7 +426,7 @@ * * @return string Encoded body */ - protected function encode($body, $encoding) + protected static function encode($body, $encoding) { switch ($encoding) { case 'base64': diff --git a/lib/kolab_sync_plugin_api.php b/lib/kolab_sync_plugin_api.php --- a/lib/kolab_sync_plugin_api.php +++ b/lib/kolab_sync_plugin_api.php @@ -44,22 +44,20 @@ return self::$instance; } - /** * Initialize plugin engine * * This has to be done after rcmail::load_gui() or rcmail::json_init() * was called because plugins need to have access to rcmail->output * - * @param object rcube Instance of the rcube base class - * @param string Current application task (used for conditional plugin loading) + * @param rcube $app Instance of the rcube base class + * @param string $task Current application task (used for conditional plugin loading) */ public function init($app, $task = '') { $this->task = $task; } - /** * Register a handler function for template objects * @@ -72,7 +70,6 @@ // empty } - /** * Register this plugin to be responsible for a specific task * @@ -84,7 +81,6 @@ $this->tasks[$task] = $owner; } - /** * Include a plugin script file in the current HTML page * @@ -95,7 +91,6 @@ //empty } - /** * Include a plugin stylesheet in the current HTML page * diff --git a/lib/kolab_sync_storage.php b/lib/kolab_sync_storage.php new file mode 100644 --- /dev/null +++ b/lib/kolab_sync_storage.php @@ -0,0 +1,2042 @@ + | + | | + | 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 | + +--------------------------------------------------------------------------+ +*/ + +/** + * Storage handling class with basic Kolab support (everything stored in IMAP) + */ +class kolab_sync_storage +{ + const INIT_SUB_PERSONAL = 1; // all subscribed folders in personal namespace + const INIT_ALL_PERSONAL = 2; // all folders in personal namespace + const INIT_SUB_OTHER = 4; // all subscribed folders in other users namespace + const INIT_ALL_OTHER = 8; // all folders in other users namespace + const INIT_SUB_SHARED = 16; // all subscribed folders in shared namespace + const INIT_ALL_SHARED = 32; // all folders in shared namespace + + const MODEL_CALENDAR = 'event'; + const MODEL_CONTACTS = 'contact'; + const MODEL_EMAIL = 'mail'; + const MODEL_NOTES = 'note'; + const MODEL_TASKS = 'task'; + + const ROOT_MAILBOX = 'INBOX'; + const ASYNC_KEY = '/private/vendor/kolab/activesync'; + const UID_KEY = '/shared/vendor/cmu/cyrus-imapd/uniqueid'; + + const CTYPE_KEY = '/shared/vendor/kolab/folder-type'; + const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type'; + + public $syncTimeStamp; + + protected $storage; + protected $folder_meta; + protected $folder_uids; + protected $folders = []; + protected $modseq = []; + protected $root_meta; + protected $relations = []; + protected $relationSupport = true; + protected $tag_rts = []; + + protected static $instance; + + protected static $types = [ + 1 => '', + 2 => 'mail.inbox', + 3 => 'mail.drafts', + 4 => 'mail.wastebasket', + 5 => 'mail.sentitems', + 6 => 'mail.outbox', + 7 => 'task.default', + 8 => 'event.default', + 9 => 'contact.default', + 10 => 'note.default', + 11 => 'journal.default', + 12 => 'mail', + 13 => 'event', + 14 => 'contact', + 15 => 'task', + 16 => 'journal', + 17 => 'note', + ]; + + + /** + * This implements the 'singleton' design pattern + * + * @return kolab_sync_storage The one and only instance + */ + public static function get_instance() + { + if (!self::$instance) { + self::$instance = new kolab_sync_storage; + self::$instance->startup(); // init AFTER object was linked with self::$instance + } + + return self::$instance; + } + + /** + * Class initialization + */ + public function startup() + { + $this->storage = kolab_sync::get_instance()->get_storage(); + + // set additional header used by libkolab + $this->storage->set_options([ + // @TODO: there can be Roundcube plugins defining additional headers, + // we maybe would need to add them here + 'fetch_headers' => 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION', + 'skip_deleted' => true, + 'threading' => false, + ]); + + // Disable paging + $this->storage->set_pagesize(999999); + } + + /** + * Clear internal cache state + */ + public function reset() + { + $this->folders = []; + } + + /** + * List known devices + * + * @return array Device list as hash array + */ + public function devices_list() + { + if ($this->root_meta === null) { + // @TODO: consider server annotation instead of INBOX + if ($meta = $this->storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) { + $this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]); + } + else { + $this->root_meta = []; + } + } + + if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) { + return $this->root_meta['DEVICE']; + } + + return []; + } + + /** + * Get list of folders available for sync + * + * @param string $deviceid Device identifier + * @param string $type Folder type + * @param bool $flat_mode Enables flat-list mode + * + * @return array|bool List of mailbox folders, False on backend failure + */ + public function folders_list($deviceid, $type, $flat_mode = false) + { + // get all folders of specified type + $folders = kolab_storage::list_folders('', '*', $type, false, $typedata); + + // get folders activesync config + $folderdata = $this->folder_meta(); + + if (!is_array($folders) || !is_array($folderdata)) { + return false; + } + + $folders_list = []; + + // check if folders are "subscribed" for activesync + foreach ($folderdata as $folder => $meta) { + if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) + || empty($meta['FOLDER'][$deviceid]['S']) + ) { + continue; + } + + // force numeric folder name to be a string (T1283) + $folder = (string) $folder; + + if (!empty($type) && !in_array($folder, $folders)) { + continue; + } + + // Activesync folder identifier (serverId) + $folder_type = !empty($typedata[$folder]) ? $typedata[$folder] : 'mail'; + $folder_id = $this->folder_id($folder, $folder_type); + + $folders_list[$folder_id] = $this->folder_data($folder, $folder_type); + } + + if ($flat_mode) { + $folders_list = $this->folders_list_flat($folders_list, $type, $typedata); + } + + return $folders_list; + } + + /** + * Converts list of folders to a "flat" list + */ + protected function folders_list_flat($folders, $type, $typedata) + { + $delim = $this->storage->get_hierarchy_delimiter(); + + foreach ($folders as $idx => $folder) { + if ($folder['parentId']) { + // for non-mail folders we make the list completely flat + if ($type != self::MODEL_EMAIL) { + $display_name = kolab_storage::object_name($folder['imap_name']); + $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); + + $folders[$idx]['parentId'] = 0; + $folders[$idx]['displayName'] = $display_name; + } + // for mail folders we modify only folders with non-existing parents + else if (!isset($folders[$folder['parentId']])) { + $items = explode($delim, $folder['imap_name']); + $parent = 0; + + // find existing parent + while (count($items) > 0) { + array_pop($items); + + $parent_name = implode($delim, $items); + $parent_type = !empty($typedata[$parent_name]) ? $typedata[$parent_name] : 'mail'; + $parent_id = $this->folder_id($parent_name, $parent_type); + + if (isset($folders[$parent_id])) { + $parent = $parent_id; + break; + } + } + + if (!$parent) { + $display_name = kolab_storage::object_name($folder['imap_name']); + $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET); + } + else { + $parent_name = isset($parent_id) ? $folders[$parent_id]['imap_name'] : ''; + $display_name = substr($folder['imap_name'], strlen($parent_name) + 1); + $display_name = rcube_charset::convert($display_name, 'UTF7-IMAP'); + $display_name = str_replace($delim, ' ยป ', $display_name); + } + + $folders[$idx]['parentId'] = $parent; + $folders[$idx]['displayName'] = $display_name; + } + } + } + + return $folders; + } + + /** + * Getter for folder metadata + * + * @return array|bool Hash array with meta data for each folder, False on backend failure + */ + protected function folder_meta() + { + if (!isset($this->folder_meta)) { + // get folders activesync config + $folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY); + + if (!is_array($folderdata)) { + return $this->folder_meta = false; + } + + $this->folder_meta = []; + + foreach ($folderdata as $folder => $meta) { + if (isset($meta[self::ASYNC_KEY])) { + if ($metadata = $this->unserialize_metadata($meta[self::ASYNC_KEY])) { + $this->folder_meta[$folder] = $metadata; + } + } + } + } + + return $this->folder_meta; + } + + /** + * Creates folder and subscribes to the device + * + * @param string $name Folder name (UTF8) + * @param int $type Folder (ActiveSync) type + * @param string $deviceid Device identifier + * @param ?string $parentid Parent folder id identifier + * + * @return string|false New folder identifier on success, False on failure + */ + public function folder_create($name, $type, $deviceid, $parentid = null) + { + $parent = null; + $name = rcube_charset::convert($name, kolab_sync::CHARSET, 'UTF7-IMAP'); + + if ($parentid) { + $parent = $this->folder_id2name($parentid, $deviceid); + + if ($parent === null) { + throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); + } + } + + if ($parent !== null) { + $delim = $this->storage->get_hierarchy_delimiter(); + $name = $parent . $delim . $name; + } + + if ($this->storage->folder_exists($name)) { + throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::FOLDER_EXISTS); + } + + $type = self::type_activesync2kolab($type); + $created = kolab_storage::folder_create($name, $type, true); + + if ($created) { + // Set ActiveSync subscription flag + $this->folder_set($name, $deviceid, 1); + + return $this->folder_id($name, $type); + } + + // Special case when client tries to create a subfolder of INBOX + // which is not possible on Cyrus-IMAP (T2223) + if ($parent === 'INBOX' && stripos($this->last_error(), 'invalid') !== false) { + throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER); + } + + return false; + } + + /** + * Renames a folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $new_name New folder name (UTF8) + * @param ?string $parentid Folder parent identifier + * + * @return bool True on success, False on failure + */ + public function folder_rename($folderid, $deviceid, $new_name, $parentid) + { + $old_name = $this->folder_id2name($folderid, $deviceid); + + if ($parentid) { + $parent = $this->folder_id2name($parentid, $deviceid); + } + + $name = rcube_charset::convert($new_name, kolab_sync::CHARSET, 'UTF7-IMAP'); + + if (isset($parent)) { + $delim = $this->storage->get_hierarchy_delimiter(); + $name = $parent . $delim . $name; + } + + // Rename/move IMAP folder + if ($name === $old_name) { + return true; + } + + $this->folder_meta = null; + + // TODO: folder type change? + + $type = kolab_storage::folder_type($old_name); + + // don't use kolab_storage for moving mail folders + if (preg_match('/^mail/', $type)) { + return $this->storage->rename_folder($old_name, $name); + } + else { + return kolab_storage::folder_rename($old_name, $name); + } + } + + /** + * Deletes folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * + * @return bool True on success, False otherwise + */ + public function folder_delete($folderid, $deviceid) + { + $name = $this->folder_id2name($folderid, $deviceid); + $type = kolab_storage::folder_type($name); + + unset($this->folder_meta[$name]); + + // don't use kolab_storage for deleting mail folders + if (preg_match('/^mail/', $type)) { + return $this->storage->delete_folder($name); + } + + return kolab_storage::folder_delete($name); + } + + /** + * Deletes contents of a folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param bool $recursive Apply to the folder and its subfolders + * + * @return bool True on success, False otherwise + */ + public function folder_empty($folderid, $deviceid, $recursive = false) + { + $foldername = $this->folder_id2name($folderid, $deviceid); + + // Remove all entries + if (!$this->storage->clear_folder($foldername)) { + return false; + } + + // Remove subfolders + if ($recursive) { + $delim = $this->storage->get_hierarchy_delimiter(); + $folderdata = $this->folder_meta(); + + if (!is_array($folderdata)) { + return false; + } + + foreach ($folderdata as $subfolder => $meta) { + if (!empty($meta['FOLDER'][$deviceid]['S']) && strpos((string) $subfolder, $foldername . $delim)) { + if (!$this->storage->clear_folder((string) $subfolder)) { + return false; + } + } + } + } + + return true; + } + + /** + * Sets ActiveSync subscription flag on a folder + * + * @param string $name Folder name (UTF7-IMAP) + * @param string $deviceid Device identifier + * @param int $flag Flag value (0|1|2) + * + * @return bool True on success, False on failure + */ + protected function folder_set($name, $deviceid, $flag) + { + if (empty($deviceid)) { + return false; + } + + // get folders activesync config + $metadata = $this->folder_meta(); + + if (!is_array($metadata)) { + return false; + } + + $metadata = isset($metadata[$name]) ? $metadata[$name] : []; + + if ($flag) { + if (empty($metadata)) { + $metadata = []; + } + + if (empty($metadata['FOLDER'])) { + $metadata['FOLDER'] = []; + } + + if (empty($metadata['FOLDER'][$deviceid])) { + $metadata['FOLDER'][$deviceid] = []; + } + + // Z-Push uses: + // 1 - synchronize, no alarms + // 2 - synchronize with alarms + $metadata['FOLDER'][$deviceid]['S'] = $flag; + } + else { + unset($metadata['FOLDER'][$deviceid]['S']); + + if (empty($metadata['FOLDER'][$deviceid])) { + unset($metadata['FOLDER'][$deviceid]); + } + + if (empty($metadata['FOLDER'])) { + unset($metadata['FOLDER']); + } + + if (empty($metadata)) { + $metadata = null; + } + } + + // Return if nothing's been changed + if (!self::data_array_diff(isset($this->folder_meta[$name]) ? $this->folder_meta[$name] : null, $metadata)) { + return true; + } + + $this->folder_meta[$name] = $metadata; + + return $this->storage->set_metadata($name, [self::ASYNC_KEY => $this->serialize_metadata($metadata)]); + } + + /** + * Returns device metadata + * + * @param string $id Device ID + * + * @return array|null Device metadata + */ + public function device_get($id) + { + $devices_list = $this->devices_list(); + return $devices_list[$id] ?? null; + } + + /** + * Registers new device on server + * + * @param array $device Device data + * @param string $id Device ID + * + * @return bool True on success, False on failure + */ + public function device_create($device, $id) + { + // Fill local cache + $this->devices_list(); + + // Some devices create dummy devices with name "validate" (#1109) + // This device entry is used in two initial requests, but later + // the device registers a real name. We can remove this dummy entry + // on new device creation + $this->device_delete('validate'); + + // Old Kolab_ZPush device parameters + // MODE: -1 | 0 | 1 (not set | flatmode | foldermode) + // TYPE: device type string + // ALIAS: user-friendly device name + + // Syncroton (kolab_sync_backend_device) uses + // ID: internal identifier in syncroton database + // TYPE: device type string + // ALIAS: user-friendly device name + + $metadata = $this->root_meta; + $metadata['DEVICE'][$id] = $device; + $metadata = [self::ASYNC_KEY => $this->serialize_metadata($metadata)]; + + $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); + + if ($result) { + // Update local cache + $this->root_meta['DEVICE'][$id] = $device; + + // subscribe default set of folders + $this->device_init_subscriptions($id); + } + + return $result; + } + + /** + * Device update. + * + * @param array $device Device data + * @param string $id Device ID + * + * @return bool True on success, False on failure + */ + public function device_update($device, $id) + { + $devices_list = $this->devices_list(); + $old_device = $devices_list[$id]; + + if (!$old_device) { + return false; + } + + // Do nothing if nothing is changed + if (!self::data_array_diff($old_device, $device)) { + return true; + } + + $device = array_merge($old_device, $device); + + $metadata = $this->root_meta; + $metadata['DEVICE'][$id] = $device; + $metadata = [self::ASYNC_KEY => $this->serialize_metadata($metadata)]; + + $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); + + if ($result) { + // Update local cache + $this->root_meta['DEVICE'][$id] = $device; + } + + return $result; + } + + /** + * Device delete. + * + * @param string $id Device ID + * + * @return bool True on success, False on failure + */ + public function device_delete($id) + { + $device = $this->device_get($id); + + if (!$device) { + return false; + } + + unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]); + + if (empty($this->root_meta['DEVICE'])) { + unset($this->root_meta['DEVICE']); + } + if (empty($this->root_meta['FOLDER'])) { + unset($this->root_meta['FOLDER']); + } + + $metadata = $this->serialize_metadata($this->root_meta); + $metadata = [self::ASYNC_KEY => $metadata]; + + // update meta data + $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata); + + if ($result) { + // remove device annotation for every folder + foreach ($this->folder_meta() as $folder => $meta) { + // skip root folder (already handled above) + if ($folder == self::ROOT_MAILBOX) + continue; + + if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) { + unset($meta['FOLDER'][$id]); + + if (empty($meta['FOLDER'])) { + unset($this->folder_meta[$folder]['FOLDER']); + unset($meta['FOLDER']); + } + if (empty($meta)) { + unset($this->folder_meta[$folder]); + $meta = null; + } + + $metadata = [self::ASYNC_KEY => $this->serialize_metadata($meta)]; + $res = $this->storage->set_metadata($folder, $metadata); + + if ($res && $meta) { + $this->folder_meta[$folder] = $meta; + } + } + } + } + + return $result; + } + + /** + * Creates an item in a folder. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param string|array $data Object data (string for email, array for other types) + * @param array $params Additional parameters (e.g. mail flags) + * + * @return string|null Item UID on success or null on failure + */ + public function createItem($folderid, $deviceid, $type, $data, $params = []) + { + if ($type == self::MODEL_EMAIL) { + $foldername = $this->folder_id2name($folderid, $deviceid); + + $uid = $this->storage->save_message($foldername, $data, '', false, $params['flags'] ?? []); + + if (!$uid) { + // $this->logger->error("Error while storing the message " . $this->storage->get_error_str()); + } + + return $uid; + } + + $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); + + // convert categories into tags, save them after creating an object + if ($useTags && !empty($data['categories'])) { + $tags = $data['categories']; + unset($data['categories']); + } + + $folder = $this->getFolder($folderid, $deviceid, $type); + + // Set User-Agent for saved objects + $app = kolab_sync::get_instance(); + $app->config->set('useragent', $app->app_name . ' ' . kolab_sync::VERSION); + + if ($folder && $folder->valid && $folder->save($data)) { + if (!empty($tags) && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES)) { + $this->setCategories($data['uid'], $tags); + } + + return $data['uid']; + } + + return null; + } + + /** + * Deletes an item from a folder by UID. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param string $uid Requested object UID + * @param bool $moveToTrash Move to trash, instead of delete (for mail messages only) + * + * @return bool True on success, False on failure + */ + public function deleteItem($folderid, $deviceid, $type, $uid, $moveToTrash = false) + { + if ($type == self::MODEL_EMAIL) { + $foldername = $this->folder_id2name($folderid, $deviceid); + $trash = kolab_sync::get_instance()->config->get('trash_mbox'); + + // move message to the Trash folder + if ($moveToTrash && strlen($trash) && $trash != $foldername && $this->storage->folder_exists($trash)) { + return $this->storage->move_message($uid, $trash, $foldername); + } + + // delete the message + // According to the ActiveSync spec. "If the DeletesAsMoves element is set to false, + // the deletion is PERMANENT.", therefore we delete the message, and not flag as deleted. + + // FIXME: We could consider acting according to the 'flag_for_deletion' setting. + // Don't forget about 'read_when_deleted' setting then. + // $this->storage->set_flag($uid, 'DELETED', $foldername); + // $this->storage->set_flag($uid, 'SEEN', $foldername); + return $this->storage->delete_message($uid, $foldername); + } + + $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); + + $folder = $this->getFolder($folderid, $deviceid, $type); + + if (!$folder || !$folder->valid) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + if ($folder->delete($uid)) { + if ($useTags) { + $this->setCategories($uid, []); + } + + return true; + } + + return false; + } + + /** + * Updates an item in a folder. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param string $uid Object UID + * @param string|array $data Object data (string for email, array for other types) + * @param array $params Additional parameters (e.g. mail flags) + * + * @return string|null Item UID on success or null on failure + */ + public function updateItem($folderid, $deviceid, $type, $uid, $data, $params = []) + { + if ($type == self::MODEL_EMAIL) { + $foldername = $this->folder_id2name($folderid, $deviceid); + + // Note: We do not support a message body update, as it's not needed + + foreach (($params['flags'] ?? []) as $flag) { + $this->storage->set_flag($uid, $flag, $foldername); + } + + // Categories (Tags) change + if (isset($params['categories']) && $this->relationSupport) { + $message = new rcube_message($uid, $foldername); + + if (empty($message->headers)) { + throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR); + } + + $this->setCategories($message, $params['categories']); + } + + return $uid; + } + + $folder = $this->getFolder($folderid, $deviceid, $type); + + $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); + + // convert categories into tags, save them after updating an object + if ($useTags && array_key_exists('categories', $data)) { + $tags = (array) $data['categories']; + unset($data['categories']); + } + + // Set User-Agent for saved objects + $app = kolab_sync::get_instance(); + $app->config->set('useragent', $app->app_name . ' ' . kolab_sync::VERSION); + + if ($folder && $folder->valid && $folder->save($data, $type, $uid)) { + if (isset($tags)) { + $this->setCategories($uid, $tags); + } + + return $uid; + } + + return null; + } + + /** + * Returns list of categories assigned to an object + * + * @param object|string $object UID or rcube_message object + * @param array $categories Addition tag names to merge with + * + * @return array List of categories + */ + public function getCategories($object, $categories = []) + { + if (is_object($object)) { + // support only messages with message-id + if (!($msg_id = $object->headers->get('message-id', false))) { + return []; + } + + $config = kolab_storage_config::get_instance(); + $delta = Syncroton_Registry::getPingTimeout(); + $folder = $object->folder; + $uid = $object->uid; + + // get tag objects raleted to specified message-id + $tags = $config->get_tags($msg_id); + + foreach ($tags as $idx => $tag) { + // resolve members if it wasn't done recently + $force = empty($this->tag_rts[$tag['uid']]) || $this->tag_rts[$tag['uid']] <= time() - $delta; + $members = $config->resolve_members($tag, $force); + + if (empty($members[$folder]) || !in_array($uid, $members[$folder])) { + unset($tags[$idx]); + } + + if ($force) { + $this->tag_rts[$tag['uid']] = time(); + } + } + + // make sure current folder is set correctly again + $this->storage->set_folder($folder); + } else { + $config = kolab_storage_config::get_instance(); + $tags = $config->get_tags($object); + } + + $tags = array_filter(array_map(function($v) { return $v['name']; }, $tags)); + + // merge result with old categories + if (!empty($categories)) { + $tags = array_unique(array_merge($tags, (array) $categories)); + } + + return $tags; + } + + /** + * Gets kolab_storage_folder object from Activesync folder ID. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * + * @return ?kolab_storage_folder + */ + public function getFolder($folderid, $deviceid, $type) + { + $unique_key = "$folderid:$deviceid:$type"; + + if (array_key_exists($unique_key, $this->folders)) { + return $this->folders[$unique_key]; + } + + $foldername = $this->folder_id2name($folderid, $deviceid); + + return $this->folders[$unique_key] = kolab_storage::get_folder($foldername, $type); + } + + /** + * Gets Activesync preferences for a folder. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * + * @return array Folder preferences + */ + public function getFolderConfig($folderid, $deviceid, $type) + { + $foldername = $this->folder_id2name($folderid, $deviceid); + + $metadata = $this->folder_meta(); + $config = []; + + if (!empty($metadata[$foldername]['FOLDER'][$deviceid])) { + $config = $metadata[$foldername]['FOLDER'][$deviceid]; + } + + return [ + 'ALARMS' => ($config['S'] ?? 0) == 2, + ]; + } + + /** + * Gets an item from a folder by UID. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param string $uid Requested object UID + * + * @return array|rcube_message|null Object properties + */ + public function getItem($folderid, $deviceid, $type, $uid) + { + if ($type == self::MODEL_EMAIL) { + $foldername = $this->folder_id2name($folderid, $deviceid); + $message = new rcube_message($uid, $foldername); + + if ($message && !empty($message->headers)) { + if ($this->relationSupport) { + $message->headers->others['categories'] = $this->getCategories($message); + } + + return $message; + } + + return null; + } + + $folder = $this->getFolder($folderid, $deviceid, $type); + + if (!$folder || !$folder->valid) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + $result = $folder->get_object($uid); + + if ($result === false) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + $useTags = $this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES); + + if ($useTags) { + $result['categories'] = $this->getCategories($uid, $result['categories'] ?? []); + } + + return $result; + } + + /** + * Gets items matching UID by prefix. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param string $uid Requested object UID prefix + * + * @return array|iterable List of objects + */ + public function getItemsByUidPrefix($folderid, $deviceid, $type, $uid) + { + $folder = $this->getFolder($folderid, $deviceid, $type); + + if (!$folder || !$folder->valid) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + $result = $folder->select([['uid', '~*', $uid]]); + + if ($result === false) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + return $result; + } + + /** + * Move an item from one folder to another. + * + * @param string $srcFolderId Source folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param string $uid Object UID + * @param string $dstFolderId Destination folder identifier + * + * @return string New object UID + * @throws Syncroton_Exception_Status + */ + public function moveItem($srcFolderId, $deviceid, $type, $uid, $dstFolderId) + { + if ($type === self::MODEL_EMAIL) { + $src_name = $this->folder_id2name($srcFolderId, $deviceid); + $dst_name = $this->folder_id2name($dstFolderId, $deviceid); + + if ($dst_name === null) { + throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); + } + + if ($src_name === null) { + throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); + } + + if (!$this->storage->move_message($uid, $dst_name, $src_name)) { + throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); + } + + // Use COPYUID feature (RFC2359) to get the new UID of the copied message + if (empty($this->storage->conn->data['COPYUID'])) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + return $this->storage->conn->data['COPYUID'][1]; + } + + $srcFolder = $this->getFolder($srcFolderId, $deviceid, $type); + $dstFolder = $this->getFolder($dstFolderId, $deviceid, $type); + + if (!$srcFolder || !$dstFolder) { + throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_DESTINATION); + } + + if (!$srcFolder->move($uid, $dstFolder)) { + throw new Syncroton_Exception_Status_MoveItems(Syncroton_Exception_Status_MoveItems::INVALID_SOURCE); + } + + return $uid; + } + + /** + * Set categories to an object + * + * @param object|string $object UID or rcube_message object + * @param array $categories List of Category names + */ + public function setCategories($object, $categories) + { + if (!is_object($object)) { + $config = kolab_storage_config::get_instance(); + $config->save_tags($object, $categories); + return; + } + + $config = kolab_storage_config::get_instance(); + $delta = Syncroton_Registry::getPingTimeout(); + $uri = kolab_storage_config::get_message_uri($object->headers, $object->folder); + + // for all tag objects... + foreach ($config->get_tags() as $relation) { + // resolve members if it wasn't done recently + $uid = $relation['uid']; + $force = empty($this->tag_rts[$uid]) || $this->tag_rts[$uid] <= time() - $delta; + + if ($force) { + $config->resolve_members($relation, $force); + $this->tag_rts[$relation['uid']] = time(); + } + + $selected = !empty($categories) && in_array($relation['name'], $categories); + $found = !empty($relation['members']) && in_array($uri, $relation['members']); + $update = false; + + // remove member from the relation + if ($found && !$selected) { + $relation['members'] = array_diff($relation['members'], (array) $uri); + $update = true; + } + // add member to the relation + else if (!$found && $selected) { + $relation['members'][] = $uri; + $update = true; + } + + if ($update) { + $config->save($relation, 'relation'); + } + + $categories = array_diff($categories, (array) $relation['name']); + } + + // create new relations + if (!empty($categories)) { + foreach ($categories as $tag) { + $relation = [ + 'name' => $tag, + 'members' => (array) $uri, + 'category' => 'tag', + ]; + + $config->save($relation, 'relation'); + } + } + + // make sure current folder is set correctly again + $this->storage->set_folder($object->folder); + } + + /** + * Search for existing objects in a folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param array $filter Filter + * @param int $result_type Type of the result (see kolab_sync_data::RESULT_* constants) + * @param bool $force Force IMAP folder cache synchronization + * + * @return array|int Search result as count or array of uids + */ + public function searchEntries($folderid, $deviceid, $type, $filter, $result_type, $force) + { + if ($type != self::MODEL_EMAIL) { + return $this->searchKolabEntries($folderid, $deviceid, $type, $filter, $result_type, $force); + } + + $filter_str = 'ALL UNDELETED'; + + // convert filter into one IMAP search string + foreach ($filter as $idx => $filter_item) { + if (is_array($filter_item)) { + // This is a request for changes since last time + // we'll use HIGHESTMODSEQ value from the last Sync + if ($filter_item[0] == 'changed' && $filter_item[1] == '>') { + $modseq_lasttime = $filter_item[2]; + $modseq_data = []; + $modseq = (array) $this->modseq_get($deviceid, $folderid, $modseq_lasttime); + } + } + else { + $filter_str .= ' ' . $filter_item; + } + } + + // get members of modified relations + if ($this->relationSupport) { + $changed_msgs = $this->getChangesByRelations($folderid, $deviceid, $type, $filter); + } + + $result = $result_type == kolab_sync_data::RESULT_COUNT ? 0 : []; + + $foldername = $this->folder_id2name($folderid, $deviceid); + + if ($foldername === null) { + return $result; + } + + $this->storage->set_folder($foldername); + + // Synchronize folder (if it wasn't synced in this request already) + if ($force) { + $this->storage->folder_sync($foldername); + } + + // We're in "get changes" mode + if (isset($modseq_data)) { + $folder_data = $this->storage->folder_data($foldername); + $modified = false; + + // If previous HIGHESTMODSEQ doesn't exist we can't get changes + // We can only get folder's HIGHESTMODSEQ value and store it for the next try + // Skip search if HIGHESTMODSEQ didn't change + if (!empty($folder_data['HIGHESTMODSEQ'])) { + $modseq_data[$foldername] = $folder_data['HIGHESTMODSEQ']; + $modseq_old = isset($modseq[$foldername]) ? $modseq[$foldername] : null; + if ($modseq_data[$foldername] != $modseq_old) { + $modseq_update = true; + if (!empty($modseq) && $modseq_old) { + $modified = true; + $filter_str .= " MODSEQ " . ($modseq_old + 1); + } + } + } + } + else { + $modified = true; + } + + // We could use messages cache by replacing search() with index() + // in some cases. This however is possible only if user has skip_deleted=true, + // in his Roundcube preferences, otherwise we'd make often cache re-initialization, + // because Roundcube message cache can work only with one skip_deleted + // setting at a time. We'd also need to make sure folder_sync() was called + // before (see above). + // + // if ($filter_str == 'ALL UNDELETED') + // $search = $this->storage->index($foldername, null, null, true, true); + // else + + if ($modified) { + $search = $this->storage->search_once($foldername, $filter_str); + + if (!($search instanceof rcube_result_index) || $search->is_error()) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + switch ($result_type) { + case kolab_sync_data::RESULT_COUNT: + $result = $search->count(); + break; + + case kolab_sync_data::RESULT_UID: + $result = $search->get(); + break; + } + } + + // handle relation changes + if (!empty($changed_msgs)) { + $members = $this->findRelationMembersInFolder($foldername, $changed_msgs, $filter); + + switch ($result_type) { + case kolab_sync_data::RESULT_COUNT: + $result += count($members); + break; + + case kolab_sync_data::RESULT_UID: + $result = array_values(array_unique(array_merge($result, $members))); + break; + } + } + + if (!empty($modseq_update) && !empty($modseq_data)) { + $this->modseq_set($deviceid, $folderid, $this->syncTimeStamp, $modseq_data); + + // if previous modseq information does not exist save current set as it, + // we would at least be able to detect changes since now + if (empty($result) && empty($modseq)) { + $this->modseq_set($deviceid, $folderid, $modseq_lasttime ?? 0, $modseq_data); + } + } + + return $result; + } + + /** + * Search for existing objects in a folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * @param array $filter Filter + * @param int $result_type Type of the result (see kolab_sync_data::RESULT_* constants) + * @param bool $force Force IMAP folder cache synchronization + * + * @return array|int Search result as count or array of uids + */ + protected function searchKolabEntries($folderid, $deviceid, $type, $filter, $result_type, $force) + { + // there's a PHP Warning from kolab_storage if $filter isn't an array + if (empty($filter)) { + $filter = []; + } elseif ($this->relationSupport && ($type == self::MODEL_TASKS || $type == self::MODEL_NOTES)) { + $changed_objects = $this->getChangesByRelations($folderid, $deviceid, $type, $filter); + } + + $folder = $this->getFolder($folderid, $deviceid, $type); + + if (!$folder || !$folder->valid) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + $error = false; + + switch ($result_type) { + case kolab_sync_data::RESULT_COUNT: + $count = $folder->count($filter); + + if ($count === null || $count === false) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + $result = (int) $count; + break; + + case kolab_sync_data::RESULT_UID: + default: + $uids = $folder->get_uids($filter); + + if (!is_array($uids)) { + throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR); + } + + $result = $uids; + break; + } + + // handle tag modifications + if (!empty($changed_objects)) { + // build new filter + // search objects mathing current filter, + // relations may contain members of many types, we need to + // search them by UID in all requested folders to get + // only these with requested type (and that really exist + // in specified folders) + $tag_filter = [['uid', '=', $changed_objects]]; + foreach ($filter as $f) { + if ($f[0] != 'changed') { + $tag_filter[] = $f; + } + } + + switch ($result_type) { + case kolab_sync_data::RESULT_COUNT: + // Note: this way we're potentally counting the same objects twice + // I'm not sure if this is a problem, we most likely do not + // need a precise result here + $count = $folder->count($tag_filter); + if ($count !== null && $count !== false) { + $result += (int) $count; + } + + break; + + case kolab_sync_data::RESULT_UID: + default: + $uids = $folder->get_uids($tag_filter); + if (is_array($uids) && !empty($uids)) { + $result = array_unique(array_merge($result, $uids)); + } + + break; + } + } + + return $result; + } + + /** + * Find members (messages) in specified folder + */ + protected function findRelationMembersInFolder($foldername, $members, $filter) + { + foreach ($members as $member) { + // IMAP URI members + if ($url = kolab_storage_config::parse_member_url($member)) { + $result[$url['folder']][$url['uid']] = $url['params']; + } + } + + // convert filter into one IMAP search string + $filter_str = 'ALL UNDELETED'; + foreach ($filter as $filter_item) { + if (is_string($filter_item)) { + $filter_str .= ' ' . $filter_item; + } + } + + $found = []; + + // first find messages by UID + if (!empty($result[$foldername])) { + $index = $this->storage->search_once($foldername, 'UID ' + . rcube_imap_generic::compressMessageSet(array_keys($result[$foldername]))); + $found = $index->get(); + + // remove found messages from the $result + if (!empty($found)) { + $result[$foldername] = array_diff_key($result[$foldername], array_flip($found)); + + if (empty($result[$foldername])) { + unset($result[$foldername]); + } + + // now apply the current filter to the found messages + $index = $this->storage->search_once($foldername, $filter_str . ' UID ' + . rcube_imap_generic::compressMessageSet($found)); + $found = $index->get(); + } + } + + // search by message parameters + if (!empty($result)) { + // @TODO: do this search in chunks (for e.g. 25 messages)? + $search = ''; + $search_count = 0; + + foreach ($result as $data) { + foreach ($data as $p) { + $search_params = []; + $search_count++; + + foreach ($p as $key => $val) { + $key = strtoupper($key); + // don't search by subject, we don't want false-positives + if ($key != 'SUBJECT') { + $search_params[] = 'HEADER ' . $key . ' ' . rcube_imap_generic::escape($val); + } + } + + $search .= ' (' . implode(' ', $search_params) . ')'; + } + } + + $search_str = str_repeat(' OR', $search_count-1) . $search; + + // search messages in current folder + $search = $this->storage->search_once($foldername, $search_str); + $uids = $search->get(); + + if (!empty($uids)) { + // add UIDs into the result + $found = array_unique(array_merge($found, $uids)); + } + } + + return $found; + } + + /** + * Detect changes of relation (tag) objects data and assigned objects + * Returns relation member identifiers + */ + protected function getChangesByRelations($folderid, $deviceid, $type, $filter) + { + // get period filter, create new objects filter + foreach ($filter as $f) { + if ($f[0] == 'changed' && $f[1] == '>') { + $since = $f[2]; + } + } + + // this is not search for changes, do nothing + if (empty($since)) { + return; + } + + // get relations state from the last sync + $last_state = (array) $this->relations_state_get($deviceid, $folderid, $since); + + // get current relations state + $config = kolab_storage_config::get_instance(); + $default = true; + $filter = [ + ['type', '=', 'relation'], + ['category', '=', 'tag'] + ]; + + $relations = $config->get_objects($filter, $default, 100); + + $result = []; + $changed = false; + + // compare states, get members of changed relations + foreach ($relations as $relation) { + $rel_id = $relation['uid']; + + if ($relation['changed']) { + $relation['changed']->setTimezone(new DateTimeZone('UTC')); + } + + // last state unknown... + if (empty($last_state[$rel_id])) { + // ...get all members + if (!empty($relation['members'])) { + $changed = true; + $result = array_merge($result, $relation['members']); + } + } + // last state known, changed tag name... + else if ($last_state[$rel_id]['name'] != $relation['name']) { + // ...get all (old and new) members + $members_old = explode("\n", $last_state[$rel_id]['members']); + $changed = true; + $members = array_unique(array_merge($relation['members'], $members_old)); + $result = array_merge($result, $members); + } + // last state known, any other change change... + else if ($last_state[$rel_id]['changed'] < $relation['changed']->format('U')) { + // ...find new and removed members + $members_old = explode("\n", $last_state[$rel_id]['members']); + $new = array_diff($relation['members'], $members_old); + $removed = array_diff($members_old, $relation['members']); + + if (!empty($new) || !empty($removed)) { + $changed = true; + $result = array_merge($result, $new, $removed); + } + } + + unset($last_state[$rel_id]); + } + + // get members of deleted relations + if (!empty($last_state)) { + $changed = true; + foreach ($last_state as $relation) { + $members = explode("\n", $relation['members']); + $result = array_merge($result, $members); + } + } + + // save current state + if ($changed) { + $data = []; + foreach ($relations as $relation) { + $data[$relation['uid']] = [ + 'name' => $relation['name'], + 'changed' => $relation['changed']->format('U'), + 'members' => implode("\n", (array)$relation['members']), + ]; + } + + $now = new DateTime('now', new DateTimeZone('UTC')); + + $this->relations_state_set($deviceid, $folderid, $now, $data); + } + + // in mail mode return only message URIs + if ($type == self::MODEL_EMAIL) { + // lambda function to skip email members + $filter_func = function($value) { + return strpos($value, 'imap://') === 0; + }; + + $result = array_filter(array_unique($result), $filter_func); + } + // otherwise return only object UIDs + else { + // lambda function to skip email members + $filter_func = function($value) { + return strpos($value, 'urn:uuid:') === 0; + }; + + // lambda function to parse member URI + $member_func = function($value) { + if (strpos($value, 'urn:uuid:') === 0) { + $value = substr($value, 9); + } + return $value; + }; + + $result = array_map($member_func, array_filter(array_unique($result), $filter_func)); + } + + return $result; + } + + /** + * Subscribe default set of folders on device registration + */ + protected function device_init_subscriptions($deviceid) + { + // INBOX always exists + $this->folder_set('INBOX', $deviceid, 1); + + $supported_types = [ + 'mail.drafts', + 'mail.wastebasket', + 'mail.sentitems', + 'mail.outbox', + 'event.default', + 'contact.default', + 'note.default', + 'task.default', + 'event', + 'contact', + 'note', + 'task', + 'event.confidential', + 'event.private', + 'task.confidential', + 'task.private', + ]; + + $rcube = rcube::get_instance(); + $config = $rcube->config; + $mode = (int) $config->get('activesync_init_subscriptions'); + $folders = []; + + // Subscribe to default folders + $foldertypes = kolab_storage::folders_typedata(); + + if (!empty($foldertypes)) { + $_foldertypes = array_intersect($foldertypes, $supported_types); + + // get default folders + foreach ($_foldertypes as $folder => $type) { + // only personal folders + if ($this->storage->folder_namespace($folder) == 'personal') { + $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; + $this->folder_set($folder, $deviceid, $flag); + $folders[] = $folder; + } + } + } + + // we're in default mode, exit + if (!$mode) { + return; + } + + // below we support additionally all mail folders + $supported_types[] = 'mail'; + $supported_types[] = 'mail.junkemail'; + + // get configured special folders + $special_folders = []; + $map = [ + 'drafts' => 'mail.drafts', + 'junk' => 'mail.junkemail', + 'sent' => 'mail.sentitems', + 'trash' => 'mail.wastebasket', + ]; + + foreach ($map as $folder => $type) { + if ($folder = $config->get($folder . '_mbox')) { + $special_folders[$folder] = $type; + } + } + + // get folders list(s) + if (($mode & self::INIT_ALL_PERSONAL) || ($mode & self::INIT_ALL_OTHER) || ($mode & self::INIT_ALL_SHARED)) { + $all_folders = $this->storage->list_folders(); + if (($mode & self::INIT_SUB_PERSONAL) || ($mode & self::INIT_SUB_OTHER) || ($mode & self::INIT_SUB_SHARED)) { + $subscribed_folders = $this->storage->list_folders_subscribed(); + } + } + else { + $all_folders = $this->storage->list_folders_subscribed(); + } + + foreach ($all_folders as $folder) { + // folder already subscribed + if (in_array($folder, $folders)) { + continue; + } + + $type = ($foldertypes[$folder] ?? null) ?: 'mail'; + if ($type == 'mail' && isset($special_folders[$folder])) { + $type = $special_folders[$folder]; + } + + if (!in_array($type, $supported_types)) { + continue; + } + + $ns = strtoupper($this->storage->folder_namespace($folder)); + + // subscribe the folder according to configured mode + // and folder namespace/subscription status + if (($mode & constant("self::INIT_ALL_{$ns}")) + || (($mode & constant("self::INIT_SUB_{$ns}")) + && (!isset($subscribed_folders) || in_array($folder, $subscribed_folders))) + ) { + $flag = preg_match('/^(event|task)/', $type) ? 2 : 1; + $this->folder_set($folder, $deviceid, $flag); + } + } + } + + /** + * Helper method to decode saved IMAP metadata + */ + protected function unserialize_metadata($str) + { + if (!empty($str)) { + $data = json_decode($str, true); + return $data; + } + + return null; + } + + /** + * Helper method to encode IMAP metadata for saving + */ + protected function serialize_metadata($data) + { + if (!empty($data) && is_array($data)) { + $data = json_encode($data); + return $data; + } + + return null; + } + + /** + * Returns Kolab folder type for specified ActiveSync type ID + */ + protected static function type_activesync2kolab($type) + { + if (!empty(self::$types[$type])) { + return self::$types[$type]; + } + + return ''; + } + + /** + * Returns ActiveSync folder type for specified Kolab type + */ + protected static function type_kolab2activesync($type) + { + $type = preg_replace('/\.(confidential|private)$/i', '', $type); + + if ($key = array_search($type, self::$types)) { + return $key; + } + + return key(self::$types); + } + + /** + * Returns folder data in Syncroton format + */ + protected function folder_data($folder, $type) + { + // Folder name parameters + $delim = $this->storage->get_hierarchy_delimiter(); + $items = explode($delim, $folder); + $name = array_pop($items); + + // Folder UID + $folder_id = $this->folder_id($folder, $type); + + // Folder type + if (strcasecmp($folder, 'INBOX') === 0) { + // INBOX is always inbox, prevent from issues related with a change of + // folder type annotation (it can be initially unset). + $type = 2; + } + else { + $type = self::type_kolab2activesync($type); + + // fix type, if there's no type annotation it's detected as UNKNOWN we'll use 'mail' (12) + if ($type == 1) { + $type = 12; + } + } + + // Syncroton folder data array + return [ + 'serverId' => $folder_id, + 'parentId' => count($items) ? $this->folder_id(implode($delim, $items), $type) : 0, + 'displayName' => rcube_charset::convert($name, 'UTF7-IMAP', kolab_sync::CHARSET), + 'type' => $type, + // for internal use + 'imap_name' => $folder, + ]; + } + + /** + * Builds folder ID based on folder name + */ + protected function folder_id($name, $type = null) + { + // ActiveSync expects folder identifiers to be max.64 characters + // So we can't use just folder name + + $name = (string) $name; + + if ($name === '') { + return null; + } + + if (isset($this->folder_uids[$name])) { + return $this->folder_uids[$name]; + } + +/* + @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves. + There's one inconvenience of this solution: folder name/type change + would be handled in ActiveSync as delete + create. + + // get folders unique identifier + $folderdata = $this->storage->get_metadata($name, self::UID_KEY); + + if ($folderdata && !empty($folderdata[$name])) { + $uid = $folderdata[$name][self::UID_KEY]; + return $this->folder_uids[$name] = $uid; + } +*/ + if (strcasecmp($name, 'INBOX') === 0) { + // INBOX is always inbox, prevent from issues related with a change of + // folder type annotation (it can be initially unset). + $type = 'mail.inbox'; + } + else { + if ($type === null) { + $type = kolab_storage::folder_type($name); + } + + if ($type != null) { + $type = preg_replace('/\.(confidential|private)$/i', '', $type); + } + } + + // Add type to folder UID hash, so type change can be detected by Syncroton + $uid = $name . '!!' . $type; + $uid = md5($uid); + + return $this->folder_uids[$name] = $uid; + } + + /** + * Returns IMAP folder name + * + * @param string $id Folder identifier + * @param string $deviceid Device dentifier + * + * @return string Folder name (UTF7-IMAP) + */ + public function folder_id2name($id, $deviceid) + { + // check in cache first + if (!empty($this->folder_uids)) { + if (($name = array_search($id, $this->folder_uids)) !== false) { + return $name; + } + } +/* + @TODO: see folder_id() + + // get folders unique identifier + $folderdata = $this->storage->get_metadata('*', self::UID_KEY); + + foreach ((array)$folderdata as $folder => $data) { + if (!empty($data[self::UID_KEY])) { + $uid = $data[self::UID_KEY]; + $this->folder_uids[$folder] = $uid; + if ($uid == $id) { + $name = $folder; + } + } + } +*/ + // get all folders of specified type + $folderdata = $this->folder_meta(); + + if (!is_array($folderdata) || $id === null) { + return null; + } + + $name = null; + + // check if folders are "subscribed" for activesync + foreach ($folderdata as $folder => $meta) { + if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) + || empty($meta['FOLDER'][$deviceid]['S']) + ) { + continue; + } + + if ($uid = $this->folder_id($folder)) { + $this->folder_uids[$folder] = $uid; + } + + if ($uid === $id) { + $name = $folder; + } + } + + return $name; + } + + /** + * Save MODSEQ value for a folder + */ + protected function modseq_set($deviceid, $folderid, $synctime, $data) + { + $synctime = $synctime->format('Y-m-d H:i:s'); + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + $old_data = $this->modseq[$folderid][$synctime] ?? null; + + if (empty($old_data)) { + $this->modseq[$folderid][$synctime] = $data; + $data = json_encode($data); + + $db->set_option('ignore_key_errors', true); + $db->query("INSERT INTO `syncroton_modseq` (`device_id`, `folder_id`, `synctime`, `data`)" + ." VALUES (?, ?, ?, ?)", + $deviceid, $folderid, $synctime, $data); + $db->set_option('ignore_key_errors', false); + } + } + + /** + * Get stored MODSEQ value for a folder + */ + protected function modseq_get($deviceid, $folderid, $synctime) + { + $synctime = $synctime->format('Y-m-d H:i:s'); + + if (empty($this->modseq[$folderid][$synctime])) { + $this->modseq[$folderid] = []; + + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + + $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_modseq`" + ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" + ." ORDER BY `synctime` DESC", + 0, 1, $deviceid, $folderid, $synctime); + + if ($row = $db->fetch_assoc()) { + $synctime = $row['synctime']; + // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format + $this->modseq[$folderid][$synctime] = json_decode($row['data'], true); + } + + // Cleanup: remove all records except the current one + $db->query("DELETE FROM `syncroton_modseq`" + ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", + $deviceid, $folderid, $synctime); + } + + return $this->modseq[$folderid][$synctime] ?? null; + } + + /** + * Set state of relation objects at specified point in time + */ + public function relations_state_set($deviceid, $folderid, $synctime, $relations) + { + $synctime = $synctime->format('Y-m-d H:i:s'); + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + $old_data = $this->relations[$folderid][$synctime] ?? null; + + if (empty($old_data)) { + $this->relations[$folderid][$synctime] = $relations; + $data = rcube_charset::clean(json_encode($relations)); + + $db->set_option('ignore_key_errors', true); + $db->query("INSERT INTO `syncroton_relations_state`" + ." (`device_id`, `folder_id`, `synctime`, `data`)" + ." VALUES (?, ?, ?, ?)", + $deviceid, $folderid, $synctime, $data); + $db->set_option('ignore_key_errors', false); + } + } + + /** + * Get state of relation objects at specified point in time + */ + protected function relations_state_get($deviceid, $folderid, $synctime) + { + $synctime = $synctime->format('Y-m-d H:i:s'); + + if (empty($this->relations[$folderid][$synctime])) { + $this->relations[$folderid] = []; + + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + + $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_relations_state`" + ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?" + ." ORDER BY `synctime` DESC", + 0, 1, $deviceid, $folderid, $synctime); + + if ($row = $db->fetch_assoc()) { + $synctime = $row['synctime']; + // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format + $this->relations[$folderid][$synctime] = json_decode($row['data'], true); + } + + // Cleanup: remove all records except the current one + $db->query("DELETE FROM `syncroton_relations_state`" + ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?", + $deviceid, $folderid, $synctime); + } + + return $this->relations[$folderid][$synctime] ?? null; + } + + /** + * Return last storage error + */ + public static function last_error() + { + return kolab_storage::$last_error; + } + + /** + * Compares two arrays + * + * @param array $array1 + * @param array $array2 + * + * @return bool True if arrays differs, False otherwise + */ + protected static function data_array_diff($array1, $array2) + { + if (!is_array($array1) || !is_array($array2)) { + return $array1 != $array2; + } + + if (count($array1) != count($array2)) { + return true; + } + + foreach ($array1 as $key => $val) { + if (!array_key_exists($key, $array2)) { + return true; + } + if ($val !== $array2[$key]) { + return true; + } + } + + return false; + } +} diff --git a/lib/kolab_sync_storage_kolab4.php b/lib/kolab_sync_storage_kolab4.php new file mode 100644 --- /dev/null +++ b/lib/kolab_sync_storage_kolab4.php @@ -0,0 +1,566 @@ + | + | | + | 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 | + +--------------------------------------------------------------------------+ +*/ + +/** + * Storage handling class with Kolab 4 support (IMAP + CalDAV + CardDAV) + */ +class kolab_sync_storage_kolab4 extends kolab_sync_storage +{ + protected $davStorage = null; + protected $relationSupport = false; + + /** + * This implements the 'singleton' design pattern + * + * @return kolab_sync_storage_kolab4 The one and only instance + */ + public static function get_instance() + { + if (!self::$instance) { + self::$instance = new kolab_sync_storage_kolab4(); + self::$instance->startup(); // init AFTER object was linked with self::$instance + } + + return self::$instance; + } + + /** + * Class initialization + */ + public function startup() + { + $sync = kolab_sync::get_instance(); + + if ($sync->username === null || $sync->password === null) { + throw new Exception("Unsupported storage handler use!"); + } + + $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($sync->username) . ':' . rawurlencode($sync->password) . '@', $url); + + $this->davStorage = new kolab_storage_dav($url); // DAV + $this->storage = $sync->get_storage(); // IMAP + + // set additional header used by libkolab + $this->storage->set_options([ + 'skip_deleted' => true, + 'threading' => false, + ]); + + // Disable paging + $this->storage->set_pagesize(999999); + } + + /** + * Get list of folders available for sync + * + * @param string $deviceid Device identifier + * @param string $type Folder (class) type + * @param bool $flat_mode Enables flat-list mode + * + * @return array|bool List of mailbox folders, False on backend failure + */ + public function folders_list($deviceid, $type, $flat_mode = false) + { + $list = []; + + // get mail folders subscribed for sync + if ($type === self::MODEL_EMAIL) { + $folderdata = $this->folder_meta(); + + if (!is_array($folderdata)) { + return false; + } + + $special_folders = $this->storage->get_special_folders(true); + $type_map = [ + 'drafts' => 3, + 'trash' => 4, + 'sent' => 5, + ]; + + // Get the folders "subscribed" for activesync + foreach ($folderdata as $folder => $meta) { + if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) + || empty($meta['FOLDER'][$deviceid]['S']) + ) { + continue; + } + + // Force numeric folder name to be a string (T1283) + $folder = (string) $folder; + + // Activesync folder properties + $folder_data = $this->folder_data($folder, 'mail'); + + // Set proper type for special folders + if (($type = array_search($folder, $special_folders)) && isset($type_map[$type])) { + $folder_data['type'] = $type_map[$type]; + } + + $list[$folder_data['serverId']] = $folder_data; + } + } + else if (in_array($type, [self::MODEL_CONTACTS, self::MODEL_CALENDAR, self::MODEL_TASKS])) { + if (!empty($this->folders)) { + foreach ($this->folders as $unique_key => $folder) { + if (strpos($unique_key, "DAV:$type:") === 0) { + $folder_data = $this->folder_data($folder, $type); + $list[$folder_data['serverId']] = $folder_data; + } + } + } + + // TODO: For now all DAV folders are subscribed + + if (empty($list)) { + foreach ($this->davStorage->get_folders($type) as $folder) { + $folder_data = $this->folder_data($folder, $type); + $list[$folder_data['serverId']] = $folder_data; + + // Store all folder objects in internal cache, otherwise + // Any access to the folder (or list) will invoke excessive DAV requests + $unique_key = $folder_data['serverId'] . ":$deviceid:$type"; + $this->folders[$unique_key] = $folder; + } + } + } +/* + // TODO + if ($flat_mode) { + $list = $this->folders_list_flat($list, $type, $typedata); + } +*/ + return $list; + } + + /** + * Creates folder and subscribes to the device + * + * @param string $name Folder name (UTF8) + * @param int $type Folder (ActiveSync) type + * @param string $deviceid Device identifier + * @param ?string $parentid Parent folder identifier + * + * @return string|false New folder identifier on success, False on failure + */ + public function folder_create($name, $type, $deviceid, $parentid = null) + { + // Mail folder + if ($type <= 6 || $type == 12) { + $parent = null; + $name = rcube_charset::convert($name, kolab_sync::CHARSET, 'UTF7-IMAP'); + + if ($parentid) { + $parent = $this->folder_id2name($parentid, $deviceid); + + if ($parent === null) { + throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::PARENT_NOT_FOUND); + } + } + + if ($parent !== null) { + $delim = $this->storage->get_hierarchy_delimiter(); + $name = $parent . $delim . $name; + } + + if ($this->storage->folder_exists($name)) { + throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::FOLDER_EXISTS); + } + + // TODO: Support setting folder types? + + $created = $this->storage->create_folder($name, true); + + if ($created) { + // Set ActiveSync subscription flag + $this->folder_set($name, $deviceid, 1); + + return $this->folder_id($name, 'mail'); + } + + // Special case when client tries to create a subfolder of INBOX + // which is not possible on Cyrus-IMAP (T2223) + if ($parent == 'INBOX' && stripos($this->last_error(), 'invalid') !== false) { + throw new Syncroton_Exception('', Syncroton_Exception_Status_FolderCreate::SPECIAL_FOLDER); + } + + return false; + } + else if ($type == 8 || $type == 13 || $type == 7 || $type == 15 || $type == 9 || $type == 14) { + // DAV folder + $type = preg_replace('|\..*|', '', self::type_activesync2kolab($type)); + + // TODO: Folder hierarchy support + + // Check if folder exists + foreach ($this->davStorage->get_folders($type) as $folder) { + if ($folder->get_name() == $name) { + throw new Syncroton_Exception_Status_FolderCreate(Syncroton_Exception_Status_FolderCreate::FOLDER_EXISTS); + } + } + + $props = ['name' => $name, 'type' => $type]; + + if ($id = $this->davStorage->folder_update($props)) { + return "DAV:{$type}:{$id}"; + } + + return false; + } + + throw new \Exception("Not implemented"); + } + + /** + * Renames a folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $new_name New folder name (UTF8) + * @param ?string $parentid Folder parent identifier + * + * @return bool True on success, False on failure + */ + public function folder_rename($folderid, $deviceid, $new_name, $parentid) + { + // DAV folder + if (strpos($folderid, 'DAV:') === 0) { + [, $type, $id] = explode(':', $folderid); + $props = [ + 'id' => $id, + 'name' => $new_name, + 'type' => $type, + ]; + + // TODO: Folder hierarchy support + + return $this->davStorage->folder_update($props) !== false; + } + + // Mail folder + $old_name = $this->folder_id2name($folderid, $deviceid); + + if ($parentid) { + $parent = $this->folder_id2name($parentid, $deviceid); + } + + $name = rcube_charset::convert($new_name, kolab_sync::CHARSET, 'UTF7-IMAP'); + + if (isset($parent)) { + $delim = $this->storage->get_hierarchy_delimiter(); + $name = $parent . $delim . $name; + } + + if ($name === $old_name) { + return true; + } + + $this->folder_meta = null; + + return $this->storage->rename_folder($old_name, $name); + } + + /** + * Deletes folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * + * @return bool True on success, False otherwise + */ + public function folder_delete($folderid, $deviceid) + { + // DAV folder + if (strpos($folderid, 'DAV:') === 0) { + [, $type, $id] = explode(':', $folderid); + + return $this->davStorage->folder_delete($id, $type) !== false; + } + + // Mail folder + $name = $this->folder_id2name($folderid, $deviceid); + + unset($this->folder_meta[$name]); + + return $this->storage->delete_folder($name); + } + + /** + * Deletes contents of a folder + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param bool $recursive Apply to the folder and its subfolders + * + * @return bool True on success, False otherwise + */ + public function folder_empty($folderid, $deviceid, $recursive = false) + { + // DAV folder + if (strpos($folderid, 'DAV:') === 0) { + [, $type, $id] = explode(':', $folderid); + + if ($folder = $this->davStorage->get_folder($id, $type)) { + return $folder->delete_all(); + } + + // TODO: $recursive=true + + return false; + } + + // Mail folder + return parent::folder_empty($folderid, $deviceid, $recursive); + } + + /** + * Returns folder data in Syncroton format + */ + protected function folder_data($folder, $type) + { + // Mail folders + if (strpos($type, 'mail') === 0) { + return parent::folder_data($folder, $type); + } + + // DAV folders + return [ + 'serverId' => "DAV:{$type}:{$folder->id}", + 'parentId' => 0, // TODO: Folder hierarchy + 'displayName' => $folder->get_name(), + 'type' => $this->type_kolab2activesync($type), + ]; + } + + /** + * Builds folder ID based on folder name + * + * @param string $name Folder name (UTF7-IMAP) + * @param string $type Kolab folder type + * + * @return string Folder identifier (up to 64 characters) + */ + protected function folder_id($name, $type = null) + { + if (!$type) { + $type = 'mail'; + } + + // ActiveSync expects folder identifiers to be max.64 characters + // So we can't use just folder name + $name = (string) $name; + + if ($name === '') { + return null; + } + + if (strpos($type, 'mail') !== 0) { + throw new Exception("Unsupported folder_id() call on a DAV folder"); + } + + if (isset($this->folder_uids[$name])) { + return $this->folder_uids[$name]; + } +/* + @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves. + There's one inconvenience of this solution: folder name/type change + would be handled in ActiveSync as delete + create. + @TODO: Consider using MAILBOXID (RFC8474) that Cyrus v3 supports + + // get folders unique identifier + $folderdata = $this->storage->get_metadata($name, self::UID_KEY); + + if ($folderdata && !empty($folderdata[$name])) { + $uid = $folderdata[$name][self::UID_KEY]; + return $this->folder_uids[$name] = $uid; + } +*/ + if (strcasecmp($name, 'INBOX') === 0) { + // INBOX is always inbox, prevent from issues related with a change of + // folder type annotation (it can be initially unset). + $type = 'mail.inbox'; + } + + // Add type to folder UID hash, so type change can be detected by Syncroton + $uid = $name . '!!' . $type; + $uid = md5($uid); + + return $this->folder_uids[$name] = $uid; + } + + /** + * Returns IMAP folder name + * + * @param string $id Folder identifier + * @param string $deviceid Device dentifier + * + * @return null|string Folder name (UTF7-IMAP) + */ + public function folder_id2name($id, $deviceid) + { + // TODO: This method should become protected and be used for mail folders only + if (strpos($id, 'DAV:') === 0) { + throw new Exception("Unsupported folder_id2name() call on a DAV folder"); + } + + // check in cache first + if (!empty($this->folder_uids)) { + if (($name = array_search($id, $this->folder_uids)) !== false) { + return $name; + } + } + + // get all folders of specified type + $folderdata = $this->folder_meta(); + + if (!is_array($folderdata) || $id === null) { + return null; + } + + // check if folders are "subscribed" for activesync + foreach ($folderdata as $folder => $meta) { + if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid]) + || empty($meta['FOLDER'][$deviceid]['S']) + ) { + continue; + } + + if ($uid = $this->folder_id($folder, 'mail')) { + $this->folder_uids[$folder] = $uid; + } + + if ($uid === $id) { + $name = $folder; + } + } + + return $name ?? null; + } + + /** + * Gets kolab_storage_folder object from Activesync folder ID. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * + * @return ?kolab_storage_folder + */ + public function getFolder($folderid, $deviceid, $type) + { + if (strpos($folderid, 'DAV:') !== 0) { + throw new Exception("Unsupported getFolder() call on a mail folder"); + } + + $unique_key = "$folderid:$deviceid:$type"; + + if (array_key_exists($unique_key, $this->folders)) { + return $this->folders[$unique_key]; + } + + [, $type, $id] = explode(':', $folderid); + + return $this->folders[$unique_key] = $this->davStorage->get_folder($id, $type); + } + + /** + * Gets Activesync preferences for a folder. + * + * @param string $folderid Folder identifier + * @param string $deviceid Device identifier + * @param string $type Activesync model name (folder type) + * + * @return array Folder preferences + */ + public function getFolderConfig($folderid, $deviceid, $type) + { + // TODO: Get "alarms" from the DAV folder props, or implement + // a storage for folder properties + return [ + 'ALARMS' => true, + ]; + } + + /** + * Return last storage error + */ + public static function last_error() + { + // TODO + return null; + } + + /** + * Subscribe default set of folders on device registration + */ + protected function device_init_subscriptions($deviceid) + { + $config = rcube::get_instance()->config; + $mode = (int) $config->get('activesync_init_subscriptions'); + + $subscribed_folders = null; + + // Special folders only + if (!$mode) { + $all_folders = $this->storage->get_special_folders(true); + // We do not subscribe to the Spam folder by default, same as the old Kolab driver does + unset($all_folders['junk']); + $all_folders = array_unique(array_merge(['INBOX'], array_values($all_folders))); + } + // other modes + elseif (($mode & self::INIT_ALL_PERSONAL) || ($mode & self::INIT_ALL_OTHER) || ($mode & self::INIT_ALL_SHARED)) { + $all_folders = $this->storage->list_folders(); + if (($mode & self::INIT_SUB_PERSONAL) || ($mode & self::INIT_SUB_OTHER) || ($mode & self::INIT_SUB_SHARED)) { + $subscribed_folders = $this->storage->list_folders_subscribed(); + } + } + else { + $all_folders = $this->storage->list_folders_subscribed(); + } + + foreach ($all_folders as $folder) { + $ns = strtoupper($this->storage->folder_namespace($folder)); + + // subscribe the folder according to configured mode + // and folder namespace/subscription status + if (!$mode + || ($mode & constant("self::INIT_ALL_{$ns}")) + || (($mode & constant("self::INIT_SUB_{$ns}")) && ($subscribed_folders === null || in_array($folder, $subscribed_folders))) + ) { + $this->folder_set($folder, $deviceid, 1); + } + } + + // TODO: Subscribe personal DAV folders, for now we assume all are subscribed + // TODO: Subscribe shared DAV folders + } +} diff --git a/lib/kolab_sync_timezone_converter.php b/lib/kolab_sync_timezone_converter.php --- a/lib/kolab_sync_timezone_converter.php +++ b/lib/kolab_sync_timezone_converter.php @@ -33,7 +33,7 @@ /** * holds the instance of the singleton * - * @var kolab_sync_timezone_onverter + * @var kolab_sync_timezone_converter */ private static $_instance = NULL; @@ -180,7 +180,7 @@ * If {@see $_expectedTimezone} is set then the method will return this timezone if it matches. * * @param string|array $_offsets Activesync timezone definition - * @param string $_expectedTomezone Expected timezone name + * @param string $_expectedTimezone Expected timezone name * * @return string Expected timezone name */ @@ -359,8 +359,10 @@ * Check if the given {@param $_standardTransition} and {@param $_daylightTransition} * match to the object property {@see $_offsets} * - * @param array $standardTransition - * @param array $daylightTransition + * @param array $_standardTransition + * @param array $_daylightTransition + * @param array $_offsets + * @param DateTimeZone $tz * * @return bool */ @@ -491,7 +493,7 @@ * Used e.g. when reverse-generating ActiveSync Timezone Offset Information * based on a given Timezone, {@see getOffsetsForTimezone} * - * @return unknown_type + * @return array */ protected function _getOffsetsTemplate() { diff --git a/lib/kolab_sync_transaction_manager.php b/lib/kolab_sync_transaction_manager.php --- a/lib/kolab_sync_transaction_manager.php +++ b/lib/kolab_sync_transaction_manager.php @@ -78,7 +78,7 @@ } /** - * @return Tinebase_TransactionManager + * @return self */ public static function getInstance() { @@ -92,9 +92,10 @@ /** * starts a transaction * - * @param mixed $_transactionable - * @return string transactionId - * @throws Tinebase_Exception_UnexpectedValue + * @param mixed $_transactionable + * + * @return string Transaction Id + * @throws Exception */ public function startTransaction($_transactionable) { diff --git a/tests/Sync/FoldersTest.php b/tests/Sync/FoldersTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/FoldersTest.php @@ -0,0 +1,389 @@ +deleteTestFolder('Test Folder', 'mail'); + $this->deleteTestFolder('Test Folder New', 'mail'); + $this->deleteTestFolder('Test Contacts Folder', 'contact'); + $this->deleteTestFolder('Test Contacts New', 'contact'); + + $request = << + + + 0 + + EOF; + + $response = $this->request($request, 'FolderSync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + // Note: We're expecting activesync_init_subscriptions=0 here. + if ($this->isStorageDriver('kolab4')) { + $folders = [ + ['Calendar', Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR], + ['Contacts', Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT], + ['INBOX', Syncroton_Command_FolderSync::FOLDERTYPE_INBOX], + ['Drafts', Syncroton_Command_FolderSync::FOLDERTYPE_DRAFTS], + ['Sent', Syncroton_Command_FolderSync::FOLDERTYPE_SENTMAIL], + ['Trash', Syncroton_Command_FolderSync::FOLDERTYPE_DELETEDITEMS], + ['Tasks', Syncroton_Command_FolderSync::FOLDERTYPE_TASK], + ]; + + } else { + $folders = [ + ['Calendar', Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR], + ['Contacts', Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT], + ['INBOX', Syncroton_Command_FolderSync::FOLDERTYPE_INBOX], + ['Drafts', Syncroton_Command_FolderSync::FOLDERTYPE_DRAFTS], + ['Sent', Syncroton_Command_FolderSync::FOLDERTYPE_SENTMAIL], + ['Trash', Syncroton_Command_FolderSync::FOLDERTYPE_DELETEDITEMS], + ['Notes', Syncroton_Command_FolderSync::FOLDERTYPE_NOTE], + ['Tasks', Syncroton_Command_FolderSync::FOLDERTYPE_TASK], + ]; + } + + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame(strval(count($folders)), $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue); + + foreach ($folders as $idx => $folder) { + $this->assertSame($folder[0], $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item($idx)->nodeValue); + $this->assertSame((string) $folder[1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item($idx)->nodeValue); + $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item($idx)->nodeValue); + } + + // Test with multi-folder support enabled + self::$deviceType = 'iphone'; + + $response = $this->request($request, 'FolderSync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + if ($this->isStorageDriver('kolab4')) { + $folders = [ + ['Calendar', Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED], + // Note: Kolab 4 with Cyrus DAV uses Addressbook, but Kolab 3 with iRony would use 'Contacts' + ['/^(Contacts|Addressbook)$/', Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED], + ['INBOX', Syncroton_Command_FolderSync::FOLDERTYPE_INBOX], + ['Drafts', Syncroton_Command_FolderSync::FOLDERTYPE_DRAFTS], + ['Sent', Syncroton_Command_FolderSync::FOLDERTYPE_SENTMAIL], + ['Trash', Syncroton_Command_FolderSync::FOLDERTYPE_DELETEDITEMS], + // Note: For now Kolab 4 uses the same Calendar folder for calendar and tasks + ['/^(Tasks|Calendar)$/', Syncroton_Command_FolderSync::FOLDERTYPE_TASK_USER_CREATED] + ]; + } + + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame(strval(count($folders)), $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue); + + foreach ($folders as $idx => $folder) { + $displayName = $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item($idx)->nodeValue; + if (str_starts_with($folder[0], '/')) { + $this->assertMatchesRegularExpression($folder[0], $displayName); + } else { + $this->assertSame($folder[0], $displayName); + } + $this->assertSame((string) $folder[1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item($idx)->nodeValue); + $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item($idx)->nodeValue); + $idx++; + } + + // After we switched to multi-folder supported mode we expect next FolderSync + // to delete the old "collective" folders + $request = << + + + 1 + + EOF; + + $response = $this->request($request, 'FolderSync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $deleted = $this->isStorageDriver('kolab4') ? 3 : 4; // No Notes folder in Kolab4 + $syncKey = 2; + + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval($syncKey), $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame(strval($deleted), $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue); + $this->assertSame($deleted, $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete")->length); + + return $syncKey; + } + + /** + * Test FolderCreate command + * + * @depends testFolderSync + */ + public function testFolderCreate($syncKey) + { + // Multi-folder mode + self::$deviceType = 'iphone'; + + // Create a mail folder + $folderName1 = 'Test Folder'; + $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED; + $request = << + + + {$syncKey} + 0 + {$folderName1} + {$folderType} + + EOF; + + $response = $this->request($request, 'FolderCreate'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:FolderCreate/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$syncKey), $xpath->query("//ns:FolderCreate/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("//ns:FolderCreate/ns:ServerId")->count()); + $folder1 = $xpath->query("//ns:FolderCreate/ns:ServerId")->item(0)->nodeValue; + + // Note: After FolderCreate there are no changes in the following FolderSync expected + + // Create a contacts folder + $folderName2 = 'Test Contacts Folder'; + $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED; + $request = << + + + {$syncKey} + 0 + {$folderName2} + {$folderType} + + EOF; + + $response = $this->request($request, 'FolderCreate'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:FolderCreate/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$syncKey), $xpath->query("//ns:FolderCreate/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("//ns:FolderCreate/ns:ServerId")->count()); + $folder2 = $xpath->query("//ns:FolderCreate/ns:ServerId")->item(0)->nodeValue; + + // Note: After FolderCreate there are no changes in the following FolderSync expected + + // TODO: Test folder with a parent + + return [ + 'SyncKey' => $syncKey, + 'folders' => [ + $folder1, + $folder2, + ] + ]; + } + + /** + * Test FolderUpdate command + * + * @depends testFolderCreate + */ + public function testFolderUpdate($params) + { + // Multi-folder mode + self::$deviceType = 'iphone'; + + // Test renaming a mail folder + $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED; + $request = << + + + {$params['SyncKey']} + {$params['folders'][0]} + + Test Folder New + {$folderType} + + EOF; + + $response = $this->request($request, 'FolderUpdate'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:FolderUpdate/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderUpdate/ns:SyncKey")->item(0)->nodeValue); + + // Test FolderSync after folder update, get the new folder id (for delete test) + $request = << + + + {$params['SyncKey']} + + EOF; + + $response = $this->request($request, 'FolderSync'); + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + // Note we expect Add+Delete here, instead of Update (but this could change in the future) + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("//ns:FolderSync/ns:Changes/ns:Add")->length); + $this->assertSame(1, $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete")->length); + $this->assertSame($params['folders'][0], $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete/ns:ServerId")->item(0)->nodeValue); + $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item(0)->nodeValue); + $this->assertSame('Test Folder New', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item(0)->nodeValue); + $this->assertSame(strval($folderType), $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item(0)->nodeValue); + $params['folders'][0] = $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ServerId")->item(0)->nodeValue; + + // Test renaming a contacts folder + $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED; + $request = << + + + {$params['SyncKey']} + {$params['folders'][1]} + + Test Contacts New + {$folderType} + + EOF; + + $response = $this->request($request, 'FolderUpdate'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:FolderUpdate/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderUpdate/ns:SyncKey")->item(0)->nodeValue); + + // Test FolderSync after folder update, get the new folder id (for delete test) + $request = << + + + {$params['SyncKey']} + + EOF; + + $response = $this->request($request, 'FolderSync'); + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue); + + if ($this->isStorageDriver('kolab4')) { + // Note we expect Update here, not Add+Delete, folder ID does not change + $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue); + $this->assertSame($params['folders'][1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Update/ns:ServerId")->item(0)->nodeValue); + $this->assertSame('Test Contacts New', $xpath->query("//ns:FolderSync/ns:Changes/ns:Update/ns:DisplayName")->item(0)->nodeValue); + $this->assertSame(strval($folderType), $xpath->query("//ns:FolderSync/ns:Changes/ns:Update/ns:Type")->item(0)->nodeValue); + } else { + // Note we expect Add+Delete here, instead of Update (but this could change in the future) + $this->assertSame('2', $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue); + $this->assertSame($params['folders'][1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete/ns:ServerId")->item(0)->nodeValue); + $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item(0)->nodeValue); + $this->assertSame('Test Contacts New', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item(0)->nodeValue); + $this->assertSame(strval($folderType), $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item(0)->nodeValue); + $params['folders'][1] = $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ServerId")->item(0)->nodeValue; + } + + // TODO: Test folder with a parent change + // TODO: Assert the folder name has changed in the storage + // TODO: Test Sync after a DAV folder rename made in another client + + return $params; + } + + /** + * Test FolderDelete command + * + * @depends testFolderUpdate + */ + public function testFolderDelete($params) + { + // Multi-folder mode + self::$deviceType = 'iphone'; + + // Delete mail folder + $request = << + + + {$params['SyncKey']} + {$params['folders'][0]} + + EOF; + + $response = $this->request($request, 'FolderDelete'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:FolderDelete/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderDelete/ns:SyncKey")->item(0)->nodeValue); + + // Note: After FolderDelete there are no changes in the following FolderSync expected + + // Delete contacts folder + $request = << + + + {$params['SyncKey']} + {$params['folders'][1]} + + EOF; + + $response = $this->request($request, 'FolderDelete'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:FolderDelete/ns:Status")->item(0)->nodeValue); + $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderDelete/ns:SyncKey")->item(0)->nodeValue); + + // Note: After FolderDelete there are no changes in the following FolderSync expected + + // TODO: Assert the folders no longer exist + } +} diff --git a/tests/Sync/ItemOperationsTest.php b/tests/Sync/ItemOperationsTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/ItemOperationsTest.php @@ -0,0 +1,81 @@ +registerDevice(); + + // TODO: Test invalid folder ID + $collectionId = 'AAAAAAAAAAAA'; + $request = << + + + + {$collectionId} + + + + EOF; + + $response = $this->request($request, 'ItemOperations'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:ItemOperations/ns:Status")->item(0)->nodeValue); + $this->assertSame( + strval(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR), + $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/ns:Status")->item(0)->nodeValue + ); + $this->assertSame( + $collectionId, + $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/AirSync:CollectionId")->item(0)->nodeValue + ); + + // Test Trash folder + $collectionId = array_search('Trash', $this->folders); + $this->assertIsString($collectionId); + + $request = << + + + + {$collectionId} + + + + EOF; + + $response = $this->request($request, 'ItemOperations'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:ItemOperations/ns:Status")->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/ns:Status")->item(0)->nodeValue); + $this->assertSame($collectionId, $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/AirSync:CollectionId")->item(0)->nodeValue); + + // TODO: Test DAV folder + // TODO: Test a folder with subfolders + // TODO: Test non-empty folder and assert that all objects are gone + $this->markTestIncomplete(); + } + + /** + * Test ItemOperations::Fetch request + */ + public function testFetch() + { + $this->markTestIncomplete(); + } +} diff --git a/tests/Sync/MeetingResponseTest.php b/tests/Sync/MeetingResponseTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/MeetingResponseTest.php @@ -0,0 +1,131 @@ +emptyTestFolder($davFolder = 'Calendar', 'event'); + $this->emptyTestFolder('INBOX', 'mail'); + + $this->registerDevice(); + + // Do the initial INBOX sync + $folderId = '38b950ebd62cd9a66929c89615d0fc04'; // INBOX + $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); + + // Append an invitation email, and sync it + $sync = \kolab_sync::get_instance(); + $replace = [ + '$from' => 'test.test@domain.tld', + '$to' => $sync->config->get('activesync_test_username'), + ]; + $this->appendMail('INBOX', 'mail.itip1', $replace); + + $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 = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); + $this->assertSame(strval(++$syncKey), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); + + $serverId = $xpath->query("ns:Commands/ns:Add/ns:ServerId", $root)->item(0)->nodeValue; + + $root = $xpath->query("ns:Commands/ns:Add/ns:ApplicationData", $root)->item(0); + $this->assertSame('You\'ve been invited to "Test"', $xpath->query("Email:Subject", $root)->item(0)->nodeValue); + $this->assertSame('Organizer ', $xpath->query("Email:From", $root)->item(0)->nodeValue); + $this->assertSame($replace['$to'], $xpath->query("Email:To", $root)->item(0)->nodeValue); + $this->assertSame('0', $xpath->query("Email:Read", $root)->item(0)->nodeValue); + $this->assertSame('IPM.Schedule.Meeting.Request', $xpath->query("Email:MessageClass", $root)->item(0)->nodeValue); + $this->assertSame('urn:content-classes:calendarmessage', $xpath->query("Email:ContentClass", $root)->item(0)->nodeValue); + $root = $xpath->query("Email:MeetingRequest", $root)->item(0); + $this->assertSame('0', $xpath->query("Email:AllDayEvent", $root)->item(0)->nodeValue); + $this->assertSame('2023-12-07T13:00:00.000Z', $xpath->query("Email:StartTime", $root)->item(0)->nodeValue); + $this->assertSame('2023-12-07T13:30:00.000Z', $xpath->query("Email:EndTime", $root)->item(0)->nodeValue); + $this->assertSame('test.test@domain.tld', $xpath->query("Email:Organizer", $root)->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("Email:ResponseRequested", $root)->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("Email:DisallowNewTimeProposal", $root)->item(0)->nodeValue); + + // Accept the invitation + $request = << + + + + 1 + {$folderId} + {$serverId} + + + EOF; + + $response = $this->request($request, 'MeetingResponse'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + $xpath->registerNamespace('MeetingResponse', 'uri:MeetingResponse'); + + $root = $xpath->query("//MeetingResponse:MeetingResponse/MeetingResponse:Result")->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); + $this->assertSame($serverId, $xpath->query("ns:RequestId", $root)->item(0)->nodeValue); + $this->assertStringMatchesFormat("CRC%s", $xpath->query("ns:CalendarId", $root)->item(0)->nodeValue); + } +} diff --git a/tests/Sync/MoveItemsTest.php b/tests/Sync/MoveItemsTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/MoveItemsTest.php @@ -0,0 +1,334 @@ +emptyTestFolder('INBOX', 'mail'); + $this->emptyTestFolder('Trash', 'mail'); + $uid = $this->appendMail('INBOX', 'mail.sync1'); + $this->registerDevice(); + + $inbox = array_search('INBOX', $this->folders); + $trash = array_search('Trash', $this->folders); + + // Initial sync + $request = << + + + + + 0 + {$inbox} + + + 0 + {$trash} + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + // Sync mail from INBOX and Trash + $request = << + + + + + 1 + {$inbox} + 1 + + + 1 + {$trash} + 1 + + + + 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")->count()); + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); + $root = $xpath->query("ns:Commands/ns:Add", $root)->item(0); + $this->assertSame('test sync', $xpath->query("ns:ApplicationData/Email:Subject", $root)->item(0)->nodeValue); + + // Move the message to $trash + $request = << + + + + {$inbox}::{$uid} + {$inbox} + {$trash} + + + EOF; + + $response = $this->request($request, 'MoveItems'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + $xpath->registerNamespace('Move', 'uri:Move'); + + $root = $xpath->query("//Move:MoveItems/Move:Response")->item(0); + $this->assertSame('3', $xpath->query("Move:Status", $root)->item(0)->nodeValue); + $this->assertSame("{$inbox}::{$uid}", $xpath->query("Move:SrcMsgId", $root)->item(0)->nodeValue); + $serverId = $xpath->query("Move:DstMsgId", $root)->item(0)->nodeValue; + + // Sync mail from INBOX and Trash + $request = << + + + + + 2 + {$inbox} + 1 + + + 1 + {$trash} + 1 + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); // INBOX + $this->assertSame($inbox, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("ns:Commands/ns:Delete", $root)->count()); + $this->assertSame("$inbox::$uid", $xpath->query("ns:Commands/ns:Delete/ns:ServerId", $root)->item(0)->nodeValue); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(1); // Trash + $this->assertSame($trash, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); + $this->assertSame('test sync', $xpath->query("ns:Commands/ns:Add/ns:ApplicationData/Email:Subject", $root)->item(0)->nodeValue); + } + + /** + * Test moving a contact + */ + public function testMoveContact() + { + // Test with multi-folder support enabled + self::$deviceType = 'iphone'; + + $davFolder = $this->isStorageDriver('kolab') ? 'Contacts' : 'Addressbook'; + $this->emptyTestFolder($davFolder, 'contact'); + $this->deleteTestFolder($folderName = 'Test Contacts Folder', 'contact'); + $this->appendObject($davFolder, 'contact.vcard1', 'contact'); + + $this->registerDevice(); + + $srcFolderId = array_search($davFolder, $this->folders); + + // Create a contacts folder + $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED; + $request = << + + + 1 + 0 + {$folderName} + {$folderType} + + EOF; + + $response = $this->request($request, 'FolderCreate'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $dstFolderId = $xpath->query("//ns:FolderCreate/ns:ServerId")->item(0)->nodeValue; + + // Sync both folders + $request = << + + + + + Contacts + 0 + {$srcFolderId} + + + Contacts + 0 + {$dstFolderId} + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $request = << + + + + + Contacts + 1 + {$srcFolderId} + + + + + 1 + 5120 + + 1 + + + + Contacts + 1 + {$dstFolderId} + + + + + 1 + 5120 + + 1 + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame($srcFolderId, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); + $this->assertSame('Jane', $xpath->query("ns:Commands/ns:Add/ns:ApplicationData/Contacts:FirstName", $root)->item(0)->nodeValue); + $srcMsgId = $xpath->query("ns:Commands/ns:Add/ns:ServerId", $root)->item(0)->nodeValue; + + // Move the message to the other folder + $request = << + + + + {$srcMsgId} + {$srcFolderId} + {$dstFolderId} + + + EOF; + + $response = $this->request($request, 'MoveItems'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + $xpath->registerNamespace('Move', 'uri:Move'); + + $root = $xpath->query("//Move:MoveItems/Move:Response")->item(0); + $this->assertSame('3', $xpath->query("Move:Status", $root)->item(0)->nodeValue); + $this->assertSame($srcMsgId, $xpath->query("Move:SrcMsgId", $root)->item(0)->nodeValue); + $dstMsgId = $xpath->query("Move:DstMsgId", $root)->item(0)->nodeValue; + + // Sync the folders again + $request = << + + + + + Contacts + 2 + {$srcFolderId} + + + + + 1 + 5120 + + 1 + + + + Contacts + 1 + {$dstFolderId} + + + + + 1 + 5120 + + 1 + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); // src folder + $this->assertSame($srcFolderId, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("ns:Commands/ns:Delete", $root)->count()); + $this->assertSame($srcMsgId, $xpath->query("ns:Commands/ns:Delete/ns:ServerId", $root)->item(0)->nodeValue); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(1); // dst folder + $this->assertSame($dstFolderId, $xpath->query("ns:CollectionId", $root)->item(0)->nodeValue); + $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count()); + $this->assertSame('Jane', $xpath->query("ns:Commands/ns:Add/ns:ApplicationData/Contacts:FirstName", $root)->item(0)->nodeValue); + $this->assertSame($dstMsgId, $xpath->query("ns:Commands/ns:Add/ns:ServerId", $root)->item(0)->nodeValue); + + $this->deleteTestFolder($folderName, 'contact'); + } +} diff --git a/tests/Sync/OptionsTest.php b/tests/Sync/OptionsTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/OptionsTest.php @@ -0,0 +1,16 @@ +request('OPTIONS', ''); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('14', $response->getHeader('MS-Server-ActiveSync')[0]); + $this->assertStringContainsString('14.1', $response->getHeader('MS-ASProtocolVersions')[0]); + $this->assertStringContainsString('FolderSync', $response->getHeader('MS-ASProtocolCommands')[0]); + } +} diff --git a/tests/Sync/ProvisionTest.php b/tests/Sync/ProvisionTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/ProvisionTest.php @@ -0,0 +1,45 @@ + + + + + + moto e(6) plus + 000000000000000 + pokerp_reteu_64 + Android 9.58-8 + Polish (Poland) + + + + + + MS-EAS-Provisioning-WBXML + + + + EOF; + + $response = $this->request($request, 'Provision'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:Provision/ns:Status")->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("//ns:Provision/Settings:DeviceInformation/Settings:Status")->item(0)->nodeValue); + $this->assertSame('2', $xpath->query("//ns:Provision/ns:Policies/ns:Policy/ns:Status")->item(0)->nodeValue); + + // TODO: Assert the properties have been set + } +} diff --git a/tests/Sync/SettingsTest.php b/tests/Sync/SettingsTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/SettingsTest.php @@ -0,0 +1,80 @@ + + + + + + + + EOF; + + $response = $this->request($request, 'Settings'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = self::fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:Settings/ns:Status")->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("//ns:Settings/ns:UserInformation/ns:Status")->item(0)->nodeValue); + $this->assertSame( + self::$username, + $xpath->query("//ns:Settings/ns:UserInformation/ns:Get/ns:Accounts/ns:Account/ns:EmailAddresses/ns:PrimarySmtpAddress")->item(0)->nodeValue + ); + } + + /** + * Test Settings command + */ + public function testSettingsDeviceInfomation() + { + // Test device info update + $request = << + + + + + moto plus + 111111111 + fn + Android 10 + English + + + + + EOF; + + $response = $this->request($request, 'Settings'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $this->assertSame('1', $xpath->query("//ns:Settings/ns:Status")->item(0)->nodeValue); + $this->assertSame('1', $xpath->query("//ns:Settings/ns:DeviceInformation/ns:Set/ns:Status")->item(0)->nodeValue); + + // TODO: Assert the properties have been set + } + + /** + * Test Settings command regarding OOF + */ + public function testSettingsOOF() + { + // TODO: Test OOF settings + $this->markTestIncomplete(); + } +} diff --git a/tests/Sync/Sync/CalendarTest.php b/tests/Sync/Sync/CalendarTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/Sync/CalendarTest.php @@ -0,0 +1,75 @@ +emptyTestFolder($davFolder = 'Calendar', 'event'); + $this->registerDevice(); + + // Test empty folder + $folderId = 'Calendar::Syncroton'; + $syncKey = 0; + $request = << + + + + + Calendar + {$syncKey} + {$folderId} + + + + + + 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(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); + + // Append two event objects and sync them + $this->appendObject($davFolder, 'event.ics1', 'event'); + $this->appendObject($davFolder, 'event.ics2', 'event'); + + $request = str_replace("0", "{$syncKey}", $request); + + $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(2, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); + + $root .= "/ns:Commands/ns:Add"; + $this->assertStringMatchesFormat("CRC%s", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); + $this->assertSame('20240715T170000Z', $xpath->query("{$root}/ns:ApplicationData/Calendar:StartTime")->item(0)->nodeValue); + $this->assertSame('Meeting', $xpath->query("{$root}/ns:ApplicationData/Calendar:Subject")->item(0)->nodeValue); + $this->assertSame('20240714T170000Z', $xpath->query("{$root}/ns:ApplicationData/Calendar:StartTime")->item(1)->nodeValue); + $this->assertSame('Party', $xpath->query("{$root}/ns:ApplicationData/Calendar:Subject")->item(1)->nodeValue); + + return $syncKey; + } +} diff --git a/tests/Sync/Sync/ContactsTest.php b/tests/Sync/Sync/ContactsTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/Sync/ContactsTest.php @@ -0,0 +1,250 @@ +isStorageDriver('kolab') ? 'Contacts' : 'Addressbook'; + $this->emptyTestFolder($davFolder, 'contact'); + $this->deleteTestFolder('Test Contacts Folder', 'contact'); // from other test files + $this->registerDevice(); + + // Test empty contacts folder + $folderId = 'Contacts::Syncroton'; + $syncKey = 0; + $request = << + + + + + Contacts + {$syncKey} + {$folderId} + + + + + 1 + 5120 + + 1 + + + + + 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(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); + + // Append two contact objects and sync them + // TODO: Test a folder with contact groups inside + $this->appendObject($davFolder, 'contact.vcard1', 'contact'); + $this->appendObject($davFolder, 'contact.vcard2', 'contact'); + + $request = str_replace("0", "{$syncKey}", $request); + + $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(2, $xpath->query("{$root}/ns:Commands/ns:Add")->count()); + + $root .= "/ns:Commands/ns:Add"; + $this->assertStringMatchesFormat("CRC%s", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue); + $this->assertSame('Jack', $xpath->query("{$root}/ns:ApplicationData/Contacts:FirstName")->item(0)->nodeValue); + $this->assertSame('Strong', $xpath->query("{$root}/ns:ApplicationData/Contacts:LastName")->item(0)->nodeValue); + $this->assertSame('Jane', $xpath->query("{$root}/ns:ApplicationData/Contacts:FirstName")->item(1)->nodeValue); + $this->assertSame('Doe', $xpath->query("{$root}/ns:ApplicationData/Contacts:LastName")->item(1)->nodeValue); + + return $syncKey; + } + + /** + * Test adding objects from client + * + * @depends testSync + */ + public function testAddFromClient($syncKey) + { + $request = << + + + + + Contacts + {$syncKey} + Contacts::Syncroton + + + + + 1 + 5120 + + 1 + + + + 42 + + Lars + + + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); + $this->assertSame(strval(++$syncKey), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue); + $root = $xpath->query("ns:Responses/ns:Add", $root)->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); + $this->assertSame('42', $xpath->query("ns:ClientId", $root)->item(0)->nodeValue); + $serverId = $xpath->query("ns:ServerId", $root)->item(0)->nodeValue; + $this->assertStringMatchesFormat("CRC%s", $serverId); + + // TODO: Test the content on the server + + return [$syncKey, $serverId]; + } + + /** + * Test updating objects from client + * + * @depends testAddFromClient + */ + public function testChangeFromClient($params) + { + $request = << + + + + + Contacts + {$params[0]} + Contacts::Syncroton + + + + + 1 + 5120 + + 1 + + + + {$params[1]} + + First + Last + + + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); + $this->assertSame(strval(++$params[0]), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue); + $this->assertSame(0, $xpath->query("ns:Responses", $root)->length); + + // TODO: Assert updated content on the server + + return $params; + } + + /** + * Test deleting objects from client + * + * @depends testChangeFromClient + */ + public function testDeleteFromClient($params) + { + $request = << + + + + + Contacts + {$params[0]} + Contacts::Syncroton + + + + + 1 + 5120 + + 1 + + + + {$params[1]} + + + + + + EOF; + + $response = $this->request($request, 'Sync'); + + $this->assertEquals(200, $response->getStatusCode()); + + $dom = $this->fromWbxml($response->getBody()); + $xpath = $this->xpath($dom); + + $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0); + $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue); + $this->assertSame(strval(++$params[0]), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue); + $this->assertSame(0, $xpath->query("ns:Responses", $root)->length); + + // TODO: Assert deleted contact on the server + } +} diff --git a/tests/Sync/Sync/EmailTest.php b/tests/Sync/Sync/EmailTest.php new file mode 100644 --- /dev/null +++ b/tests/Sync/Sync/EmailTest.php @@ -0,0 +1,181 @@ +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); + + return $syncKey; + } + + /** + * Test updating message properties from client + * + * @depends testSync + */ + public function testChangeFromClient($syncKey) + { + $this->markTestIncomplete(); + + return $syncKey; + } + + /** + * Test deleting messages from client + * + * @depends testChangeFromClient + */ + public function testDeleteFromClient($syncKey) + { + $this->markTestIncomplete(); + } +} diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php new file mode 100644 --- /dev/null +++ b/tests/SyncTestCase.php @@ -0,0 +1,362 @@ +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'); + + 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_modseq'); + $db->query('DELETE FROM syncroton_content'); + + self::$client = new \GuzzleHttp\Client([ + 'http_errors' => false, + 'base_uri' => 'http://localhost:8000', + '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; + } + + /** + * 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 ($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("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; + } +} diff --git a/tests/body_converter.php b/tests/Unit/BodyConverterTest.php rename from tests/body_converter.php rename to tests/Unit/BodyConverterTest.php --- a/tests/body_converter.php +++ b/tests/Unit/BodyConverterTest.php @@ -1,6 +1,6 @@ config->set('devel_mode', false); diff --git a/tests/phpunit.xml b/tests/phpunit.xml --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -2,15 +2,11 @@ bootstrap="bootstrap.php" colors="true"> - - body_converter.php - data.php - data_calendar.php - data_tasks.php - globalid_converter.php - message.php - timezone_converter.php - wbxml.php + + Unit + + + Sync diff --git a/tests/src/contact.vcard1 b/tests/src/contact.vcard1 new file mode 100644 --- /dev/null +++ b/tests/src/contact.vcard1 @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:abcdef-0123-4567-89ab-abcdefabcdef +FN:Jane Doe +N:Doe;Jane;J.;; +END:VCARD diff --git a/tests/src/contact.vcard2 b/tests/src/contact.vcard2 new file mode 100644 --- /dev/null +++ b/tests/src/contact.vcard2 @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:abcdef-0123-4567-89ab-abcdefabc123 +FN:Jack Strong +N:Strong;Jack;;; +END:VCARD diff --git a/tests/src/event.ics1 b/tests/src/event.ics1 new file mode 100644 --- /dev/null +++ b/tests/src/event.ics1 @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test/test//NONSGML v1.0//EN +BEGIN:VEVENT +UID:abcdef +DTSTAMP:19970714T170000Z +ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com +DTSTART:20240714T170000Z +DTEND:20240714T180000Z +SUMMARY:Party +END:VEVENT +END:VCALENDAR diff --git a/tests/src/event.ics2 b/tests/src/event.ics2 new file mode 100644 --- /dev/null +++ b/tests/src/event.ics2 @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test/test//NONSGML v1.0//EN +BEGIN:VEVENT +UID:123456 +DTSTAMP:19970714T170000Z +ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com +DTSTART:20240715T170000Z +DTEND:20240715T180000Z +SUMMARY:Meeting +END:VEVENT +END:VCALENDAR diff --git a/tests/src/mail.itip1 b/tests/src/mail.itip1 new file mode 100644 --- /dev/null +++ b/tests/src/mail.itip1 @@ -0,0 +1,62 @@ +MIME-Version: 1.0 +Date: Thu, 07 Dec 2023 13:29:14 +0100 +Message-ID: <14ab307198d32cee00b38ffb54c9e577@nestle.kolab.ch> +From: "Organizer" <$from> +To: <$to> +Subject: You've been invited to "Test" +Content-Type: multipart/alternative; + boundary="=_f39ac9438326f676a8d562e163aa31e0" + +--=_f39ac9438326f676a8d562e163aa31e0 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8; + format=flowed + +*Test* +--=_f39ac9438326f676a8d562e163aa31e0 +Content-Transfer-Encoding: 8bit +Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=event.ics + +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.3//EN +CALSCALE:GREGORIAN +METHOD:REQUEST +BEGIN:VTIMEZONE +TZID:Europe/Warsaw +BEGIN:DAYLIGHT +DTSTART:20230326T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +END:DAYLIGHT +BEGIN:DAYLIGHT +DTSTART:20240331T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20231029T010000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:1154B829349633D80E143AAB5641170A-93BC4FC398A3FD52 +DTSTAMP:20231207T122914Z +CREATED:20231207T122914Z +LAST-MODIFIED:20231207T122914Z +DTSTART;TZID=Europe/Warsaw:20231207T140000 +DTEND;TZID=Europe/Warsaw:20231207T143000 +SUMMARY:Test +SEQUENCE:0 +TRANSP:OPAQUE +ATTENDEE;CN="Attendee Name";PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPA + NT;CUTYPE=INDIVIDUAL;RSVP=TRUE:mailto:$to +ORGANIZER;CN="Organizer Name":mailto:$from +END:VEVENT +END:VCALENDAR + +--=_f39ac9438326f676a8d562e163aa31e0-- diff --git a/tests/src/mail.sync1 b/tests/src/mail.sync1 new file mode 100644 --- /dev/null +++ b/tests/src/mail.sync1 @@ -0,0 +1,10 @@ +Date: Thu, 09 Aug 2012 13:18:31 +0000 +Subject: test sync +Message-ID: +From: "Sync 1" +To: "To 1" , "To 2" +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: base64 + +ZWVlYQ== diff --git a/tests/src/mail.sync2 b/tests/src/mail.sync2 new file mode 100644 --- /dev/null +++ b/tests/src/mail.sync2 @@ -0,0 +1,20 @@ +Date: Thu, 10 Aug 2012 13:18:31 +0000 +Subject: sync test with attachment +Message-ID: +From: user@domain.tld +To: kolab@domain.tld +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="BOUNDARY" + +--BOUNDARY +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: base64 + +ZWVl +--BOUNDARY +Content-Transfer-Encoding: base64 +Content-Type: image/jpeg; name=logo.gif +Content-Disposition: inline; filename=logo.gif; size=2574 + +/9j/4AAQSkZJRgABAgEASABIAAD/4QqARXhpZgAATU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUA +--BOUNDARY--