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