diff --git a/lib/kolab_sync_storage.php b/lib/kolab_sync_storage.php
index f29965b..5e13323 100644
--- a/lib/kolab_sync_storage.php
+++ b/lib/kolab_sync_storage.php
@@ -1,2029 +1,2029 @@
|
| |
| 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
{
public const INIT_SUB_PERSONAL = 1; // all subscribed folders in personal namespace
public const INIT_ALL_PERSONAL = 2; // all folders in personal namespace
public const INIT_SUB_OTHER = 4; // all subscribed folders in other users namespace
public const INIT_ALL_OTHER = 8; // all folders in other users namespace
public const INIT_SUB_SHARED = 16; // all subscribed folders in shared namespace
public const INIT_ALL_SHARED = 32; // all folders in shared namespace
public const MODEL_CALENDAR = 'event';
public const MODEL_CONTACTS = 'contact';
public const MODEL_EMAIL = 'mail';
public const MODEL_NOTES = 'note';
public const MODEL_TASKS = 'task';
public const ROOT_MAILBOX = 'INBOX';
public const ASYNC_KEY = '/private/vendor/kolab/activesync';
public const UID_KEY = '/shared/vendor/cmu/cyrus-imapd/uniqueid';
public const CTYPE_KEY = '/shared/vendor/kolab/folder-type';
public const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type';
public $syncTimeStamp;
protected $storage;
protected $folder_meta;
protected $folder_uids;
protected $folders = [];
protected $root_meta;
protected $relations = [];
- protected $relationSupport = true;
+ public $relationSupport = true;
protected $tag_rts = [];
private $modseq = [];
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
elseif (!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 = $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($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 (!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 === null) {
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
elseif (!$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 $device_key Device primary key
* @param string $type Activesync model name (folder type)
* @param array $filter Filter
* @param int $result_type Type of the result (see kolab_sync_data::RESULT_* constants)
* @param bool $force Force IMAP folder cache synchronization
* @param string $extraData Extra data as extracted by the getExtraData during the last sync
*
* @return array|int Search result as count or array of uids
*/
public function searchEntries($folderid, $deviceid, $device_key, $type, $filter, $result_type, $force, $extraData)
{
if ($type != self::MODEL_EMAIL) {
return $this->searchKolabEntries($folderid, $deviceid, $device_key, $type, $filter, $result_type, $force);
}
$filter_str = 'ALL UNDELETED';
$getChangesMode = false;
// convert filter into one IMAP search string
foreach ($filter as $idx => $filter_item) {
if (is_array($filter_item)) {
if ($filter_item[0] == 'changed' && $filter_item[1] == '>') {
$getChangesMode = true;
}
} else {
$filter_str .= ' ' . $filter_item;
}
}
$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);
}
$modified = true;
// We're in "get changes" mode
if ($getChangesMode) {
$folder_data = $this->storage->folder_data($foldername);
// If HIGHESTMODSEQ doesn't exist we can't get changes
if (!empty($folder_data['HIGHESTMODSEQ'])) {
// Store modseq for later in getExtraData
if (!array_key_exists($deviceid, $this->modseq)) {
$this->modseq[$deviceid] = [];
}
$this->modseq[$deviceid][$folderid] = $folder_data['HIGHESTMODSEQ'];
// After the initial sync we have no extraData
if ($extraData) {
$modseq_old = json_decode($extraData)->modseq;
// Skip search if HIGHESTMODSEQ didn't change
if ($folder_data['HIGHESTMODSEQ'] == $modseq_old) {
$modified = false;
} else {
$filter_str .= " MODSEQ " . ($modseq_old + 1);
}
} else {
// If we don't have extra data we can't search for changes.
// Either we are in initial sync, which means there are no changes to find,
// or we are in the migration (no previous extraData), in which case we ignore changes for one sync key
// because we don't have the means to search for the changes. Going forward we'll have the modseq info.
$modified = false;
}
} else {
// We have no way of finding the changes.
// We could fall back to search by date or ignore changes, but both seems suboptimal.
throw new Syncroton_Exception_Status(Syncroton_Exception_Status::SERVER_ERROR);
}
}
// 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;
}
}
// get members of modified relations
if ($this->relationSupport) {
$changed_msgs = $this->getChangesByRelations($folderid, $device_key, $type, $filter);
// 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;
}
}
}
return $result;
}
/**
* Return extra data that is stored with the sync key and passed in during the search to find changes.
*
* @param string $folderid Folder identifier
* @param string $deviceid Device identifier
*
* @return string|null Extra data (JSON-encoded)
*/
public function getExtraData($folderid, $deviceid)
{
//We explicitly return a cached value that was used during the search.
//Otherwise we'd risk storing a higher modseq value and missing an update.
if (isset($this->modseq[$deviceid][$folderid])) {
return json_encode(['modseq' => intval($this->modseq[$deviceid][$folderid])]);
}
//If we didn't fetch modseq in the first place we have to fetch it now.
$foldername = $this->folder_id2name($folderid, $deviceid);
if ($foldername !== null) {
$folder_data = $this->storage->folder_data($foldername);
if (!empty($folder_data['HIGHESTMODSEQ'])) {
return json_encode(['modseq' => intval($folder_data['HIGHESTMODSEQ'])]);
}
}
return null;
}
/**
* Search for existing objects in a folder
*
* @param string $folderid Folder identifier
* @param string $deviceid Device identifier
* @param string $device_key Device primary key
* @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, $device_key, $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, $device_key, $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) {
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) {
$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, $device_key, $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($device_key, $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...
elseif ($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...
elseif ($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($device_key, $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).
$as_type = 2;
} else {
$as_type = self::type_kolab2activesync($type);
// fix type, if there's no type annotation it's detected as UNKNOWN we'll use 'mail' (12)
if ($as_type == 1) {
$as_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' => $as_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|null 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) || empty($id)) {
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;
}
/**
* Set state of relation objects at specified point in time
*/
public function relations_state_set($device_key, $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));
$result = $db->query(
"INSERT INTO `syncroton_relations_state`"
. " (`device_id`, `folder_id`, `synctime`, `data`)"
. " VALUES (?, ?, ?, ?)",
$device_key,
$folderid,
$synctime,
$data
);
if ($err = $db->is_error($result)) {
throw new Exception("Failed to save relation: {$err}");
}
}
}
/**
* Get state of relation objects at specified point in time
*/
protected function relations_state_get($device_key, $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,
$device_key,
$folderid,
$synctime
);
if ($row = $db->fetch_assoc()) {
// Don't use $row['synctime'] for the internal cache.
// The synctime of the found row is usually earlier than the requested synctime.
// Note: We use internal cache because there's a call to both hasChanges() and
// getChangedEntries() in Sync. It's needed until we add some caching on a higher level.
$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` <> ?",
$device_key,
$folderid,
$row['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
index 9c45a89..ab34e55 100644
--- a/lib/kolab_sync_storage_kolab4.php
+++ b/lib/kolab_sync_storage_kolab4.php
@@ -1,571 +1,571 @@
|
| |
| 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;
+ public $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;
}
} elseif (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;
} elseif ($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($folder->default ? "$type.default" : $type),
];
}
/**
* Builds folder ID based on folder name
*
* @param string $name Folder name (UTF7-IMAP)
* @param string $type Kolab folder type
*
* @return string|null 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) || empty($id)) {
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
}
public function getExtraData($folderid, $deviceid)
{
if (strpos($folderid, 'DAV:') === 0) {
return null;
}
return parent::getExtraData($folderid, $deviceid);
}
}
diff --git a/tests/Sync/Sync/RelationsTest.php b/tests/Sync/Sync/RelationsTest.php
new file mode 100644
index 0000000..1e021e6
--- /dev/null
+++ b/tests/Sync/Sync/RelationsTest.php
@@ -0,0 +1,202 @@
+
+
+
+
+
+ 0
+ {$folderId}
+
+
+
+ EOF;
+ return $this->request($request, 'Sync');
+ }
+
+ protected function syncRequest($syncKey, $folderId, $windowSize = null) {
+ $request = <<
+
+
+
+
+ {$syncKey}
+ {$folderId}
+ 1
+ 1
+ {$windowSize}
+
+ 0
+ 1
+
+ 2
+ 51200
+ 0
+
+
+
+
+
+ EOF;
+ return $this->request($request, 'Sync');
+ }
+
+ /**
+ * Test Sync command
+ */
+ public function testRelationsSync()
+ {
+ $sync = \kolab_sync::get_instance();
+ if (!$sync->storage()->relationSupport) {
+ $this->markTestSkipped('No relation support');
+ }
+
+ $this->emptyTestFolder('INBOX', 'mail');
+ $this->emptyTestFolder('Configuration', 'configuration');
+ $this->registerDevice();
+
+ // Test INBOX
+ $folderId = '38b950ebd62cd9a66929c89615d0fc04';
+ $syncKey = 0;
+ $response = $this->initialSyncRequest($folderId);
+ $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);
+
+ // First we append
+ $uid1 = $this->appendMail('INBOX', 'mail.sync1');
+ $uid2 = $this->appendMail('INBOX', 'mail.sync2');
+ $this->appendMail('INBOX', 'mail.sync1', ['sync1' => 'sync3']);
+ $this->appendMail('INBOX', 'mail.sync1', ['sync1' => 'sync4']);
+
+ $sync = \kolab_sync::get_instance();
+
+ $device = $sync->storage()->device_get(self::$deviceId);
+
+ //Add a tag
+ $sync->storage()->updateItem($folderId, $device['ID'], \kolab_sync_storage::MODEL_EMAIL, $uid1, null, ['categories' => ['test1']]);
+ sleep(1);
+
+ $response = $this->syncRequest($syncKey, $folderId, 10);
+ $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(4, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+
+ $root .= "/ns:Commands/ns:Add";
+ $this->assertSame(1, $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->count());
+ $this->assertSame("test1", $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->item(0)->nodeValue);
+
+ //Add a second tag
+ $sync->storage()->updateItem($folderId, $device['ID'], \kolab_sync_storage::MODEL_EMAIL, $uid1, null, ['categories' => ['test1', 'test2']]);
+ sleep(1); // Necessary to make sure we pick up on the tag.
+
+ $response = $this->syncRequest($syncKey, $folderId, 10);
+ $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());
+ $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Change")->count());
+ $root .= "/ns:Commands/ns:Change";
+ $this->assertSame(1, $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->count());
+ //FIXME not sure what I'm doing wrong, but the xml looks ok
+ $this->assertSame("test1test2", $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->item(0)->nodeValue);
+
+ //Rerun the same command and make sure we get the same result
+ $syncKey--;
+ $root = "//ns:Sync/ns:Collections/ns:Collection";
+ $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue);
+ $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue);
+ $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue);
+ $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+ $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Change")->count());
+ $root .= "/ns:Commands/ns:Change";
+ $this->assertSame(1, $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->count());
+ //FIXME not sure what I'm doing wrong, but the xml looks ok
+ $this->assertSame("test1test2", $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->item(0)->nodeValue);
+
+
+ // Assert the db state
+ $rcube = \rcube::get_instance();
+ $db = $rcube->get_dbh();
+ $result = $db->query(
+ "SELECT `data`, `synctime` FROM `syncroton_relations_state`"
+ . " WHERE `device_id` = ? AND `folder_id` = ?"
+ . " ORDER BY `synctime` DESC",
+ $device['ID'],
+ $folderId
+ );
+ $data = [];
+ while ($state = $db->fetch_assoc($result)) {
+ $data[] = $state;
+ }
+ $this->assertSame(1, count($data));
+
+ // Reset to no tags
+ $sync->storage()->updateItem($folderId, $device['ID'], \kolab_sync_storage::MODEL_EMAIL, $uid1, null, ['categories' => []]);
+ sleep(1); // Necessary to make sure we pick up on the tag.
+
+ $response = $this->syncRequest($syncKey, $folderId, 10);
+ $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());
+ $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Change")->count());
+ $root .= "/ns:Commands/ns:Change";
+ $this->assertSame(0, $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->count());
+ //FIXME this currently fails because we omit the empty categories element
+ // $this->assertSame("", $xpath->query("{$root}/ns:ApplicationData/Email:Categories")->item(0)->nodeValue);
+
+
+ // Assert the db state
+ $result = $db->query(
+ "SELECT `data`, `synctime` FROM `syncroton_relations_state`"
+ . " WHERE `device_id` = ? AND `folder_id` = ?"
+ . " ORDER BY `synctime` DESC",
+ $device['ID'],
+ $folderId
+ );
+ $data = [];
+ while ($state = $db->fetch_assoc($result)) {
+ $data[] = $state;
+ }
+ $this->assertSame(1, count($data));
+
+ $response = $this->syncRequest($syncKey, $folderId, 10);
+ $this->assertEquals(200, $response->getStatusCode());
+ // We expect an empty response without a change
+ $this->assertEquals(0, $response->getBody()->getSize());
+ // print($dom->saveXML());
+
+ return $syncKey;
+ }
+}
+
diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php
index 3868f63..4ade25a 100644
--- a/tests/SyncTestCase.php
+++ b/tests/SyncTestCase.php
@@ -1,405 +1,407 @@
markTestSkipped('Not setup');
}
self::$deviceType = null;
}
/**
* {@inheritDoc}
*/
public static function setUpBeforeClass(): void
{
$sync = \kolab_sync::get_instance();
$config = $sync->config;
$db = $sync->get_dbh();
self::$username = $config->get('activesync_test_username');
self::$password = $config->get('activesync_test_password');
self::$host = $config->get('activesync_test_host', 'http://localhost:8000');
if (empty(self::$username)) {
return;
}
self::$deviceId = 'test' . time();
$db->query('DELETE FROM syncroton_device');
$db->query('DELETE FROM syncroton_synckey');
$db->query('DELETE FROM syncroton_folder');
$db->query('DELETE FROM syncroton_data');
$db->query('DELETE FROM syncroton_data_folder');
$db->query('DELETE FROM syncroton_content');
+ $db->query('DELETE FROM syncroton_relations_state');
self::$client = new \GuzzleHttp\Client([
'http_errors' => false,
'base_uri' => self::$host,
'verify' => false,
'auth' => [self::$username, self::$password],
'connect_timeout' => 10,
'timeout' => 10,
'headers' => [
'Content-Type' => 'application/xml; charset=utf-8',
'Depth' => '1',
],
]);
// TODO: execute: php -S localhost:8000
}
/**
* {@inheritDoc}
*/
public static function tearDownAfterClass(): void
{
if (self::$deviceId) {
$sync = \kolab_sync::get_instance();
if (self::$authenticated || $sync->authenticate(self::$username, self::$password)) {
$sync->password = self::$password;
$storage = $sync->storage();
$storage->device_delete(self::$deviceId);
}
$db = $sync->get_dbh();
$db->query('DELETE FROM syncroton_device');
$db->query('DELETE FROM syncroton_synckey');
$db->query('DELETE FROM syncroton_folder');
+ $db->query('DELETE FROM syncroton_relations_state');
}
}
/**
* 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}");
+ exit("Failed to append mail {$filename} into {$folder}");
}
return $uid;
}
/**
* Run A SQL query
*/
protected function runSQLQuery($query)
{
$sync = \kolab_sync::get_instance();
$db = $sync->get_dbh();
$db->query($query);
}
/**
* Mark an email message as read over IMAP
*/
protected function markMailAsRead($folder, $uids)
{
$imap = $this->getImapStorage();
return $imap->set_flag($uids, 'SEEN', $folder);
}
/**
* List emails over IMAP
*/
protected function listEmails($folder, $uids)
{
$imap = $this->getImapStorage();
return $imap->list_flags($folder, $uids);
}
/**
* Append an DAV object to a DAV/IMAP folder
*/
protected function appendObject($foldername, $filename, $type)
{
$path = __DIR__ . '/src/' . $filename;
if (!file_exists($path)) {
exit("File does not exist: {$path}");
}
$content = file_get_contents($path);
$uid = preg_match('/UID:(?:urn:uuid:)?([a-z0-9-]+)/', $content, $m) ? $m[1] : null;
if (empty($uid)) {
exit("Filed to find UID in {$path}");
}
if ($this->isStorageDriver('kolab')) {
$imap = $this->getImapStorage();
if ($imap->folder_exists($foldername)) {
// TODO
exit("Not implemented for Kolab v3 storage driver");
}
return;
}
$dav = $this->getDavStorage();
foreach ($dav->get_folders($type) as $folder) {
if ($folder->get_name() === $foldername) {
$dav_type = $folder->get_dav_type();
$location = $folder->object_location($uid);
if ($folder->dav->create($location, $content, $dav_type) !== false) {
return;
}
}
}
exit("Failed to append object into {$foldername}");
}
/**
* Delete a folder
*/
protected function deleteTestFolder($name, $type)
{
// Deleting IMAP folders
if ($type == 'mail' || $this->isStorageDriver('kolab')) {
$imap = $this->getImapStorage();
if ($imap->folder_exists($name)) {
$imap->delete_folder($name);
}
return;
}
// Deleting DAV folders
$dav = $this->getDavStorage();
foreach ($dav->get_folders($type) as $folder) {
if ($folder->get_name() === $name) {
$dav->folder_delete($folder->id, $type);
}
}
}
/**
* Remove all objects from a folder
*/
protected function emptyTestFolder($name, $type)
{
// Deleting in IMAP folders
if ($type == 'mail' || $this->isStorageDriver('kolab')) {
$imap = $this->getImapStorage();
$imap->delete_message('*', $name);
return;
}
// Deleting in DAV folders
$dav = $this->getDavStorage();
foreach ($dav->get_folders($type) as $folder) {
if ($folder->get_name() === $name) {
$folder->delete_all();
}
}
}
/**
* Convert WBXML binary content into XML
*/
protected function fromWbxml($binary)
{
$stream = fopen('php://memory', 'r+');
fwrite($stream, $binary);
rewind($stream);
$decoder = new \Syncroton_Wbxml_Decoder($stream);
return $decoder->decode();
}
/**
* Initialize DAV storage
*/
protected function getDavStorage()
{
$sync = \kolab_sync::get_instance();
$url = $sync->config->get('activesync_dav_server', 'http://localhost');
if (strpos($url, '://') === false) {
$url = 'http://' . $url;
}
// Inject user+password to the URL, there's no other way to pass it to the DAV client
$url = str_replace('://', '://' . rawurlencode(self::$username) . ':' . rawurlencode(self::$password) . '@', $url);
// Make sure user is authenticated
$this->getImapStorage();
if (!empty($sync->user)) {
// required e.g. for DAV client cache use
\rcube::get_instance()->user = $sync->user;
}
return new \kolab_storage_dav($url);
}
/**
* Initialize IMAP storage
*/
protected function getImapStorage()
{
$sync = \kolab_sync::get_instance();
if (!self::$authenticated) {
if ($sync->authenticate(self::$username, self::$password)) {
self::$authenticated = true;
$sync->password = self::$password;
}
}
return $sync->get_storage();
}
/**
* Check the configured activesync_storage driver
*/
protected function isStorageDriver($name)
{
return $name === \kolab_sync::get_instance()->config->get('activesync_storage', 'kolab');
}
/**
* Make a HTTP request to the ActiveSync server
*/
protected function request($body, $cmd, $type = 'POST')
{
$username = self::$username;
$deviceId = self::$deviceId;
$deviceType = self::$deviceType ?: 'WindowsOutlook15';
$body = $this->toWbxml($body);
return self::$client->request(
$type,
"?Cmd={$cmd}&User={$username}&DeviceId={$deviceId}&DeviceType={$deviceType}",
[
'headers' => [
'Content-Type' => 'application/vnd.ms-sync.wbxml',
'MS-ASProtocolVersion' => '14.0',
],
'body' => $body,
]
);
}
/**
* Register the device for tests, some commands do not work until device/folders are registered
*/
protected function registerDevice()
{
// Execute initial FolderSync, it is required before executing some commands
$request = <<
0
EOF;
$response = $this->request($request, 'FolderSync');
$this->assertEquals(200, $response->getStatusCode());
$dom = $this->fromWbxml($response->getBody());
$xpath = $this->xpath($dom);
foreach ($xpath->query("//ns:FolderSync/ns:Changes/ns:Add") as $idx => $folder) {
$serverId = $folder->getElementsByTagName('ServerId')->item(0)->nodeValue;
$displayName = $folder->getElementsByTagName('DisplayName')->item(0)->nodeValue;
$this->folders[$serverId] = $displayName;
}
}
/**
* Convert XML into WBXML binary content
*/
protected function toWbxml($xml)
{
$outputStream = fopen('php://temp', 'r+');
$encoder = new \Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3);
$dom = new \DOMDocument();
$dom->loadXML($xml);
$encoder->encode($dom);
rewind($outputStream);
return stream_get_contents($outputStream);
}
/**
* Get XPath from a DOM
*/
protected function xpath($dom)
{
$xpath = new \DOMXpath($dom);
$xpath->registerNamespace("ns", $dom->documentElement->namespaceURI);
$xpath->registerNamespace("AirSync", "uri:AirSync");
$xpath->registerNamespace("AirSyncBase", "uri:AirSyncBase");
$xpath->registerNamespace("Calendar", "uri:Calendar");
$xpath->registerNamespace("Contacts", "uri:Contacts");
$xpath->registerNamespace("Email", "uri:Email");
$xpath->registerNamespace("Email2", "uri:Email2");
$xpath->registerNamespace("Settings", "uri:Settings");
$xpath->registerNamespace("Tasks", "uri:Tasks");
return $xpath;
}
/**
* adapter for phpunit < 9
*/
public static function assertMatchesRegularExpression(string $arg1, string $arg2, string $message = ''): void
{
if (method_exists("PHPUnit\Framework\TestCase", "assertMatchesRegularExpression")) {
parent::assertMatchesRegularExpression($arg1, $arg2, $message);
} else {
parent::assertRegExp($arg1, $arg2);
}
}
}