Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117850845
D4631.1775313743.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
253 KB
Referenced Files
None
Subscribers
None
D4631.1775313743.diff
View Options
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,8 +71,9 @@
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
@@ -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 @@
-<?php
-
-/**
- +--------------------------------------------------------------------------+
- | Kolab Sync (ActiveSync for Kolab) |
- | |
- | Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
- | |
- | 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 <http://www.gnu.org/licenses/> |
- +--------------------------------------------------------------------------+
- | Author: Aleksander Machniak <machniak@kolabsys.com> |
- +--------------------------------------------------------------------------+
-*/
-
-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,12 +377,14 @@
*/
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);
}
+ // TODO
+ /*
$dstname = $this->backend->folder_id2name($dstFolderId, $this->device->deviceid);
if ($dstname === null) {
@@ -478,6 +396,7 @@
}
return $item['uid'];
+ */
}
/**
@@ -543,14 +462,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
*
@@ -562,243 +486,42 @@
*/
protected function searchEntries($folderid, $filter = array(), $result_type = self::RESULT_UID)
{
- if ($folderid == $this->defaultRootFolder) {
- $folders = $this->listFolders();
-
- if (!is_array($folders)) {
- throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
- }
-
- $folders = array_keys($folders);
- }
- else {
- $folders = array($folderid);
- }
+ $folders = $this->extractFolders($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);
+ if ($folders === null) {
+ throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
}
- $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);
+ $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;
- if (!$folder || !$folder->valid) {
- throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
- }
-
- $found++;
- $error = false;
+ foreach ($folders as $folderid) {
+ $search = $this->backend->searchEntries($folderid, $this->device->id, $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));
+ foreach ($search as $idx => $uid) {
+ $search[$idx] = $this->serverId($uid, $folderid);
}
+ $result = array_unique(array_merge($result, $search));
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;
- }
- }
-
- 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;
- }
- }
}
if (!$found) {
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,7 +672,7 @@
/**
* Fetches the entry from the backend
*/
- protected function getObject($folderid, $entryid, &$folder = null)
+ protected function getObject($folderid, $entryid)
{
$folders = $this->extractFolders($folderid);
@@ -958,41 +681,38 @@
}
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;
- }
+ $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($folderid, $this->device->deviceid, $this->modelName, $uid);
+
+ foreach ($objects as $object) {
+ if (($object['uid'] === $uid || strpos($object['uid'], $uid) === 0)
+ && $crc == $this->objectCRC($object['uid'], $folderid)
+ ) {
+ $object['folderId'] = $folderid;
+ 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($folderid, $this->device->deviceid, $this->modelName, $uid);
+
+ if (!empty($object) && ($crc === null || $crc == $this->objectCRC($object['uid'], $folderid))) {
+ $object['folderId'] = $folderid;
+ return $object;
+ }
}
}
@@ -1017,8 +737,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 +745,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 +762,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 +774,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 +794,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;
@@ -1130,19 +849,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 +871,16 @@
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)
+ protected function getFolderConfig($folderid)
{
- $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)
- {
- if ($folderid == $this->defaultRootFolder) {
- $default = $this->getDefaultFolder();
-
- if (!is_array($default)) {
- return null;
- }
-
- $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 +1534,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 +1612,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 +1642,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,7 +182,7 @@
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;
@@ -368,14 +368,14 @@
*/
public function toKolab(Syncroton_Model_IEntry $data, $folderid, $entry = null, $timezone = null)
{
- $foldername = isset($entry['_mailbox']) ? $entry['_mailbox'] : $this->getFolderName($folderid);
- if (empty($entry)) {
+ $config = [];
+
+ 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 +384,12 @@
// uID is not available on exceptions, so we guard for that and silently ignore.
}
}
- $event = !empty($entry) ? $entry : array();
- $config = $this->getFolderConfig($foldername);
+
+ if (!empty($entry['folderId'])) {
+ $config = $this->getFolderConfig($entry['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;
@@ -695,12 +699,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 +713,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 +724,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 +742,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 +764,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 +785,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 +816,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 +835,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 +866,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);
@@ -694,7 +691,7 @@
$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 $this->serverId($uid, $dest_id);
}
}
@@ -719,7 +716,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 +762,7 @@
// Categories (Tags) change
if (isset($entry->categories)) {
- $this->setKolabTags($message, $entry->categories);
+ $this->backend->setCategories($message, $entry->categories);
}
}
@@ -950,243 +947,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 +989,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 +1107,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 +1177,7 @@
/**
* Creates entry ID of the message
*/
- public function createMessageId($folderid, $uid)
+ protected function serverId($uid, $folderid)
{
return $folderid . '::' . $uid;
}
@@ -1543,106 +1303,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,1827 @@
+<?php
+
+/**
+ +--------------------------------------------------------------------------+
+ | Kolab Sync (ActiveSync for Kolab) |
+ | |
+ | Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com> |
+ | |
+ | 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 <http://www.gnu.org/licenses/> |
+ +--------------------------------------------------------------------------+
+ | Author: Aleksander Machniak <machniak@kolabsys.com> |
+ +--------------------------------------------------------------------------+
+*/
+
+/**
+ * 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',
+ ];
+
+ protected static $classes = [
+ Syncroton_Data_Factory::CLASS_CALENDAR => self::MODEL_CALENDAR,
+ Syncroton_Data_Factory::CLASS_CONTACTS => self::MODEL_CONTACTS,
+ Syncroton_Data_Factory::CLASS_EMAIL => self::MODEL_EMAIL,
+ Syncroton_Data_Factory::CLASS_NOTES => self::MODEL_NOTES,
+ Syncroton_Data_Factory::CLASS_TASKS => self::MODEL_TASKS,
+ ];
+
+
+ /**
+ * 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();
+
+ // @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([
+ // @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
+ */
+ 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 = [];
+
+ 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 (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)
+ */
+ 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] : [];
+
+ 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)]);
+ }
+
+
+ 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
+ */
+ public function getFolderConfig($folderid, $deviceid, $type)
+ {
+ $foldername = $this->folder_id2name($folderid, $deviceid);
+
+ $metadata = $this->backend->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
+ */
+ 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
+ */
+ 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;
+ }
+
+ /**
+ * 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)) {
+ // 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
+ */
+ protected 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
+ */
+ 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
+ */
+ 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;
+ }
+
+ $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;
+ }
+
+ /**
+ */
+ 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] = [];
+
+ $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] = [];
+
+ $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
+ */
+ 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,592 @@
+<?php
+
+/**
+ +--------------------------------------------------------------------------+
+ | Kolab Sync (ActiveSync for Kolab) |
+ | |
+ | Copyright (C) 2011-2023, Apheleia IT AG <contact@apheleia-it.ch> |
+ | |
+ | 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 <http://www.gnu.org/licenses/> |
+ +--------------------------------------------------------------------------+
+ | Author: Aleksander Machniak <machniak@kolabsys.com> |
+ +--------------------------------------------------------------------------+
+*/
+
+/**
+ * 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])) {
+ $list = [];
+
+ // TODO: For now all DAV folders are subscribed
+ foreach ($this->davStorage->get_folders($type) as $folder) {
+ $folder_data = $this->folder_data($folder, $type);
+ $list[$folder_data['serverId']] = $folder_data;
+ }
+
+ return $list;
+ }
+/*
+ // 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)
+ */
+ public 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 = self::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
+ */
+ 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
+ }
+
+ /**
+ * Get all DAV folders of all types
+ */
+ protected function dav_all_folders()
+ {
+ $list = [];
+
+ // TODO: For performance reasons this should be one HTTP request, not three
+ foreach ([self::MODEL_CONTACTS, self::MODEL_CALENDAR, self::MODEL_TASKS] as $type) {
+ $folders = $this->davStorage->get_folders($type);
+ $list = array_merge($list, $folders);
+ }
+
+ return $list;
+ }
+}
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 @@
+<?php
+
+class FoldersTest extends Tests\SyncTestCase
+{
+ /**
+ * Test FolderSync command
+ */
+ public function testFolderSync()
+ {
+ // Note: We essentially assume the test account is in an initial state, extra folders may break tests
+ // Anyway, we first remove folders that might have been created during tests in this file
+ $this->deleteTestFolder('Test Folder', 'mail');
+ $this->deleteTestFolder('Test Folder New', 'mail');
+ $this->deleteTestFolder('Test Contacts Folder', 'contact');
+ $this->deleteTestFolder('Test Contacts New', 'contact');
+
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderSync xmlns="uri:FolderHierarchy">
+ <SyncKey>0</SyncKey>
+ </FolderSync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderSync xmlns="uri:FolderHierarchy">
+ <SyncKey>1</SyncKey>
+ </FolderSync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderCreate xmlns="uri:FolderHierarchy">
+ <SyncKey>{$syncKey}</SyncKey>
+ <ParentId>0</ParentId>
+ <DisplayName>{$folderName1}</DisplayName>
+ <Type>{$folderType}</Type>
+ </FolderCreate>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderCreate xmlns="uri:FolderHierarchy">
+ <SyncKey>{$syncKey}</SyncKey>
+ <ParentId>0</ParentId>
+ <DisplayName>{$folderName2}</DisplayName>
+ <Type>{$folderType}</Type>
+ </FolderCreate>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderUpdate xmlns="uri:FolderHierarchy">
+ <SyncKey>{$params['SyncKey']}</SyncKey>
+ <ServerId>{$params['folders'][0]}</ServerId>
+ <ParentId/>
+ <DisplayName>Test Folder New</DisplayName>
+ <Type>{$folderType}</Type>
+ </FolderUpdate>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderSync xmlns="uri:FolderHierarchy">
+ <SyncKey>{$params['SyncKey']}</SyncKey>
+ </FolderSync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderUpdate xmlns="uri:FolderHierarchy">
+ <SyncKey>{$params['SyncKey']}</SyncKey>
+ <ServerId>{$params['folders'][1]}</ServerId>
+ <ParentId/>
+ <DisplayName>Test Contacts New</DisplayName>
+ <Type>{$folderType}</Type>
+ </FolderUpdate>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderSync xmlns="uri:FolderHierarchy">
+ <SyncKey>{$params['SyncKey']}</SyncKey>
+ </FolderSync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderDelete xmlns="uri:FolderHierarchy">
+ <SyncKey>{$params['SyncKey']}</SyncKey>
+ <ServerId>{$params['folders'][0]}</ServerId>
+ </FolderDelete>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderDelete xmlns="uri:FolderHierarchy">
+ <SyncKey>{$params['SyncKey']}</SyncKey>
+ <ServerId>{$params['folders'][1]}</ServerId>
+ </FolderDelete>
+ 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 @@
+<?php
+
+class ItemOperationsTest extends Tests\SyncTestCase
+{
+ /**
+ * Test ItemOperations::EmptyFolderContents request
+ */
+ public function testEmptyFolderContents()
+ {
+ $this->registerDevice();
+
+ // TODO: Test invalid folder ID
+ $collectionId = 'AAAAAAAAAAAA';
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <ItemOperations xmlns="uri:ItemOperations" xmlns:AirSync="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+ <EmptyFolderContents>
+ <AirSync:CollectionId>{$collectionId}</AirSync:CollectionId>
+ <Options><DeleteSubFolders/></Options>
+ </EmptyFolderContents>
+ </ItemOperations>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <ItemOperations xmlns="uri:ItemOperations" xmlns:AirSync="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+ <EmptyFolderContents>
+ <AirSync:CollectionId>{$collectionId}</AirSync:CollectionId>
+ <Options><DeleteSubFolders/></Options>
+ </EmptyFolderContents>
+ </ItemOperations>
+ 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 @@
+<?php
+
+namespace Tests\Sync;
+
+class MeetingResponseTest extends \Tests\SyncTestCase
+{
+ /**
+ * Test MeetingResponse command
+ */
+ public function testAcceptingInvitation()
+ {
+ $this->emptyTestFolder($davFolder = 'Calendar', 'event');
+ $this->emptyTestFolder('INBOX', 'mail');
+
+ $this->registerDevice();
+
+ // Do the initial INBOX sync
+ $folderId = '38b950ebd62cd9a66929c89615d0fc04'; // INBOX
+ $syncKey = 0;
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync">
+ <Collections>
+ <Collection>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>{$folderId}</CollectionId>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync">
+ <Collections>
+ <Collection>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>{$folderId}</CollectionId>
+ <DeletesAsMoves>1</DeletesAsMoves>
+ <GetChanges>1</GetChanges>
+ <WindowSize>1</WindowSize>
+ <Options>
+ <FilterType>0</FilterType>
+ <Conflict>1</Conflict>
+ <BodyPreference xmlns="uri:AirSyncBase">
+ <Type>2</Type>
+ <TruncationSize>51200</TruncationSize>
+ <AllOrNone>0</AllOrNone>
+ </BodyPreference>
+ </Options>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 <test.test@domain.tld>', $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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <MeetingResponse xmlns="uri:MeetingResponse" xmlns:Search="uri:Search">
+ <Request>
+ <UserResponse>1</UserResponse>
+ <CollectionId>{$folderId}</CollectionId>
+ <RequestId>{$serverId}</RequestId>
+ </Request>
+ </MeetingResponse>
+ 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/OptionsTest.php b/tests/Sync/OptionsTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/OptionsTest.php
@@ -0,0 +1,16 @@
+<?php
+
+class OptionsTest extends Tests\SyncTestCase
+{
+ /**
+ * Test Options command/request
+ */
+ public function testOptions()
+ {
+ $response = self::$client->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 @@
+<?php
+
+class ProvisionTest extends Tests\SyncTestCase
+{
+ /**
+ * Test Provision command
+ */
+ public function testProvision()
+ {
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Provision xmlns="uri:Provision" xmlns:Settings="uri:Settings">
+ <DeviceInformation xmlns="uri:Settings">
+ <Set>
+ <Model>moto e(6) plus</Model>
+ <IMEI>000000000000000</IMEI>
+ <FriendlyName>pokerp_reteu_64</FriendlyName>
+ <OS>Android 9.58-8</OS>
+ <OSLanguage>Polish (Poland)</OSLanguage>
+ <MobileOperator/>
+ </Set>
+ </DeviceInformation>
+ <Policies>
+ <Policy>
+ <PolicyType>MS-EAS-Provisioning-WBXML</PolicyType>
+ </Policy>
+ </Policies>
+ </Provision>
+ 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 @@
+<?php
+
+class SettingsTest extends Tests\SyncTestCase
+{
+ /**
+ * Test Settings command
+ */
+ public function testSettingsUserInformation()
+ {
+ // Test retrieving the settings
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Settings xmlns="uri:Settings">
+ <UserInformation>
+ <Get/>
+ </UserInformation>
+ </Settings>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Settings xmlns="uri:Settings">
+ <DeviceInformation>
+ <Set>
+ <Model>moto plus</Model>
+ <IMEI>111111111</IMEI>
+ <FriendlyName>fn</FriendlyName>
+ <OS>Android 10</OS>
+ <OSLanguage>English</OSLanguage>
+ <MobileOperator/>
+ </Set>
+ </DeviceInformation>
+ </Settings>
+ 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 @@
+<?php
+
+namespace Tests\Sync\Sync;
+
+class CalendarTest extends \Tests\SyncTestCase
+{
+ /**
+ * Test Sync command
+ */
+ public function testSync()
+ {
+ $this->emptyTestFolder($davFolder = 'Calendar', 'event');
+ $this->registerDevice();
+
+ // Test empty folder
+ $folderId = 'Calendar::Syncroton';
+ $syncKey = 0;
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+ <Collections>
+ <Collection>
+ <Class>Calendar</Class>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>{$folderId}</CollectionId>
+ <DeletesAsMoves/>
+ <GetChanges/>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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("<SyncKey>0</SyncKey>", "<SyncKey>{$syncKey}</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 @@
+<?php
+
+namespace Tests\Sync\Sync;
+
+class ContactsTest extends \Tests\SyncTestCase
+{
+ /**
+ * Test Sync command
+ */
+ public function testSync()
+ {
+ $davFolder = $this->isStorageDriver('kolab') ? 'Contacts' : 'Addressbook';
+ $this->emptyTestFolder($davFolder, 'contact');
+ $this->registerDevice();
+
+ // Test empty contacts folder
+ $folderId = 'Contacts::Syncroton';
+ $syncKey = 0;
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+ <Collections>
+ <Collection>
+ <Class>Contacts</Class>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>{$folderId}</CollectionId>
+ <DeletesAsMoves/>
+ <GetChanges/>
+ <Options>
+ <AirSyncBase:BodyPreference>
+ <AirSyncBase:Type>1</AirSyncBase:Type>
+ <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+ </AirSyncBase:BodyPreference>
+ <Conflict>1</Conflict>
+ </Options>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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("<SyncKey>0</SyncKey>", "<SyncKey>{$syncKey}</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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase" xmlns:Contacts="uri:Contacts">
+ <Collections>
+ <Collection>
+ <Class>Contacts</Class>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>Contacts::Syncroton</CollectionId>
+ <DeletesAsMoves/>
+ <GetChanges/>
+ <Options>
+ <AirSyncBase:BodyPreference>
+ <AirSyncBase:Type>1</AirSyncBase:Type>
+ <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+ </AirSyncBase:BodyPreference>
+ <Conflict>1</Conflict>
+ </Options>
+ <Commands>
+ <Add>
+ <ClientId>42</ClientId>
+ <ApplicationData>
+ <Contacts:FirstName>Lars</Contacts:FirstName>
+ </ApplicationData>
+ </Add>
+ </Commands>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase" xmlns:Contacts="uri:Contacts">
+ <Collections>
+ <Collection>
+ <Class>Contacts</Class>
+ <SyncKey>{$params[0]}</SyncKey>
+ <CollectionId>Contacts::Syncroton</CollectionId>
+ <DeletesAsMoves/>
+ <GetChanges/>
+ <Options>
+ <AirSyncBase:BodyPreference>
+ <AirSyncBase:Type>1</AirSyncBase:Type>
+ <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+ </AirSyncBase:BodyPreference>
+ <Conflict>1</Conflict>
+ </Options>
+ <Commands>
+ <Change>
+ <ServerId>{$params[1]}</ServerId>
+ <ApplicationData>
+ <Contacts:FirstName>First</Contacts:FirstName>
+ <Contacts:LastName>Last</Contacts:LastName>
+ </ApplicationData>
+ </Change>
+ </Commands>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase" xmlns:Contacts="uri:Contacts">
+ <Collections>
+ <Collection>
+ <Class>Contacts</Class>
+ <SyncKey>{$params[0]}</SyncKey>
+ <CollectionId>Contacts::Syncroton</CollectionId>
+ <DeletesAsMoves/>
+ <GetChanges/>
+ <Options>
+ <AirSyncBase:BodyPreference>
+ <AirSyncBase:Type>1</AirSyncBase:Type>
+ <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+ </AirSyncBase:BodyPreference>
+ <Conflict>1</Conflict>
+ </Options>
+ <Commands>
+ <Delete>
+ <ServerId>{$params[1]}</ServerId>
+ </Delete>
+ </Commands>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 @@
+<?php
+
+namespace Tests\Sync\Sync;
+
+class EmailTest extends \Tests\SyncTestCase
+{
+ /**
+ * Test Sync command
+ */
+ public function testSync()
+ {
+ $this->emptyTestFolder('INBOX', 'mail');
+ $this->registerDevice();
+
+ // Test invalid collection identifier
+ $request = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync">
+ <Collections>
+ <Collection>
+ <SyncKey>0</SyncKey>
+ <CollectionId>1111111111</CollectionId>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync">
+ <Collections>
+ <Collection>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>{$folderId}</CollectionId>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+ <Collections>
+ <Collection>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>{$folderId}</CollectionId>
+ <DeletesAsMoves>1</DeletesAsMoves>
+ <GetChanges>1</GetChanges>
+ <WindowSize>1</WindowSize>
+ <Options>
+ <FilterType>0</FilterType>
+ <Conflict>1</Conflict>
+ <BodyPreference xmlns="uri:AirSyncBase">
+ <Type>2</Type>
+ <TruncationSize>51200</TruncationSize>
+ <AllOrNone>0</AllOrNone>
+ </BodyPreference>
+ </Options>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+ <Collections>
+ <Collection>
+ <SyncKey>{$syncKey}</SyncKey>
+ <CollectionId>{$folderId}</CollectionId>
+ <DeletesAsMoves>1</DeletesAsMoves>
+ <GetChanges>1</GetChanges>
+ <Options>
+ <FilterType>0</FilterType>
+ <Conflict>1</Conflict>
+ <BodyPreference xmlns="uri:AirSyncBase">
+ <Type>2</Type>
+ <TruncationSize>51200</TruncationSize>
+ <AllOrNone>0</AllOrNone>
+ </BodyPreference>
+ </Options>
+ </Collection>
+ </Collections>
+ </Sync>
+ 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,360 @@
+<?php
+
+namespace Tests;
+
+class SyncTestCase extends \PHPUnit\Framework\TestCase
+{
+ protected static ?\GuzzleHttp\Client $client;
+ protected static ?string $deviceId;
+ protected static ?string $deviceType;
+ protected static ?string $username;
+ protected static ?string $password;
+ protected static bool $authenticated = false;
+
+ protected array $folders = [];
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ if (empty(self::$username)) {
+ $this->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);
+ }
+ }
+
+ $result = $imap->save_message($folder, $source, '', $is_file);
+
+ if ($result === false) {
+ exit("Failed to append mail into {$folder}");
+ }
+ }
+
+ /**
+ * 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 {$folder}");
+ }
+
+ /**
+ * 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 = <<<EOF
+ <?xml version="1.0" encoding="utf-8"?>
+ <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+ <FolderSync xmlns="uri:FolderHierarchy">
+ <SyncKey>0</SyncKey>
+ </FolderSync>
+ 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 @@
<?php
-class body_converter extends PHPUnit\Framework\TestCase
+class BodyConverterTest extends PHPUnit\Framework\TestCase
{
function data_html_to_text()
{
diff --git a/tests/data_calendar.php b/tests/Unit/DataCalendarTest.php
rename from tests/data_calendar.php
rename to tests/Unit/DataCalendarTest.php
--- a/tests/data_calendar.php
+++ b/tests/Unit/DataCalendarTest.php
@@ -1,6 +1,6 @@
<?php
-class data_calendar extends PHPUnit\Framework\TestCase
+class DataCalendarTest extends PHPUnit\Framework\TestCase
{
/**
* Test for kolab_sync_data_calendar::from_kolab_alarm()
diff --git a/tests/globalid_converter.php b/tests/Unit/DataEmailTest.php
rename from tests/globalid_converter.php
rename to tests/Unit/DataEmailTest.php
--- a/tests/globalid_converter.php
+++ b/tests/Unit/DataEmailTest.php
@@ -1,6 +1,6 @@
<?php
-class globalid_converter extends PHPUnit\Framework\TestCase
+class DataEmailTest extends PHPUnit\Framework\TestCase
{
/**
* Test GlobalObjId encoding/decoding
diff --git a/tests/data_tasks.php b/tests/Unit/DataTasksTest.php
rename from tests/data_tasks.php
rename to tests/Unit/DataTasksTest.php
--- a/tests/data_tasks.php
+++ b/tests/Unit/DataTasksTest.php
@@ -1,6 +1,6 @@
<?php
-class data_tasks extends PHPUnit\Framework\TestCase
+class DataTasksTest extends PHPUnit\Framework\TestCase
{
function data_prio()
{
diff --git a/tests/data.php b/tests/Unit/DataTest.php
rename from tests/data.php
rename to tests/Unit/DataTest.php
--- a/tests/data.php
+++ b/tests/Unit/DataTest.php
@@ -1,6 +1,6 @@
<?php
-class data extends PHPUnit\Framework\TestCase
+class DataTest extends PHPUnit\Framework\TestCase
{
/**
* Test for kolab_sync_data::recurrence_to_kolab()
diff --git a/tests/message.php b/tests/Unit/MessageTest.php
rename from tests/message.php
rename to tests/Unit/MessageTest.php
--- a/tests/message.php
+++ b/tests/Unit/MessageTest.php
@@ -1,6 +1,6 @@
<?php
-class message extends PHPUnit\Framework\TestCase
+class MessageTest extends PHPUnit\Framework\TestCase
{
/**
* Test message parsing and headers setting
diff --git a/tests/timezone_converter.php b/tests/Unit/TimezoneConverterTest.php
rename from tests/timezone_converter.php
rename to tests/Unit/TimezoneConverterTest.php
--- a/tests/timezone_converter.php
+++ b/tests/Unit/TimezoneConverterTest.php
@@ -1,6 +1,6 @@
<?php
-class timezone_converter extends PHPUnit\Framework\TestCase
+class TimezoneConverterTest extends PHPUnit\Framework\TestCase
{
function test_list_timezones()
{
diff --git a/tests/wbxml.php b/tests/Unit/WbxmlTest.php
rename from tests/wbxml.php
rename to tests/Unit/WbxmlTest.php
--- a/tests/wbxml.php
+++ b/tests/Unit/WbxmlTest.php
@@ -1,6 +1,6 @@
<?php
-class wbxml extends PHPUnit\Framework\TestCase
+class WbxmlTest extends PHPUnit\Framework\TestCase
{
//function testDecode()
//{
@@ -888,5 +888,23 @@
$this->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 = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">'
+ . '<FolderSync xmlns="uri:FolderHierarchy"><SyncKey>0</SyncKey></FolderSync>';
+
+ $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 @@
<?php
+error_reporting(E_ALL);
+ini_set('display_errors', 1);
+
if (php_sapi_name() != 'cli') {
die("Not in shell mode (php-cli)");
}
@@ -7,5 +10,6 @@
define('TESTS_DIR', dirname(__FILE__) . '/');
require_once(TESTS_DIR . '/../lib/init.php');
+require_once(TESTS_DIR . '/SyncTestCase.php');
rcube::get_instance()->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">
<testsuites>
- <testsuite name="All Tests">
- <file>body_converter.php</file>
- <file>data.php</file>
- <file>data_calendar.php</file>
- <file>data_tasks.php</file>
- <file>globalid_converter.php</file>
- <file>message.php</file>
- <file>timezone_converter.php</file>
- <file>wbxml.php</file>
+ <testsuite name="Unit">
+ <directory suffix="Test.php">Unit</directory>
+ </testsuite>
+ <testsuite name="Sync">
+ <directory suffix="Test.php">Sync</directory>
</testsuite>
</testsuites>
</phpunit>
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: <sync1@domain.tld>
+From: "Sync 1" <user@domain.tld>
+To: "To 1" <kolab1@domain.tld>, "To 2" <kolab2@domain.tld>
+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: <sync2@domain.tld>
+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--
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 2:42 PM (1 d, 15 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18830003
Default Alt Text
D4631.1775313743.diff (253 KB)
Attached To
Mode
D4631: DAV storage
Attached
Detach File
Event Timeline