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/kolab_sync.php b/lib/kolab_sync.php
--- a/lib/kolab_sync.php
+++ b/lib/kolab_sync.php
@@ -28,25 +28,19 @@
*/
class kolab_sync extends rcube
{
- /**
- * Application name
- *
- * @var string
- */
+ /** @var string Application name */
public $app_name = 'ActiveSync for Kolab'; // no double quotes inside
- /**
- * Current user
- *
- * @var rcube_user
- */
- public $user;
-
+ /** @var string|null Request user name */
public $username;
+
+ /** @var string|null Request user password */
public $password;
- public $task = null;
+ public $task;
+
protected $per_user_log_dir;
+ protected $log_dir;
const CHARSET = 'UTF-8';
const VERSION = "2.4.2";
@@ -77,13 +71,14 @@
public function startup()
{
// Initialize Syncroton Logger
- $debug_mode = $this->config->get('activesync_debug') ? kolab_sync_logger::DEBUG : kolab_sync_logger::WARN;
- $this->logger = new kolab_sync_logger($debug_mode);
+ $debug_mode = $this->config->get('activesync_debug') ? kolab_sync_logger::DEBUG : kolab_sync_logger::WARN;
+ $this->logger = new kolab_sync_logger($debug_mode);
+ $this->log_dir = $this->config->get('log_dir');
// 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')));
@@ -132,6 +127,9 @@
}
}
+ // Set log directory per-user
+ $this->set_log_dir($_SERVER['PHP_AUTH_USER']);
+
// Authenticate the user
$userid = $this->authenticate($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']);
}
@@ -144,7 +142,7 @@
$this->plugins->exec_hook('ready', array('task' => 'syncroton'));
- // Set log directory per-user
+ // Set log directory per-user (again, in case the username changed above)
$this->set_log_dir();
// Save user password for Roundcube Framework
@@ -210,7 +208,7 @@
}
// LDAP server failure... send 503 error
- if ($auth['kolab_ldap_error'] ?? null) {
+ if (!empty($auth['kolab_ldap_error'])) {
self::server_error();
}
@@ -228,7 +226,7 @@
$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;
@@ -259,7 +257,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);
@@ -390,16 +388,36 @@
}
+ /**
+ * 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
*/
- protected function set_log_dir()
+ protected function set_log_dir($username = null)
{
- if (empty($this->username)) {
+ if (empty($username)) {
+ $username = $this->username;
+ }
+
+ if (empty($username)) {
return;
}
- $this->logger->set_username($this->username);
+ $this->logger->set_username($username);
$user_debug = (bool) $this->config->get('per_user_logging');
@@ -407,8 +425,7 @@
return;
}
- $log_dir = $this->config->get('log_dir');
- $log_dir .= DIRECTORY_SEPARATOR . $this->username;
+ $log_dir = $this->log_dir . DIRECTORY_SEPARATOR . $username;
// No automatically creating any log directories
if (!is_dir($log_dir)) {
@@ -470,6 +487,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;
@@ -490,6 +511,7 @@
if ($this->config->get('devel_mode') || $this->config->get('performance_stats')) {
// we have to disable per_user_logging to make sure stats end up in the main console log
$this->config->set('per_user_logging', false);
+ $this->config->set('log_dir', $this->log_dir);
// make sure logged numbers use unified format
setlocale(LC_NUMERIC, 'en_US.utf8', 'en_US.UTF-8', 'en_US', 'C');
@@ -499,7 +521,7 @@
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'))
@@ -528,5 +550,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_device.php b/lib/kolab_sync_backend_device.php
--- a/lib/kolab_sync_backend_device.php
+++ b/lib/kolab_sync_backend_device.php
@@ -45,7 +45,7 @@
public function __construct()
{
parent::__construct();
- $this->backend = kolab_sync_backend::get_instance();
+ $this->backend = kolab_sync::storage();
}
/**
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
@@ -78,18 +78,11 @@
protected $folderType;
/**
- * Internal cache for kolab_storage folder objects
+ * Internal cache for storage folders list
*
* @var array
*/
- protected $folders = array();
-
- /**
- * Internal cache for IMAP folders list
- *
- * @var array
- */
- protected $imap_folders = array();
+ protected $folders = [];
/**
* Logger instance.
@@ -120,6 +113,9 @@
'playbook',
);
+ protected $lastsync_folder = null;
+ protected $lastsync_time = null;
+
const RESULT_OBJECT = 0;
const RESULT_UID = 1;
const RESULT_COUNT = 2;
@@ -185,10 +181,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 +242,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 +287,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 +305,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 +322,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,10 +340,8 @@
$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);
}
/**
@@ -413,39 +352,16 @@
*/
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 +377,15 @@
*/
public function moveItem($srcFolderId, $serverId, $dstFolderId)
{
- $item = $this->getObject($srcFolderId, $serverId, $folder);
+ $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);
+ $uid = $this->backend->moveObject($item['folderId'], $this->device->deviceid, $this->modelName, $item['uid'], $dstFolderId);
- 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);
- }
-
- return $item['uid'];
+ return $this->serverId($uid, $dstFolderId);
}
/**
@@ -543,14 +451,19 @@
}
}
-
+ /**
+ * 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?
}
-
/**
* Search for existing entries
*
@@ -558,108 +471,32 @@
* @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);
- }
+ $result = $result_type == self::RESULT_COUNT ? 0 : [];
+ $ts = time();
+ $force = $this->lastsync_folder != $folderid || $this->lastsync_time <= $ts - Syncroton_Registry::getPingTimeout();
+ $tags = isset($this->tag_categories) && !$this->tag_categories;
+ $found = false;
- $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 : 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, $tags);
+ $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 +504,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;
}
@@ -949,50 +656,41 @@
/**
* 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->getObjectsByUidPrefix($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;
+ continue;
}
}
+
+ // Or (faster) strict UID matching...
+ $object = $this->backend->getObject($fid, $this->device->deviceid, $this->modelName, $uid);
+
+ if (!empty($object) && ($crc === null || $crc == $this->objectCRC($object['uid'], $fid))) {
+ $object['folderId'] = $fid;
+ return $object;
+ }
}
}
@@ -1002,7 +700,7 @@
protected function createObject($folderid, $data)
{
if ($folderid == $this->defaultRootFolder) {
- $default = $this->getDefaultFolder();
+ $default = $this->getDefaultFolder();
if (!is_array($default)) {
return null;
@@ -1017,8 +715,7 @@
unset($data['categories']);
}
- $foldername = $this->backend->folder_id2name($folderid, $this->device->deviceid);
- $folder = $this->getFolderObject($foldername);
+ $folder = $this->backend->getFolder($folderid, $this->device->deviceid, $this->modelName);
// Set User-Agent for saved objects
$app = kolab_sync::get_instance();
@@ -1026,10 +723,10 @@
if ($folder && $folder->valid && $folder->save($data)) {
if (!empty($tags)) {
- $this->setKolabTags($data['uid'], $tags);
+ $this->backend->setCategories($data['uid'], $tags);
}
- $data['_serverId'] = $this->serverId($data['uid'], $folder);
+ $data['_serverId'] = $this->serverId($data['uid'], $folderid);
return $data;
}
@@ -1043,7 +740,7 @@
$object = $this->getObject($folderid, $entryid);
if ($object) {
- $folder = $this->getFolderObject($object['_mailbox']);
+ $folder = $this->backend->getFolder($object['folderId'], $this->device->deviceid, $this->modelName);
// convert categories into tags, save them after updating an object
if (isset($this->tag_categories) && $this->tag_categories && array_key_exists('categories', $data)) {
@@ -1055,12 +752,12 @@
$app = kolab_sync::get_instance();
$app->config->set('useragent', $app->app_name . ' ' . kolab_sync::VERSION);
- if ($folder && $folder->valid && $folder->save($data)) {
+ if ($folder && $folder->valid && $folder->save($data, $this->modelName, $object['uid'])) {
if (isset($tags)) {
- $this->setKolabTags($data['uid'], $tags);
+ $this->backend->setCategories($data['uid'], $tags);
}
- $data['_serverId'] = $this->serverId($object['uid'], $folder);
+ $data['_serverId'] = $this->serverId($object['uid'], $object['folderId']);
return $data;
}
@@ -1075,11 +772,11 @@
$object = $this->getObject($folderid, $entryid);
if ($object) {
- $folder = $this->getFolderObject($object['_mailbox']);
+ $folder = $this->backend->getFolder($object['folderId'], $this->device->deviceid, $this->modelName);
if ($folder && $folder->valid && $folder->delete($object['uid'])) {
if (isset($this->tag_categories) && $this->tag_categories) {
- $this->setKolabTags($object['uid'], null);
+ $this->backend->setCategories($object['uid'], []);
}
return true;
@@ -1105,11 +802,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 +827,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,77 +849,26 @@
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);
}
/**
@@ -1876,32 +1522,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 +1600,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 +1630,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
@@ -182,10 +182,11 @@
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
@@ -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]);
@@ -368,14 +369,12 @@
*/
public function toKolab(Syncroton_Model_IEntry $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);
}
@@ -695,12 +695,10 @@
// Update/Save the event
if (empty($existing)) {
- $folder = $this->save_event($event, $status);
+ $folderId = $this->save_event($event, $status);
// Create SyncState for the new event, so it is not synced twice
- if ($folder) {
- $folderId = $this->getFolderId($folder);
-
+ if ($folderId) {
try {
$syncBackend = Syncroton_Registry::getSyncStateBackend();
$folderBackend = Syncroton_Registry::getFolderBackend();
@@ -711,7 +709,7 @@
$contentBackend->create(new Syncroton_Model_Content(array(
'device_id' => $this->device->id,
'folder_id' => $syncFolder->id,
- 'contentid' => $this->serverId($event['uid'], $folder),
+ 'contentid' => $this->serverId($event['uid'], $folderId),
'creation_time' => $syncState->lastsync,
'creation_synckey' => $syncState->counter,
)));
@@ -722,10 +720,10 @@
}
}
else {
- $folder = $this->update_event($event, $existing, $status, $request->instanceId);
+ $folderId = $this->update_event($event, $existing, $status, $request->instanceId);
}
- if (!$folder) {
+ if (!$folderId) {
throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR);
}
@@ -740,7 +738,7 @@
// 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 empty($status) ? null : $this->serverId($event['uid'], $folderId);
}
/**
@@ -762,7 +760,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 +781,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->getObject($_folder['serverId'], $this->device->deviceid, $this->modelName, $uid))
+ ) {
+ $result['folderId'] = $_folder['serverId'];
+ return $result;
+ }
}
}
}
@@ -809,7 +812,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 +831,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 +862,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;
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,9 +93,6 @@
*/
protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED;
- private $lastsync_folder = null;
- private $lastsync_time = null;
-
/**
* the constructor
@@ -470,7 +467,7 @@
// Categories (Tags)
if (isset($this->tag_categories) && $this->tag_categories) {
// convert kolab tags into categories
- $result['categories'] = $this->getKolabTags($message);
+ $result['categories'] = $this->backend->getCategories($message);
}
$is_ios = preg_match('/(iphone|ipad)/i', $this->device->devicetype);
@@ -629,6 +626,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 +672,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->moveObject($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;
}
/**
@@ -719,7 +706,7 @@
throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::SYNC_SERVER_ERROR);
}
- return $this->createMessageId($folderId, $uid);
+ return $this->serverId($uid, $folderId);
}
/**
@@ -765,7 +752,7 @@
// Categories (Tags) change
if (isset($entry->categories)) {
- $this->setKolabTags($message, $entry->categories);
+ $this->backend->setCategories($message, $entry->categories);
}
}
@@ -950,243 +937,6 @@
}
}
- /**
- * 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;
- }
- }
-
- $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;
- }
-
/**
* ActiveSync Search handler
*
@@ -1229,7 +979,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',
));
@@ -1347,7 +1097,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);
@@ -1417,7 +1167,7 @@
/**
* Creates entry ID of the message
*/
- public function createMessageId($folderid, $uid)
+ protected function serverId($uid, $folderid)
{
return $folderid . '::' . $uid;
}
@@ -1543,106 +1293,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: ?????
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
@@ -85,7 +85,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
@@ -112,7 +111,7 @@
$result['messageClass'] = 'IPM.StickyNote';
// convert kolab tags into categories
- $result['categories'] = $this->getKolabTags($note['uid'], $result['categories']);
+ $result['categories'] = $this->backend->getCategories($note['uid'], $result['categories']);
return $as_array ? $result : new Syncroton_Model_Note($result);
}
@@ -128,9 +127,7 @@
*/
public function toKolab(Syncroton_Model_IEntry $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
@@ -113,7 +113,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)
@@ -159,7 +158,7 @@
// convert kolab tags into categories
if (!empty($result['categories'])) {
- $result['categories'] = $this->getKolabTags($task['uid'], $result['categories']);
+ $result['categories'] = $this->backend->getCategories($task['uid'], $result['categories']);
}
// Recurrence
@@ -168,8 +167,6 @@
return $as_array ? $result : new Syncroton_Model_Task($result);
}
-
-
/**
* Apply a timezone matching the utc offset.
*/
@@ -196,9 +193,7 @@
*/
public function toKolab(Syncroton_Model_IEntry $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;
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,1855 @@
+ |
+ | |
+ | 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 $root_meta;
+ 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 = $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
+ *
+ * @param 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
+ *
+ * @param 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;
+ }
+
+ /**
+ * 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)
+ *
+ * @param ?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)
+ *
+ * @param 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 object 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 array|null Object properties
+ */
+ public function getObject($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->get_object($uid);
+
+ if ($result === false) {
+ throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Gets objects 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
+ *
+ * @param array|iterable List of objects
+ */
+ public function getObjectsByUidPrefix($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 object 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 moveObject($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
+ * @param bool $tags Enable tags/relations check (if supported)
+ *
+ * @return array|int Search result as count or array of uids
+ */
+ public function searchEntries($folderid, $deviceid, $type, $filter, $result_type, $force, $tags)
+ {
+ if ($type != self::MODEL_EMAIL) {
+ return $this->searchKolabEntries($folderid, $deviceid, $type, $filter, $result_type, $force, $tags);
+ }
+
+ $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 ($tags && $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 ($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)) {
+ $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, $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
+ * @param bool $tags Enable tags/relations check (if supported)
+ *
+ * @return array|int Search result as count or array of uids
+ */
+ protected function searchKolabEntries($folderid, $deviceid, $type, $filter, $result_type, $force, $tags)
+ {
+ // there's a PHP Warning from kolab_storage if $filter isn't an array
+ if (empty($filter)) {
+ $filter = [];
+ } elseif ($tags && $this->relationSupport) {
+ $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);
+ } else {
+ $result = (int) $count;
+ }
+ break;
+
+ case kolab_sync_data::RESULT_UID:
+ $uids = $folder->get_uids($filter);
+
+ if (!is_array($uids)) {
+ throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
+ } else {
+ $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:
+ $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 ($this->modelName == 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,589 @@
+ |
+ | |
+ | 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
+ *
+ * @param 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
+ *
+ * @param 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 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;
+ }
+
+ /**
+ * 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 = [])
+ {
+ // TODO
+ return $categories;
+ }
+
+ /**
+ * 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)
+ *
+ * @param ?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)
+ *
+ * @param 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,
+ ];
+ }
+
+ /**
+ * 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)
+ {
+ // TODO
+ }
+
+ /**
+ * 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');
+
+ // 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}")) && (!isset($subscribed_folders) || 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/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,332 @@
+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);
+ }
+}
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,249 @@
+isStorageDriver('kolab') ? 'Contacts' : 'Addressbook';
+ $this->emptyTestFolder($davFolder, 'contact');
+ $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 @@
assertTrue($end - $start < 0.05);
}
+
+ public function testDecoder()
+ {
+ $inputStream = fopen("php://memory", 'r+');
+ $input = "\x03\x01j\x00\x00\x07VR\x030\x00\x01\x01";
+ fwrite($inputStream, $input);
+ rewind($inputStream);
+
+ $decoder = new Syncroton_Wbxml_Decoder($inputStream);
+ $dom = $decoder->decode();
+ $xml = $dom->saveXML();
+
+ $expected = ''
+ . ''
+ . '0';
+
+ $this->assertSame($expected, str_replace("\n", '', $xml));
+ }
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -1,5 +1,8 @@
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--