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 .= "<errors>";
-                while(list($num,$value) = each($this->err)) {
+                foreach($this->err as $num => $value) {
                     $this->out .= "<message>".$value."</message>";
                 }
                 $this->out .= "</errors>";
@@ -569,7 +568,7 @@
     function makeStyles() {
         $this->outstyles = "<style type=\"text/css\"><!--\n";
         reset($this->styles);
-        while(list($stylename, $styleattrib) = each($this->styles)) {
+        foreach($this->styles as $stylename => $styleattrib) {
             $this->outstyles .= ".".$stylename." { ".$styleattrib." }\n";
         }
         $this->outstyles .= "--></style>\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 @@
-<?php
-
-/**
- +--------------------------------------------------------------------------+
- | Kolab Sync (ActiveSync for Kolab)                                        |
- |                                                                          |
- | Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com>         |
- |                                                                          |
- | This program is free software: you can redistribute it and/or modify     |
- | it under the terms of the GNU Affero General Public License as published |
- | by the Free Software Foundation, either version 3 of the License, or     |
- | (at your option) any later version.                                      |
- |                                                                          |
- | This program is distributed in the hope that it will be useful,          |
- | but WITHOUT ANY WARRANTY; without even the implied warranty of           |
- | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the             |
- | GNU Affero General Public License for more details.                      |
- |                                                                          |
- | You should have received a copy of the GNU Affero General Public License |
- | along with this program. If not, see <http://www.gnu.org/licenses/>      |
- +--------------------------------------------------------------------------+
- | Author: Aleksander Machniak <machniak@kolabsys.com>                      |
- +--------------------------------------------------------------------------+
-*/
-
-class kolab_sync_backend
-{
-    /**
-     * Singleton instace of kolab_sync_backend
-     *
-     * @var kolab_sync_backend
-     */
-    static protected $instance;
-
-    protected $storage;
-    protected $folder_meta;
-    protected $folder_uids;
-    protected $root_meta;
-
-    static protected $types = array(
-        1  => '',
-        2  => 'mail.inbox',
-        3  => 'mail.drafts',
-        4  => 'mail.wastebasket',
-        5  => 'mail.sentitems',
-        6  => 'mail.outbox',
-        7  => 'task.default',
-        8  => 'event.default',
-        9  => 'contact.default',
-        10 => 'note.default',
-        11 => 'journal.default',
-        12 => 'mail',
-        13 => 'event',
-        14 => 'contact',
-        15 => 'task',
-        16 => 'journal',
-        17 => 'note',
-    );
-
-    static protected $classes = array(
-        Syncroton_Data_Factory::CLASS_CALENDAR => 'event',
-        Syncroton_Data_Factory::CLASS_CONTACTS => 'contact',
-        Syncroton_Data_Factory::CLASS_EMAIL    => 'mail',
-        Syncroton_Data_Factory::CLASS_NOTES    => 'note',
-        Syncroton_Data_Factory::CLASS_TASKS    => 'task',
-    );
-
-    const ROOT_MAILBOX  = 'INBOX';
-//    const ROOT_MAILBOX  = '';
-    const ASYNC_KEY     = '/private/vendor/kolab/activesync';
-    const UID_KEY       = '/shared/vendor/cmu/cyrus-imapd/uniqueid';
-
-
-    /**
-     * This implements the 'singleton' design pattern
-     *
-     * @return kolab_sync_backend The one and only instance
-     */
-    static function get_instance()
-    {
-        if (!self::$instance) {
-            self::$instance = new kolab_sync_backend;
-            self::$instance->startup();  // init AFTER object was linked with self::$instance
-        }
-
-        return self::$instance;
-    }
-
-
-    /**
-     * Class initialization
-     */
-    public function startup()
-    {
-        $this->storage = rcube::get_instance()->get_storage();
-
-        // @TODO: reset cache? if we do this for every request the cache would be useless
-        // There's no session here
-        //$this->storage->clear_cache('mailboxes.', true);
-
-        // set additional header used by libkolab
-        $this->storage->set_options(array(
-            // @TODO: there can be Roundcube plugins defining additional headers,
-            // we maybe would need to add them here
-            'fetch_headers' => 'X-KOLAB-TYPE X-KOLAB-MIME-VERSION',
-            'skip_deleted'  => true,
-            'threading'     => false,
-        ));
-
-        // Disable paging
-        $this->storage->set_pagesize(999999);
-    }
-
-
-    /**
-     * List known devices
-     *
-     * @return array Device list as hash array
-     */
-    public function devices_list()
-    {
-        if ($this->root_meta === null) {
-            // @TODO: consider server annotation instead of INBOX
-            if ($meta = $this->storage->get_metadata(self::ROOT_MAILBOX, self::ASYNC_KEY)) {
-                $this->root_meta = $this->unserialize_metadata($meta[self::ROOT_MAILBOX][self::ASYNC_KEY]);
-            }
-            else {
-                $this->root_meta = array();
-            }
-        }
-
-        if (!empty($this->root_meta['DEVICE']) && is_array($this->root_meta['DEVICE'])) {
-            return $this->root_meta['DEVICE'];
-        }
-
-        return array();
-    }
-
-
-    /**
-     * Get list of folders available for sync
-     *
-     * @param string $deviceid  Device identifier
-     * @param string $type      Folder type
-     * @param bool   $flat_mode Enables flat-list mode
-     *
-     * @return array|bool List of mailbox folders, False on backend failure
-     */
-    public function folders_list($deviceid, $type, $flat_mode = false)
-    {
-        // get all folders of specified type
-        $folders = kolab_storage::list_folders('', '*', $type, false, $typedata);
-
-        // get folders activesync config
-        $folderdata = $this->folder_meta();
-
-        if (!is_array($folders) || !is_array($folderdata)) {
-            return false;
-        }
-
-        $folders_list = array();
-
-        // check if folders are "subscribed" for activesync
-        foreach ($folderdata as $folder => $meta) {
-            if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid])
-                || empty($meta['FOLDER'][$deviceid]['S'])
-            ) {
-                continue;
-            }
-
-            // force numeric folder name to be a string (T1283)
-            $folder = (string) $folder;
-
-            if (!empty($type) && !in_array($folder, $folders)) {
-                continue;
-            }
-
-            // Activesync folder identifier (serverId)
-            $folder_type = !empty($typedata[$folder]) ? $typedata[$folder] : 'mail';
-            $folder_id   = self::folder_id($folder, $folder_type);
-
-            $folders_list[$folder_id] = $this->folder_data($folder, $folder_type);
-        }
-
-        if ($flat_mode) {
-            $folders_list = $this->folders_list_flat($folders_list, $type, $typedata);
-        }
-
-        return $folders_list;
-    }
-
-    /**
-     * Converts list of folders to a "flat" list
-     */
-    private function folders_list_flat($folders, $type, $typedata)
-    {
-        $delim = $this->storage->get_hierarchy_delimiter();
-
-        foreach ($folders as $idx => $folder) {
-            if ($folder['parentId']) {
-                // for non-mail folders we make the list completely flat
-                if ($type != 'mail') {
-                    $display_name = kolab_storage::object_name($folder['imap_name']);
-                    $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET);
-
-                    $folders[$idx]['parentId']    = 0;
-                    $folders[$idx]['displayName'] = $display_name;
-                }
-                // for mail folders we modify only folders with non-existing parents
-                else if (!isset($folders[$folder['parentId']])) {
-                    $items  = explode($delim, $folder['imap_name']);
-                    $parent = 0;
-
-                    // find existing parent
-                    while (count($items) > 0) {
-                        array_pop($items);
-
-                        $parent_name = implode($delim, $items);
-                        $parent_type = !empty($typedata[$parent_name]) ? $typedata[$parent_name] : 'mail';
-                        $parent_id   = self::folder_id($parent_name, $parent_type);
-
-                        if (isset($folders[$parent_id])) {
-                            $parent = $parent_id;
-                            break;
-                        }
-                    }
-
-                    if (!$parent) {
-                        $display_name = kolab_storage::object_name($folder['imap_name']);
-                        $display_name = html_entity_decode($display_name, ENT_COMPAT, RCUBE_CHARSET);
-                    }
-                    else {
-                        $parent_name  = $folders[$parent_id]['imap_name'];
-                        $display_name = substr($folder['imap_name'], strlen($parent_name)+1);
-                        $display_name = rcube_charset::convert($display_name, 'UTF7-IMAP');
-                        $display_name = str_replace($delim, ' ยป ', $display_name);
-                    }
-
-                    $folders[$idx]['parentId']    = $parent;
-                    $folders[$idx]['displayName'] = $display_name;
-                }
-            }
-        }
-
-        return $folders;
-    }
-
-    /**
-     * Getter for folder metadata
-     *
-     * @return array|bool Hash array with meta data for each folder, False on backend failure
-     */
-    public function folder_meta()
-    {
-        if (!isset($this->folder_meta)) {
-            // get folders activesync config
-            $folderdata = $this->storage->get_metadata("*", self::ASYNC_KEY);
-
-            if (!is_array($folderdata)) {
-                return $this->folder_meta = false;
-            }
-
-            $this->folder_meta = array();
-
-            foreach ($folderdata as $folder => $meta) {
-                if ($asyncdata = $meta[self::ASYNC_KEY]) {
-                    if ($metadata = $this->unserialize_metadata($asyncdata)) {
-                        $this->folder_meta[$folder] = $metadata;
-                    }
-                }
-            }
-        }
-
-        return $this->folder_meta;
-    }
-
-
-    /**
-     * Creates folder and subscribes to the device
-     *
-     * @param string $name      Folder name (UTF7-IMAP)
-     * @param int    $type      Folder (ActiveSync) type
-     * @param string $deviceid  Device identifier
-     *
-     * @return bool True on success, False on failure
-     */
-    public function folder_create($name, $type, $deviceid)
-    {
-        if ($this->storage->folder_exists($name)) {
-            $created = true;
-        }
-        else {
-            $type    = self::type_activesync2kolab($type);
-            $created = kolab_storage::folder_create($name, $type, true);
-        }
-
-        if ($created) {
-            // Set ActiveSync subscription flag
-            $this->folder_set($name, $deviceid, 1);
-
-            return true;
-        }
-
-        return false;
-    }
-
-
-    /**
-     * Renames a folder
-     *
-     * @param string $old_name  Old folder name (UTF7-IMAP)
-     * @param string $new_name  New folder name (UTF7-IMAP)
-     * @param int    $type      Folder (ActiveSync) type
-     *
-     * @return bool True on success, False on failure
-     */
-    public function folder_rename($old_name, $new_name, $type)
-    {
-        $this->folder_meta = null;
-
-        $type = self::type_activesync2kolab($type);
-
-        // don't use kolab_storage for moving mail folders
-        if (preg_match('/^mail/', $type)) {
-            return $this->storage->rename_folder($old_name, $new_name);
-        }
-        else {
-            return kolab_storage::folder_rename($old_name, $new_name);
-        }
-    }
-
-
-    /**
-     * Deletes folder
-     *
-     * @param string $name      Folder name (UTF7-IMAP)
-     * @param string $deviceid  Device identifier
-     *
-     */
-    public function folder_delete($name, $deviceid)
-    {
-        unset($this->folder_meta[$name]);
-
-        return kolab_storage::folder_delete($name);
-    }
-
-
-    /**
-     * Sets ActiveSync subscription flag on a folder
-     *
-     * @param string $name      Folder name (UTF7-IMAP)
-     * @param string $deviceid  Device identifier
-     * @param int    $flag      Flag value (0|1|2)
-     */
-    public function folder_set($name, $deviceid, $flag)
-    {
-        if (empty($deviceid)) {
-            return false;
-        }
-
-        // get folders activesync config
-        $metadata = $this->folder_meta();
-
-        if (!is_array($metadata)) {
-            return false;
-        }
-
-        $metadata = isset($metadata[$name]) ? $metadata[$name] : array();
-
-        if ($flag)  {
-            if (empty($metadata)) {
-                $metadata = array();
-            }
-
-            if (empty($metadata['FOLDER'])) {
-                $metadata['FOLDER'] = array();
-            }
-
-            if (empty($metadata['FOLDER'][$deviceid])) {
-                $metadata['FOLDER'][$deviceid] = array();
-            }
-
-            // Z-Push uses:
-            //  1 - synchronize, no alarms
-            //  2 - synchronize with alarms
-            $metadata['FOLDER'][$deviceid]['S'] = $flag;
-        }
-        else {
-            unset($metadata['FOLDER'][$deviceid]['S']);
-
-            if (empty($metadata['FOLDER'][$deviceid])) {
-                unset($metadata['FOLDER'][$deviceid]);
-            }
-
-            if (empty($metadata['FOLDER'])) {
-                unset($metadata['FOLDER']);
-            }
-
-            if (empty($metadata)) {
-                $metadata = null;
-            }
-        }
-
-        // Return if nothing's been changed
-        if (!self::data_array_diff(isset($this->folder_meta[$name]) ? $this->folder_meta[$name] : null, $metadata)) {
-            return true;
-        }
-
-        $this->folder_meta[$name] = $metadata;
-
-        return $this->storage->set_metadata($name, array(
-            self::ASYNC_KEY => $this->serialize_metadata($metadata)));
-    }
-
-
-    public function device_get($id)
-    {
-        $devices_list = $this->devices_list();
-        return $devices_list[$id] ?? null;
-    }
-
-    /**
-     * Registers new device on server
-     *
-     * @param array  $device  Device data
-     * @param string $id      Device ID
-     *
-     * @return bool True on success, False on failure
-     */
-    public function device_create($device, $id)
-    {
-        // Fill local cache
-        $this->devices_list();
-
-        // Some devices create dummy devices with name "validate" (#1109)
-        // This device entry is used in two initial requests, but later
-        // the device registers a real name. We can remove this dummy entry
-        // on new device creation
-        $this->device_delete('validate');
-
-        // Old Kolab_ZPush device parameters
-        // MODE:  -1 | 0 | 1  (not set | flatmode | foldermode)
-        // TYPE:  device type string
-        // ALIAS: user-friendly device name
-
-        // Syncroton (kolab_sync_backend_device) uses
-        // ID:    internal identifier in syncroton database
-        // TYPE:  device type string
-        // ALIAS: user-friendly device name
-
-        $metadata = $this->root_meta;
-        $metadata['DEVICE'][$id] = $device;
-        $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata));
-
-        $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata);
-
-        if ($result) {
-            // Update local cache
-            $this->root_meta['DEVICE'][$id] = $device;
-
-            // subscribe default set of folders
-            $this->device_init_subscriptions($id);
-        }
-
-        return $result;
-    }
-
-    /**
-     * Device update.
-     *
-     * @param array  $device  Device data
-     * @param string $id      Device ID
-     *
-     * @return bool True on success, False on failure
-     */
-    public function device_update($device, $id)
-    {
-        $devices_list = $this->devices_list();
-        $old_device   = $devices_list[$id];
-
-        if (!$old_device) {
-            return false;
-        }
-
-        // Do nothing if nothing is changed
-        if (!self::data_array_diff($old_device, $device)) {
-            return true;
-        }
-
-        $device = array_merge($old_device, $device);
-
-        $metadata = $this->root_meta;
-        $metadata['DEVICE'][$id] = $device;
-        $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($metadata));
-
-        $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata);
-
-        if ($result) {
-            // Update local cache
-            $this->root_meta['DEVICE'][$id] = $device;
-        }
-
-        return $result;
-    }
-
-
-    /**
-     * Device delete.
-     *
-     * @param string $id  Device ID
-     *
-     * @return bool True on success, False on failure
-     */
-    public function device_delete($id)
-    {
-        $device = $this->device_get($id);
-
-        if (!$device) {
-            return false;
-        }
-
-        unset($this->root_meta['DEVICE'][$id], $this->root_meta['FOLDER'][$id]);
-
-        if (empty($this->root_meta['DEVICE'])) {
-            unset($this->root_meta['DEVICE']);
-        }
-        if (empty($this->root_meta['FOLDER'])) {
-            unset($this->root_meta['FOLDER']);
-        }
-
-        $metadata = $this->serialize_metadata($this->root_meta);
-        $metadata = array(self::ASYNC_KEY => $metadata);
-
-        // update meta data
-        $result = $this->storage->set_metadata(self::ROOT_MAILBOX, $metadata);
-
-        if ($result) {
-            // remove device annotation for every folder
-            foreach ($this->folder_meta() as $folder => $meta) {
-                // skip root folder (already handled above)
-                if ($folder == self::ROOT_MAILBOX)
-                    continue;
-
-                if (!empty($meta['FOLDER']) && isset($meta['FOLDER'][$id])) {
-                    unset($meta['FOLDER'][$id]);
-
-                    if (empty($meta['FOLDER'])) {
-                        unset($this->folder_meta[$folder]['FOLDER']);
-                        unset($meta['FOLDER']);
-                    }
-                    if (empty($meta)) {
-                        unset($this->folder_meta[$folder]);
-                        $meta = null;
-                    }
-
-                    $metadata = array(self::ASYNC_KEY => $this->serialize_metadata($meta));
-                    $res = $this->storage->set_metadata($folder, $metadata);
-
-                    if ($res && $meta) {
-                        $this->folder_meta[$folder] = $meta;
-                    }
-                }
-            }
-        }
-
-        return $result;
-    }
-
-    /**
-     * Subscribe default set of folders on device registration
-     */
-    private function device_init_subscriptions($deviceid)
-    {
-        // INBOX always exists
-        $this->folder_set('INBOX', $deviceid, 1);
-
-        $supported_types = array(
-            'mail.drafts',
-            'mail.wastebasket',
-            'mail.sentitems',
-            'mail.outbox',
-            'event.default',
-            'contact.default',
-            'note.default',
-            'task.default',
-            'event',
-            'contact',
-            'note',
-            'task',
-            'event.confidential',
-            'event.private',
-            'task.confidential',
-            'task.private',
-        );
-
-        // This default set can be extended by adding following values:
-        $modes = array(
-            'SUB_PERSONAL' => 1, // all subscribed folders in personal namespace
-            'ALL_PERSONAL' => 2, // all folders in personal namespace
-            'SUB_OTHER'    => 4, // all subscribed folders in other users namespace
-            'ALL_OTHER'    => 8, // all folders in other users namespace
-            'SUB_SHARED'   => 16, // all subscribed folders in shared namespace
-            'ALL_SHARED'   => 32, // all folders in shared namespace
-        );
-
-        $rcube   = rcube::get_instance();
-        $config  = $rcube->config;
-        $mode    = (int) $config->get('activesync_init_subscriptions');
-        $folders = array();
-
-        // Subscribe to default folders
-        $foldertypes = kolab_storage::folders_typedata();
-
-        if (!empty($foldertypes)) {
-            $_foldertypes = array_intersect($foldertypes, $supported_types);
-
-            // get default folders
-            foreach ($_foldertypes as $folder => $type) {
-                // only personal folders
-                if ($this->storage->folder_namespace($folder) == 'personal') {
-                    $flag = preg_match('/^(event|task)/', $type) ? 2 : 1;
-                    $this->folder_set($folder, $deviceid, $flag);
-                    $folders[] = $folder;
-                }
-            }
-        }
-
-        // we're in default mode, exit
-        if (!$mode) {
-            return;
-        }
-
-        // below we support additionally all mail folders
-        $supported_types[] = 'mail';
-        $supported_types[] = 'mail.junkemail';
-
-        // get configured special folders
-        $special_folders = array();
-        $map             = array(
-            'drafts' => 'mail.drafts',
-            'junk'   => 'mail.junkemail',
-            'sent'   => 'mail.sentitems',
-            'trash'  => 'mail.wastebasket',
-        );
-
-        foreach ($map as $folder => $type) {
-            if ($folder = $config->get($folder . '_mbox')) {
-                $special_folders[$folder] = $type;
-            }
-        }
-
-        // get folders list(s)
-        if (($mode & $modes['ALL_PERSONAL']) || ($mode & $modes['ALL_OTHER']) || ($mode & $modes['ALL_SHARED'])) {
-            $all_folders = $this->storage->list_folders();
-            if (($mode & $modes['SUB_PERSONAL']) || ($mode & $modes['SUB_OTHER']) || ($mode & $modes['SUB_SHARED'])) {
-                $subscribed_folders = $this->storage->list_folders_subscribed();
-            }
-        }
-        else {
-            $all_folders = $this->storage->list_folders_subscribed();
-        }
-
-        foreach ($all_folders as $folder) {
-            // folder already subscribed
-            if (in_array($folder, $folders)) {
-                continue;
-            }
-
-            $type = ($foldertypes[$folder] ?? null) ?: 'mail';
-            if ($type == 'mail' && isset($special_folders[$folder])) {
-                $type = $special_folders[$folder];
-            }
-
-            if (!in_array($type, $supported_types)) {
-                continue;
-            }
-
-            $ns = strtoupper($this->storage->folder_namespace($folder));
-
-            // subscribe the folder according to configured mode
-            // and folder namespace/subscription status
-            if (($mode & $modes["ALL_$ns"])
-                || (($mode & $modes["SUB_$ns"])
-                    && (!isset($subscribed_folders) || in_array($folder, $subscribed_folders)))
-            ) {
-                $flag = preg_match('/^(event|task)/', $type) ? 2 : 1;
-                $this->folder_set($folder, $deviceid, $flag);
-            }
-        }
-    }
-
-    /**
-     * Helper method to decode saved IMAP metadata
-     */
-    private function unserialize_metadata($str)
-    {
-        if (!empty($str)) {
-            // Support old Z-Push annotation format
-            if ($str[0] != '{') {
-                $str = base64_decode($str);
-            }
-            $data = json_decode($str, true);
-            return $data;
-        }
-
-        return null;
-    }
-
-    /**
-     * Helper method to encode IMAP metadata for saving
-     */
-    private function serialize_metadata($data)
-    {
-        if (!empty($data) && is_array($data)) {
-            $data = json_encode($data);
-//            $data = base64_encode($data);
-            return $data;
-        }
-
-        return null;
-    }
-
-    /**
-     * Returns Kolab folder type for specified ActiveSync type ID
-     */
-    public static function type_activesync2kolab($type)
-    {
-        if (!empty(self::$types[$type])) {
-            return self::$types[$type];
-        }
-
-        return '';
-    }
-
-    /**
-     * Returns ActiveSync folder type for specified Kolab type
-     */
-    public static function type_kolab2activesync($type)
-    {
-        $type = preg_replace('/\.(confidential|private)$/i', '', $type);
-
-        if ($key = array_search($type, self::$types)) {
-            return $key;
-        }
-
-        return key(self::$types);
-    }
-
-    /**
-     * Returns Kolab folder type for specified ActiveSync class name
-     */
-    public static function class_activesync2kolab($class)
-    {
-        if (!empty(self::$classes[$class])) {
-            return self::$classes[$class];
-        }
-
-        return '';
-    }
-
-    /**
-     * Returns folder data in Syncroton format
-     */
-    private function folder_data($folder, $type)
-    {
-        // Folder name parameters
-        $delim = $this->storage->get_hierarchy_delimiter();
-        $items = explode($delim, $folder);
-        $name  = array_pop($items);
-
-        // Folder UID
-        $folder_id = $this->folder_id($folder, $type);
-
-        // Folder type
-        if (strcasecmp($folder, 'INBOX') === 0) {
-            // INBOX is always inbox, prevent from issues related with a change of
-            // folder type annotation (it can be initially unset).
-            $type = 2;
-        }
-        else {
-            $type = self::type_kolab2activesync($type);
-
-            // fix type, if there's no type annotation it's detected as UNKNOWN we'll use 'mail' (12)
-            if ($type == 1) {
-                $type = 12;
-            }
-        }
-
-        // Syncroton folder data array
-        return array(
-            'serverId'    => $folder_id,
-            'parentId'    => count($items) ? self::folder_id(implode($delim, $items)) : 0,
-            'displayName' => rcube_charset::convert($name, 'UTF7-IMAP', kolab_sync::CHARSET),
-            'type'        => $type,
-            // for internal use
-            'imap_name'   => $folder,
-        );
-    }
-
-    /**
-     * Builds folder ID based on folder name
-     */
-    public function folder_id($name, $type = null)
-    {
-        // ActiveSync expects folder identifiers to be max.64 characters
-        // So we can't use just folder name
-
-        $name = (string) $name;
-
-        if ($name === '') {
-            return null;
-        }
-
-        if (isset($this->folder_uids[$name])) {
-            return $this->folder_uids[$name];
-        }
-
-/*
-        @TODO: For now uniqueid annotation doesn't work, we will create UIDs by ourselves.
-               There's one inconvenience of this solution: folder name/type change
-               would be handled in ActiveSync as delete + create.
-
-        // get folders unique identifier
-        $folderdata = $this->storage->get_metadata($name, self::UID_KEY);
-
-        if ($folderdata && !empty($folderdata[$name])) {
-            $uid = $folderdata[$name][self::UID_KEY];
-            return $this->folder_uids[$name] = $uid;
-        }
-*/
-        if (strcasecmp($name, 'INBOX') === 0) {
-            // INBOX is always inbox, prevent from issues related with a change of
-            // folder type annotation (it can be initially unset).
-            $type = 'mail.inbox';
-        }
-        else {
-            if ($type === null) {
-                $type = kolab_storage::folder_type($name);
-            }
-
-            if ($type != null) {
-                $type = preg_replace('/\.(confidential|private)$/i', '', $type);
-            }
-        }
-
-        // Add type to folder UID hash, so type change can be detected by Syncroton
-        $uid = $name . '!!' . $type;
-        $uid = md5($uid);
-
-        return $this->folder_uids[$name] = $uid;
-    }
-
-    /**
-     * Returns IMAP folder name
-     *
-     * @param string $id        Folder identifier
-     * @param string $deviceid  Device dentifier
-     *
-     * @return string Folder name (UTF7-IMAP)
-     */
-    public function folder_id2name($id, $deviceid)
-    {
-        // check in cache first
-        if (!empty($this->folder_uids)) {
-            if (($name = array_search($id, $this->folder_uids)) !== false) {
-                return $name;
-            }
-        }
-
-/*
-        @TODO: see folder_id()
-
-        // get folders unique identifier
-        $folderdata = $this->storage->get_metadata('*', self::UID_KEY);
-
-        foreach ((array)$folderdata as $folder => $data) {
-            if (!empty($data[self::UID_KEY])) {
-                $uid = $data[self::UID_KEY];
-                $this->folder_uids[$folder] = $uid;
-                if ($uid == $id) {
-                    $name = $folder;
-                }
-            }
-        }
-*/
-        // get all folders of specified type
-        $folderdata = $this->folder_meta();
-
-        if (!is_array($folderdata) || $id === null) {
-            return null;
-        }
-
-        // check if folders are "subscribed" for activesync
-        foreach ($folderdata as $folder => $meta) {
-            if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid])
-                || empty($meta['FOLDER'][$deviceid]['S'])
-            ) {
-                continue;
-            }
-
-            if ($uid = self::folder_id($folder)) {
-                $this->folder_uids[$folder] = $uid;
-            }
-
-            if ($uid === $id) {
-                $name = $folder;
-            }
-        }
-
-        return $name;
-    }
-
-    /**
-     */
-    public function modseq_set($deviceid, $folderid, $synctime, $data)
-    {
-        $synctime = $synctime->format('Y-m-d H:i:s');
-        $rcube    = rcube::get_instance();
-        $db       = $rcube->get_dbh();
-        $old_data = $this->modseq[$folderid][$synctime] ?? null;
-
-        if (empty($old_data)) {
-            $this->modseq[$folderid][$synctime] = $data;
-            $data = json_encode($data);
-
-            $db->set_option('ignore_key_errors', true);
-            $db->query("INSERT INTO `syncroton_modseq` (`device_id`, `folder_id`, `synctime`, `data`)"
-                ." VALUES (?, ?, ?, ?)",
-                $deviceid, $folderid, $synctime, $data);
-            $db->set_option('ignore_key_errors', false);
-        }
-    }
-
-    public function modseq_get($deviceid, $folderid, $synctime)
-    {
-        $synctime = $synctime->format('Y-m-d H:i:s');
-
-        if (empty($this->modseq[$folderid][$synctime])) {
-            $this->modseq[$folderid] = array();
-
-            $rcube = rcube::get_instance();
-            $db    = $rcube->get_dbh();
-
-            $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_modseq`"
-                ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?"
-                ." ORDER BY `synctime` DESC",
-                0, 1, $deviceid, $folderid, $synctime);
-
-            if ($row = $db->fetch_assoc()) {
-                $synctime = $row['synctime'];
-                // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format
-                $this->modseq[$folderid][$synctime] = json_decode($row['data'], true);
-            }
-
-            // Cleanup: remove all records except the current one
-            $db->query("DELETE FROM `syncroton_modseq`"
-                ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?",
-                $deviceid, $folderid, $synctime);
-        }
-
-        return @$this->modseq[$folderid][$synctime];
-    }
-
-    /**
-     * Set state of relation objects at specified point in time
-     */
-    public function relations_state_set($deviceid, $folderid, $synctime, $relations)
-    {
-        $synctime = $synctime->format('Y-m-d H:i:s');
-        $rcube    = rcube::get_instance();
-        $db       = $rcube->get_dbh();
-        $old_data = $this->relations[$folderid][$synctime] ?? null;
-
-        if (empty($old_data)) {
-            $this->relations[$folderid][$synctime] = $relations;
-            $data = rcube_charset::clean(json_encode($relations));
-
-            $db->set_option('ignore_key_errors', true);
-            $db->query("INSERT INTO `syncroton_relations_state`"
-                ." (`device_id`, `folder_id`, `synctime`, `data`)"
-                ." VALUES (?, ?, ?, ?)",
-                $deviceid, $folderid, $synctime, $data);
-            $db->set_option('ignore_key_errors', false);
-        }
-    }
-
-    /**
-     * Get state of relation objects at specified point in time
-     */
-    public function relations_state_get($deviceid, $folderid, $synctime)
-    {
-        $synctime = $synctime->format('Y-m-d H:i:s');
-
-        if (empty($this->relations[$folderid][$synctime])) {
-            $this->relations[$folderid] = array();
-
-            $rcube = rcube::get_instance();
-            $db    = $rcube->get_dbh();
-
-            $db->limitquery("SELECT `data`, `synctime` FROM `syncroton_relations_state`"
-                ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <= ?"
-                ." ORDER BY `synctime` DESC",
-                0, 1, $deviceid, $folderid, $synctime);
-
-            if ($row = $db->fetch_assoc()) {
-                $synctime = $row['synctime'];
-                // @TODO: make sure synctime from sql is in "Y-m-d H:i:s" format
-                $this->relations[$folderid][$synctime] = json_decode($row['data'], true);
-            }
-
-            // Cleanup: remove all records except the current one
-            $db->query("DELETE FROM `syncroton_relations_state`"
-                ." WHERE `device_id` = ? AND `folder_id` = ? AND `synctime` <> ?",
-                $deviceid, $folderid, $synctime);
-        }
-
-        return @$this->relations[$folderid][$synctime];
-    }
-
-    /**
-     * Return last storage error
-     */
-    public static function last_error()
-    {
-        return kolab_storage::$last_error;
-    }
-
-    /**
-     * Compares two arrays
-     *
-     * @param array $array1
-     * @param array $array2
-     *
-     * @return bool True if arrays differs, False otherwise
-     */
-    private static function data_array_diff($array1, $array2)
-    {
-        if (!is_array($array1) || !is_array($array2)) {
-            return $array1 != $array2;
-        }
-
-        if (count($array1) != count($array2)) {
-            return true;
-        }
-
-        foreach ($array1 as $key => $val) {
-            if (!array_key_exists($key, $array2)) {
-                return true;
-            }
-            if ($val !== $array2[$key]) {
-                return true;
-            }
-        }
-
-        return false;
-    }
-}
diff --git a/lib/kolab_sync_backend_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 <folder>::<uid>[::<part_id>]
         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 @@
+<?php
+
+/**
+ +--------------------------------------------------------------------------+
+ | Kolab Sync (ActiveSync for Kolab)                                        |
+ |                                                                          |
+ | Copyright (C) 2011-2012, Kolab Systems AG <contact@kolabsys.com>         |
+ |                                                                          |
+ | This program is free software: you can redistribute it and/or modify     |
+ | it under the terms of the GNU Affero General Public License as published |
+ | by the Free Software Foundation, either version 3 of the License, or     |
+ | (at your option) any later version.                                      |
+ |                                                                          |
+ | This program is distributed in the hope that it will be useful,          |
+ | but WITHOUT ANY WARRANTY; without even the implied warranty of           |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the             |
+ | GNU Affero General Public License for more details.                      |
+ |                                                                          |
+ | You should have received a copy of the GNU Affero General Public License |
+ | along with this program. If not, see <http://www.gnu.org/licenses/>      |
+ +--------------------------------------------------------------------------+
+ | Author: Aleksander Machniak <machniak@kolabsys.com>                      |
+ +--------------------------------------------------------------------------+
+*/
+
+/**
+ * Storage handling class with basic Kolab support (everything stored in IMAP)
+ */
+class kolab_sync_storage
+{
+    const INIT_SUB_PERSONAL = 1; // all subscribed folders in personal namespace
+    const INIT_ALL_PERSONAL = 2; // all folders in personal namespace
+    const INIT_SUB_OTHER    = 4; // all subscribed folders in other users namespace
+    const INIT_ALL_OTHER    = 8; // all folders in other users namespace
+    const INIT_SUB_SHARED   = 16; // all subscribed folders in shared namespace
+    const INIT_ALL_SHARED   = 32; // all folders in shared namespace
+
+    const MODEL_CALENDAR = 'event';
+    const MODEL_CONTACTS = 'contact';
+    const MODEL_EMAIL    = 'mail';
+    const MODEL_NOTES    = 'note';
+    const MODEL_TASKS    = 'task';
+
+    const ROOT_MAILBOX  = 'INBOX';
+    const ASYNC_KEY     = '/private/vendor/kolab/activesync';
+    const UID_KEY       = '/shared/vendor/cmu/cyrus-imapd/uniqueid';
+
+    const CTYPE_KEY         = '/shared/vendor/kolab/folder-type';
+    const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type';
+
+    public $syncTimeStamp;
+
+    protected $storage;
+    protected $folder_meta;
+    protected $folder_uids;
+    protected $folders = [];
+    protected $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 @@
+<?php
+
+/**
+ +--------------------------------------------------------------------------+
+ | Kolab Sync (ActiveSync for Kolab)                                        |
+ |                                                                          |
+ | Copyright (C) 2011-2023, Apheleia IT AG <contact@apheleia-it.ch>         |
+ |                                                                          |
+ | This program is free software: you can redistribute it and/or modify     |
+ | it under the terms of the GNU Affero General Public License as published |
+ | by the Free Software Foundation, either version 3 of the License, or     |
+ | (at your option) any later version.                                      |
+ |                                                                          |
+ | This program is distributed in the hope that it will be useful,          |
+ | but WITHOUT ANY WARRANTY; without even the implied warranty of           |
+ | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the             |
+ | GNU Affero General Public License for more details.                      |
+ |                                                                          |
+ | You should have received a copy of the GNU Affero General Public License |
+ | along with this program. If not, see <http://www.gnu.org/licenses/>      |
+ +--------------------------------------------------------------------------+
+ | Author: Aleksander Machniak <machniak@kolabsys.com>                      |
+ +--------------------------------------------------------------------------+
+*/
+
+/**
+ * Storage handling class with Kolab 4 support (IMAP + CalDAV + CardDAV)
+ */
+class kolab_sync_storage_kolab4 extends kolab_sync_storage
+{
+    protected $davStorage = null;
+    protected $relationSupport = false;
+
+    /**
+     * This implements the 'singleton' design pattern
+     *
+     * @return kolab_sync_storage_kolab4 The one and only instance
+     */
+    public static function get_instance()
+    {
+        if (!self::$instance) {
+            self::$instance = new kolab_sync_storage_kolab4();
+            self::$instance->startup();  // init AFTER object was linked with self::$instance
+        }
+
+        return self::$instance;
+    }
+
+    /**
+     * Class initialization
+     */
+    public function startup()
+    {
+        $sync = kolab_sync::get_instance();
+
+        if ($sync->username === null || $sync->password === null) {
+            throw new Exception("Unsupported storage handler use!");
+        }
+
+        $url = $sync->config->get('activesync_dav_server', 'http://localhost');
+
+        if (strpos($url, '://') === false) {
+            $url = 'http://' . $url;
+        }
+
+        // Inject user+password to the URL, there's no other way to pass it to the DAV client
+        $url = str_replace('://', '://' . rawurlencode($sync->username) . ':' . rawurlencode($sync->password) . '@', $url);
+
+        $this->davStorage = new kolab_storage_dav($url); // DAV
+        $this->storage = $sync->get_storage(); // IMAP
+
+        // set additional header used by libkolab
+        $this->storage->set_options([
+                'skip_deleted'  => true,
+                'threading'     => false,
+        ]);
+
+        // Disable paging
+        $this->storage->set_pagesize(999999);
+    }
+
+    /**
+     * Get list of folders available for sync
+     *
+     * @param string $deviceid  Device identifier
+     * @param string $type      Folder (class) type
+     * @param bool   $flat_mode Enables flat-list mode
+     *
+     * @return array|bool List of mailbox folders, False on backend failure
+     */
+    public function folders_list($deviceid, $type, $flat_mode = false)
+    {
+        $list = [];
+
+        // get mail folders subscribed for sync
+        if ($type === self::MODEL_EMAIL) {
+            $folderdata = $this->folder_meta();
+
+            if (!is_array($folderdata)) {
+                return false;
+            }
+
+            $special_folders = $this->storage->get_special_folders(true);
+            $type_map = [
+                'drafts' => 3,
+                'trash' => 4,
+                'sent' => 5,
+            ];
+
+            // Get the folders "subscribed" for activesync
+            foreach ($folderdata as $folder => $meta) {
+                if (empty($meta['FOLDER']) || empty($meta['FOLDER'][$deviceid])
+                    || empty($meta['FOLDER'][$deviceid]['S'])
+                ) {
+                    continue;
+                }
+
+                // Force numeric folder name to be a string (T1283)
+                $folder = (string) $folder;
+
+                // Activesync folder properties
+                $folder_data = $this->folder_data($folder, 'mail');
+
+                // Set proper type for special folders
+                if (($type = array_search($folder, $special_folders)) && isset($type_map[$type])) {
+                    $folder_data['type'] = $type_map[$type];
+                }
+
+                $list[$folder_data['serverId']] = $folder_data;
+            }
+        }
+        else if (in_array($type, [self::MODEL_CONTACTS, self::MODEL_CALENDAR, self::MODEL_TASKS])) {
+            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 @@
+<?php
+
+class FoldersTest extends Tests\SyncTestCase
+{
+    /**
+     * Test FolderSync command
+     */
+    public function testFolderSync()
+    {
+        // Note: We essentially assume the test account is in an initial state, extra folders may break tests
+        // Anyway, we first remove folders that might have been created during tests in this file
+        $this->deleteTestFolder('Test Folder', 'mail');
+        $this->deleteTestFolder('Test Folder New', 'mail');
+        $this->deleteTestFolder('Test Contacts Folder', 'contact');
+        $this->deleteTestFolder('Test Contacts New', 'contact');
+
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderSync xmlns="uri:FolderHierarchy">
+            <SyncKey>0</SyncKey>
+        </FolderSync>
+        EOF;
+
+        $response = $this->request($request, 'FolderSync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        // Note: We're expecting activesync_init_subscriptions=0 here.
+        if ($this->isStorageDriver('kolab4')) {
+            $folders = [
+                ['Calendar', Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR],
+                ['Contacts', Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT],
+                ['INBOX', Syncroton_Command_FolderSync::FOLDERTYPE_INBOX],
+                ['Drafts', Syncroton_Command_FolderSync::FOLDERTYPE_DRAFTS],
+                ['Sent', Syncroton_Command_FolderSync::FOLDERTYPE_SENTMAIL],
+                ['Trash', Syncroton_Command_FolderSync::FOLDERTYPE_DELETEDITEMS],
+                ['Tasks', Syncroton_Command_FolderSync::FOLDERTYPE_TASK],
+            ];
+
+        } else {
+            $folders = [
+                ['Calendar', Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR],
+                ['Contacts', Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT],
+                ['INBOX', Syncroton_Command_FolderSync::FOLDERTYPE_INBOX],
+                ['Drafts', Syncroton_Command_FolderSync::FOLDERTYPE_DRAFTS],
+                ['Sent', Syncroton_Command_FolderSync::FOLDERTYPE_SENTMAIL],
+                ['Trash', Syncroton_Command_FolderSync::FOLDERTYPE_DELETEDITEMS],
+                ['Notes', Syncroton_Command_FolderSync::FOLDERTYPE_NOTE],
+                ['Tasks', Syncroton_Command_FolderSync::FOLDERTYPE_TASK],
+            ];
+        }
+
+        $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame(strval(count($folders)), $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue);
+
+        foreach ($folders as $idx => $folder) {
+            $this->assertSame($folder[0], $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item($idx)->nodeValue);
+            $this->assertSame((string) $folder[1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item($idx)->nodeValue);
+            $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item($idx)->nodeValue);
+        }
+
+        // Test with multi-folder support enabled
+        self::$deviceType = 'iphone';
+
+        $response = $this->request($request, 'FolderSync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        if ($this->isStorageDriver('kolab4')) {
+            $folders = [
+                ['Calendar', Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED],
+                // Note: Kolab 4 with Cyrus DAV uses Addressbook, but Kolab 3 with iRony would use 'Contacts'
+                ['/^(Contacts|Addressbook)$/', Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED],
+                ['INBOX', Syncroton_Command_FolderSync::FOLDERTYPE_INBOX],
+                ['Drafts', Syncroton_Command_FolderSync::FOLDERTYPE_DRAFTS],
+                ['Sent', Syncroton_Command_FolderSync::FOLDERTYPE_SENTMAIL],
+                ['Trash', Syncroton_Command_FolderSync::FOLDERTYPE_DELETEDITEMS],
+                // Note: For now Kolab 4 uses the same Calendar folder for calendar and tasks
+                ['/^(Tasks|Calendar)$/', Syncroton_Command_FolderSync::FOLDERTYPE_TASK_USER_CREATED]
+            ];
+        }
+
+        $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame(strval(count($folders)), $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue);
+
+        foreach ($folders as $idx => $folder) {
+            $displayName = $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item($idx)->nodeValue;
+            if (str_starts_with($folder[0], '/')) {
+                $this->assertMatchesRegularExpression($folder[0], $displayName);
+            } else {
+                $this->assertSame($folder[0], $displayName);
+            }
+            $this->assertSame((string) $folder[1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item($idx)->nodeValue);
+            $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item($idx)->nodeValue);
+            $idx++;
+        }
+
+        // After we switched to multi-folder supported mode we expect next FolderSync
+        // to delete the old "collective" folders
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderSync xmlns="uri:FolderHierarchy">
+            <SyncKey>1</SyncKey>
+        </FolderSync>
+        EOF;
+
+        $response = $this->request($request, 'FolderSync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $deleted = $this->isStorageDriver('kolab4') ? 3 : 4; // No Notes folder in Kolab4
+        $syncKey = 2;
+
+        $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval($syncKey), $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame(strval($deleted), $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue);
+        $this->assertSame($deleted, $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete")->length);
+
+        return $syncKey;
+    }
+
+    /**
+     * Test FolderCreate command
+     *
+     * @depends testFolderSync
+     */
+    public function testFolderCreate($syncKey)
+    {
+        // Multi-folder mode
+        self::$deviceType = 'iphone';
+
+        // Create a mail folder
+        $folderName1 = 'Test Folder';
+        $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderCreate xmlns="uri:FolderHierarchy">
+            <SyncKey>{$syncKey}</SyncKey>
+            <ParentId>0</ParentId>
+            <DisplayName>{$folderName1}</DisplayName>
+            <Type>{$folderType}</Type>
+        </FolderCreate>
+        EOF;
+
+        $response = $this->request($request, 'FolderCreate');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:FolderCreate/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("//ns:FolderCreate/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame(1, $xpath->query("//ns:FolderCreate/ns:ServerId")->count());
+        $folder1 = $xpath->query("//ns:FolderCreate/ns:ServerId")->item(0)->nodeValue;
+
+        // Note: After FolderCreate there are no changes in the following FolderSync expected
+
+        // Create a contacts folder
+        $folderName2 = 'Test Contacts Folder';
+        $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderCreate xmlns="uri:FolderHierarchy">
+            <SyncKey>{$syncKey}</SyncKey>
+            <ParentId>0</ParentId>
+            <DisplayName>{$folderName2}</DisplayName>
+            <Type>{$folderType}</Type>
+        </FolderCreate>
+        EOF;
+
+        $response = $this->request($request, 'FolderCreate');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:FolderCreate/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("//ns:FolderCreate/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame(1, $xpath->query("//ns:FolderCreate/ns:ServerId")->count());
+        $folder2 = $xpath->query("//ns:FolderCreate/ns:ServerId")->item(0)->nodeValue;
+
+        // Note: After FolderCreate there are no changes in the following FolderSync expected
+
+        // TODO: Test folder with a parent
+
+        return [
+            'SyncKey' => $syncKey,
+            'folders' => [
+                $folder1,
+                $folder2,
+            ]
+        ];
+    }
+
+    /**
+     * Test FolderUpdate command
+     *
+     * @depends testFolderCreate
+     */
+    public function testFolderUpdate($params)
+    {
+        // Multi-folder mode
+        self::$deviceType = 'iphone';
+
+        // Test renaming a mail folder
+        $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_MAIL_USER_CREATED;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderUpdate xmlns="uri:FolderHierarchy">
+            <SyncKey>{$params['SyncKey']}</SyncKey>
+            <ServerId>{$params['folders'][0]}</ServerId>
+            <ParentId/>
+            <DisplayName>Test Folder New</DisplayName>
+            <Type>{$folderType}</Type>
+        </FolderUpdate>
+        EOF;
+
+        $response = $this->request($request, 'FolderUpdate');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:FolderUpdate/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderUpdate/ns:SyncKey")->item(0)->nodeValue);
+
+        // Test FolderSync after folder update, get the new folder id (for delete test)
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderSync xmlns="uri:FolderHierarchy">
+            <SyncKey>{$params['SyncKey']}</SyncKey>
+        </FolderSync>
+        EOF;
+
+        $response = $this->request($request, 'FolderSync');
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        // Note we expect Add+Delete here, instead of Update (but this could change in the future)
+        $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame(1, $xpath->query("//ns:FolderSync/ns:Changes/ns:Add")->length);
+        $this->assertSame(1, $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete")->length);
+        $this->assertSame($params['folders'][0], $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete/ns:ServerId")->item(0)->nodeValue);
+        $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item(0)->nodeValue);
+        $this->assertSame('Test Folder New', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item(0)->nodeValue);
+        $this->assertSame(strval($folderType), $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item(0)->nodeValue);
+        $params['folders'][0] = $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ServerId")->item(0)->nodeValue;
+
+        // Test renaming a contacts folder
+        $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CONTACT_USER_CREATED;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderUpdate xmlns="uri:FolderHierarchy">
+            <SyncKey>{$params['SyncKey']}</SyncKey>
+            <ServerId>{$params['folders'][1]}</ServerId>
+            <ParentId/>
+            <DisplayName>Test Contacts New</DisplayName>
+            <Type>{$folderType}</Type>
+        </FolderUpdate>
+        EOF;
+
+        $response = $this->request($request, 'FolderUpdate');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:FolderUpdate/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderUpdate/ns:SyncKey")->item(0)->nodeValue);
+
+        // Test FolderSync after folder update, get the new folder id (for delete test)
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderSync xmlns="uri:FolderHierarchy">
+            <SyncKey>{$params['SyncKey']}</SyncKey>
+        </FolderSync>
+        EOF;
+
+        $response = $this->request($request, 'FolderSync');
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderSync/ns:SyncKey")->item(0)->nodeValue);
+
+        if ($this->isStorageDriver('kolab4')) {
+            // Note we expect Update here, not Add+Delete, folder ID does not change
+            $this->assertSame('1', $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue);
+            $this->assertSame($params['folders'][1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Update/ns:ServerId")->item(0)->nodeValue);
+            $this->assertSame('Test Contacts New', $xpath->query("//ns:FolderSync/ns:Changes/ns:Update/ns:DisplayName")->item(0)->nodeValue);
+            $this->assertSame(strval($folderType), $xpath->query("//ns:FolderSync/ns:Changes/ns:Update/ns:Type")->item(0)->nodeValue);
+        } else {
+            // Note we expect Add+Delete here, instead of Update (but this could change in the future)
+            $this->assertSame('2', $xpath->query("//ns:FolderSync/ns:Changes/ns:Count")->item(0)->nodeValue);
+            $this->assertSame($params['folders'][1], $xpath->query("//ns:FolderSync/ns:Changes/ns:Delete/ns:ServerId")->item(0)->nodeValue);
+            $this->assertSame('0', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ParentId")->item(0)->nodeValue);
+            $this->assertSame('Test Contacts New', $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:DisplayName")->item(0)->nodeValue);
+            $this->assertSame(strval($folderType), $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:Type")->item(0)->nodeValue);
+            $params['folders'][1] = $xpath->query("//ns:FolderSync/ns:Changes/ns:Add/ns:ServerId")->item(0)->nodeValue;
+        }
+
+        // TODO: Test folder with a parent change
+        // TODO: Assert the folder name has changed in the storage
+        // TODO: Test Sync after a DAV folder rename made in another client
+
+        return $params;
+    }
+
+    /**
+     * Test FolderDelete command
+     *
+     * @depends testFolderUpdate
+     */
+    public function testFolderDelete($params)
+    {
+        // Multi-folder mode
+        self::$deviceType = 'iphone';
+
+        // Delete mail folder
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderDelete xmlns="uri:FolderHierarchy">
+            <SyncKey>{$params['SyncKey']}</SyncKey>
+            <ServerId>{$params['folders'][0]}</ServerId>
+        </FolderDelete>
+        EOF;
+
+        $response = $this->request($request, 'FolderDelete');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:FolderDelete/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderDelete/ns:SyncKey")->item(0)->nodeValue);
+
+        // Note: After FolderDelete there are no changes in the following FolderSync expected
+
+        // Delete contacts folder
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderDelete xmlns="uri:FolderHierarchy">
+            <SyncKey>{$params['SyncKey']}</SyncKey>
+            <ServerId>{$params['folders'][1]}</ServerId>
+        </FolderDelete>
+        EOF;
+
+        $response = $this->request($request, 'FolderDelete');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:FolderDelete/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$params['SyncKey']), $xpath->query("//ns:FolderDelete/ns:SyncKey")->item(0)->nodeValue);
+
+        // Note: After FolderDelete there are no changes in the following FolderSync expected
+
+        // TODO: Assert the folders no longer exist
+    }
+}
diff --git a/tests/Sync/ItemOperationsTest.php b/tests/Sync/ItemOperationsTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/ItemOperationsTest.php
@@ -0,0 +1,81 @@
+<?php
+
+class ItemOperationsTest extends Tests\SyncTestCase
+{
+    /**
+     * Test ItemOperations::EmptyFolderContents request
+     */
+    public function testEmptyFolderContents()
+    {
+        $this->registerDevice();
+
+        // TODO: Test invalid folder ID
+        $collectionId = 'AAAAAAAAAAAA';
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <ItemOperations xmlns="uri:ItemOperations" xmlns:AirSync="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <EmptyFolderContents>
+                <AirSync:CollectionId>{$collectionId}</AirSync:CollectionId>
+                <Options><DeleteSubFolders/></Options>
+            </EmptyFolderContents>
+        </ItemOperations>
+        EOF;
+
+        $response = $this->request($request, 'ItemOperations');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:ItemOperations/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(
+            strval(Syncroton_Exception_Status_ItemOperations::ITEM_SERVER_ERROR),
+            $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/ns:Status")->item(0)->nodeValue
+        );
+        $this->assertSame(
+            $collectionId,
+            $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/AirSync:CollectionId")->item(0)->nodeValue
+        );
+
+        // Test Trash folder
+        $collectionId = array_search('Trash', $this->folders);
+        $this->assertIsString($collectionId);
+
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <ItemOperations xmlns="uri:ItemOperations" xmlns:AirSync="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <EmptyFolderContents>
+                <AirSync:CollectionId>{$collectionId}</AirSync:CollectionId>
+                <Options><DeleteSubFolders/></Options>
+            </EmptyFolderContents>
+        </ItemOperations>
+        EOF;
+
+        $response = $this->request($request, 'ItemOperations');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:ItemOperations/ns:Status")->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/ns:Status")->item(0)->nodeValue);
+        $this->assertSame($collectionId, $xpath->query("//ns:ItemOperations/ns:Response/ns:EmptyFolderContents/AirSync:CollectionId")->item(0)->nodeValue);
+
+        // TODO: Test DAV folder
+        // TODO: Test a folder with subfolders
+        // TODO: Test non-empty folder and assert that all objects are gone
+        $this->markTestIncomplete();
+    }
+
+    /**
+     * Test ItemOperations::Fetch request
+     */
+    public function testFetch()
+    {
+        $this->markTestIncomplete();
+    }
+}
diff --git a/tests/Sync/MeetingResponseTest.php b/tests/Sync/MeetingResponseTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/MeetingResponseTest.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Tests\Sync;
+
+class MeetingResponseTest extends \Tests\SyncTestCase
+{
+    /**
+     * Test MeetingResponse command
+     */
+    public function testAcceptingInvitation()
+    {
+        $this->emptyTestFolder($davFolder = 'Calendar', 'event');
+        $this->emptyTestFolder('INBOX', 'mail');
+
+        $this->registerDevice();
+
+        // Do the initial INBOX sync
+        $folderId = '38b950ebd62cd9a66929c89615d0fc04'; // INBOX
+        $syncKey = 0;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync">
+            <Collections>
+                <Collection>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>{$folderId}</CollectionId>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:SyncKey")->item(0)->nodeValue);
+
+        // Append an invitation email, and sync it
+        $sync = \kolab_sync::get_instance();
+        $replace = [
+            '$from' => 'test.test@domain.tld',
+            '$to' => $sync->config->get('activesync_test_username'),
+        ];
+        $this->appendMail('INBOX', 'mail.itip1', $replace);
+
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync">
+            <Collections>
+                <Collection>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>{$folderId}</CollectionId>
+                    <DeletesAsMoves>1</DeletesAsMoves>
+                    <GetChanges>1</GetChanges>
+                    <WindowSize>1</WindowSize>
+                    <Options>
+                        <FilterType>0</FilterType>
+                        <Conflict>1</Conflict>
+                        <BodyPreference xmlns="uri:AirSyncBase">
+                            <Type>2</Type>
+                            <TruncationSize>51200</TruncationSize>
+                            <AllOrNone>0</AllOrNone>
+                        </BodyPreference>
+                    </Options>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0);
+        $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue);
+        $this->assertSame(1, $xpath->query("ns:Commands/ns:Add", $root)->count());
+
+        $serverId = $xpath->query("ns:Commands/ns:Add/ns:ServerId", $root)->item(0)->nodeValue;
+
+        $root = $xpath->query("ns:Commands/ns:Add/ns:ApplicationData", $root)->item(0);
+        $this->assertSame('You\'ve been invited to "Test"', $xpath->query("Email:Subject", $root)->item(0)->nodeValue);
+        $this->assertSame('Organizer <test.test@domain.tld>', $xpath->query("Email:From", $root)->item(0)->nodeValue);
+        $this->assertSame($replace['$to'], $xpath->query("Email:To", $root)->item(0)->nodeValue);
+        $this->assertSame('0', $xpath->query("Email:Read", $root)->item(0)->nodeValue);
+        $this->assertSame('IPM.Schedule.Meeting.Request', $xpath->query("Email:MessageClass", $root)->item(0)->nodeValue);
+        $this->assertSame('urn:content-classes:calendarmessage', $xpath->query("Email:ContentClass", $root)->item(0)->nodeValue);
+        $root = $xpath->query("Email:MeetingRequest", $root)->item(0);
+        $this->assertSame('0', $xpath->query("Email:AllDayEvent", $root)->item(0)->nodeValue);
+        $this->assertSame('2023-12-07T13:00:00.000Z', $xpath->query("Email:StartTime", $root)->item(0)->nodeValue);
+        $this->assertSame('2023-12-07T13:30:00.000Z', $xpath->query("Email:EndTime", $root)->item(0)->nodeValue);
+        $this->assertSame('test.test@domain.tld', $xpath->query("Email:Organizer", $root)->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("Email:ResponseRequested", $root)->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("Email:DisallowNewTimeProposal", $root)->item(0)->nodeValue);
+
+        // Accept the invitation
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <MeetingResponse xmlns="uri:MeetingResponse" xmlns:Search="uri:Search">
+            <Request>
+                <UserResponse>1</UserResponse>
+                <CollectionId>{$folderId}</CollectionId>
+                <RequestId>{$serverId}</RequestId>
+            </Request>
+        </MeetingResponse>
+        EOF;
+
+        $response = $this->request($request, 'MeetingResponse');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+        $xpath->registerNamespace('MeetingResponse', 'uri:MeetingResponse');
+
+        $root = $xpath->query("//MeetingResponse:MeetingResponse/MeetingResponse:Result")->item(0);
+        $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue);
+        $this->assertSame($serverId, $xpath->query("ns:RequestId", $root)->item(0)->nodeValue);
+        $this->assertStringMatchesFormat("CRC%s", $xpath->query("ns:CalendarId", $root)->item(0)->nodeValue);
+    }
+}
diff --git a/tests/Sync/MoveItemsTest.php b/tests/Sync/MoveItemsTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/MoveItemsTest.php
@@ -0,0 +1,334 @@
+<?php
+
+class MoveItemsTest extends Tests\SyncTestCase
+{
+    /**
+     * Test moving an email message
+     */
+    public function testMoveEmail()
+    {
+        $this->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 = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync">
+            <Collections>
+                <Collection>
+                    <SyncKey>0</SyncKey>
+                    <CollectionId>{$inbox}</CollectionId>
+                </Collection>
+                <Collection>
+                    <SyncKey>0</SyncKey>
+                    <CollectionId>{$trash}</CollectionId>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        // Sync mail from INBOX and Trash
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <SyncKey>1</SyncKey>
+                    <CollectionId>{$inbox}</CollectionId>
+                    <GetChanges>1</GetChanges>
+                </Collection>
+                <Collection>
+                    <SyncKey>1</SyncKey>
+                    <CollectionId>{$trash}</CollectionId>
+                    <GetChanges>1</GetChanges>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame(1, $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->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 = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <MoveItems xmlns="uri:Move">
+            <Move>
+                <SrcMsgId>{$inbox}::{$uid}</SrcMsgId>
+                <SrcFldId>{$inbox}</SrcFldId>
+                <DstFldId>{$trash}</DstFldId>
+            </Move>
+        </MoveItems>
+        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 = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <SyncKey>2</SyncKey>
+                    <CollectionId>{$inbox}</CollectionId>
+                    <GetChanges>1</GetChanges>
+                </Collection>
+                <Collection>
+                    <SyncKey>1</SyncKey>
+                    <CollectionId>{$trash}</CollectionId>
+                    <GetChanges>1</GetChanges>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = $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 = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderCreate xmlns="uri:FolderHierarchy">
+            <SyncKey>1</SyncKey>
+            <ParentId>0</ParentId>
+            <DisplayName>{$folderName}</DisplayName>
+            <Type>{$folderType}</Type>
+        </FolderCreate>
+        EOF;
+
+        $response = $this->request($request, 'FolderCreate');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $dstFolderId = $xpath->query("//ns:FolderCreate/ns:ServerId")->item(0)->nodeValue;
+
+        // Sync both folders
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>0</SyncKey>
+                    <CollectionId>{$srcFolderId}</CollectionId>
+                </Collection>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>0</SyncKey>
+                    <CollectionId>{$dstFolderId}</CollectionId>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>1</SyncKey>
+                    <CollectionId>{$srcFolderId}</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                </Collection>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>1</SyncKey>
+                    <CollectionId>{$dstFolderId}</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = $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 = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <MoveItems xmlns="uri:Move">
+            <Move>
+                <SrcMsgId>{$srcMsgId}</SrcMsgId>
+                <SrcFldId>{$srcFolderId}</SrcFldId>
+                <DstFldId>{$dstFolderId}</DstFldId>
+            </Move>
+        </MoveItems>
+        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 = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>2</SyncKey>
+                    <CollectionId>{$srcFolderId}</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                </Collection>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>1</SyncKey>
+                    <CollectionId>{$dstFolderId}</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = $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 @@
+<?php
+
+class OptionsTest extends Tests\SyncTestCase
+{
+    /**
+     * Test Options command/request
+     */
+    public function testOptions()
+    {
+        $response = self::$client->request('OPTIONS', '');
+        $this->assertEquals(200, $response->getStatusCode());
+        $this->assertStringContainsString('14', $response->getHeader('MS-Server-ActiveSync')[0]);
+        $this->assertStringContainsString('14.1', $response->getHeader('MS-ASProtocolVersions')[0]);
+        $this->assertStringContainsString('FolderSync', $response->getHeader('MS-ASProtocolCommands')[0]);
+    }
+}
diff --git a/tests/Sync/ProvisionTest.php b/tests/Sync/ProvisionTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/ProvisionTest.php
@@ -0,0 +1,45 @@
+<?php
+
+class ProvisionTest extends Tests\SyncTestCase
+{
+    /**
+     * Test Provision command
+     */
+    public function testProvision()
+    {
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Provision xmlns="uri:Provision" xmlns:Settings="uri:Settings">
+            <DeviceInformation xmlns="uri:Settings">
+                <Set>
+                    <Model>moto e(6) plus</Model>
+                    <IMEI>000000000000000</IMEI>
+                    <FriendlyName>pokerp_reteu_64</FriendlyName>
+                    <OS>Android 9.58-8</OS>
+                    <OSLanguage>Polish (Poland)</OSLanguage>
+                    <MobileOperator/>
+                </Set>
+            </DeviceInformation>
+            <Policies>
+                <Policy>
+                    <PolicyType>MS-EAS-Provisioning-WBXML</PolicyType>
+                </Policy>
+            </Policies>
+        </Provision>
+        EOF;
+
+        $response = $this->request($request, 'Provision');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:Provision/ns:Status")->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("//ns:Provision/Settings:DeviceInformation/Settings:Status")->item(0)->nodeValue);
+        $this->assertSame('2', $xpath->query("//ns:Provision/ns:Policies/ns:Policy/ns:Status")->item(0)->nodeValue);
+
+        // TODO: Assert the properties have been set
+    }
+}
diff --git a/tests/Sync/SettingsTest.php b/tests/Sync/SettingsTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/SettingsTest.php
@@ -0,0 +1,80 @@
+<?php
+
+class SettingsTest extends Tests\SyncTestCase
+{
+    /**
+     * Test Settings command
+     */
+    public function testSettingsUserInformation()
+    {
+        // Test retrieving the settings
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Settings xmlns="uri:Settings">
+            <UserInformation>
+                <Get/>
+            </UserInformation>
+        </Settings>
+        EOF;
+
+        $response = $this->request($request, 'Settings');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = self::fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:Settings/ns:Status")->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("//ns:Settings/ns:UserInformation/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(
+            self::$username,
+            $xpath->query("//ns:Settings/ns:UserInformation/ns:Get/ns:Accounts/ns:Account/ns:EmailAddresses/ns:PrimarySmtpAddress")->item(0)->nodeValue
+        );
+    }
+
+    /**
+     * Test Settings command
+     */
+    public function testSettingsDeviceInfomation()
+    {
+        // Test device info update
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Settings xmlns="uri:Settings">
+            <DeviceInformation>
+                <Set>
+                    <Model>moto plus</Model>
+                    <IMEI>111111111</IMEI>
+                    <FriendlyName>fn</FriendlyName>
+                    <OS>Android 10</OS>
+                    <OSLanguage>English</OSLanguage>
+                    <MobileOperator/>
+                </Set>
+            </DeviceInformation>
+        </Settings>
+        EOF;
+
+        $response = $this->request($request, 'Settings');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:Settings/ns:Status")->item(0)->nodeValue);
+        $this->assertSame('1', $xpath->query("//ns:Settings/ns:DeviceInformation/ns:Set/ns:Status")->item(0)->nodeValue);
+
+        // TODO: Assert the properties have been set
+    }
+
+    /**
+     * Test Settings command regarding OOF
+     */
+    public function testSettingsOOF()
+    {
+        // TODO: Test OOF settings
+        $this->markTestIncomplete();
+    }
+}
diff --git a/tests/Sync/Sync/CalendarTest.php b/tests/Sync/Sync/CalendarTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/Sync/CalendarTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Tests\Sync\Sync;
+
+class CalendarTest extends \Tests\SyncTestCase
+{
+    /**
+     * Test Sync command
+     */
+    public function testSync()
+    {
+        $this->emptyTestFolder($davFolder = 'Calendar', 'event');
+        $this->registerDevice();
+
+        // Test empty folder
+        $folderId = 'Calendar::Syncroton';
+        $syncKey = 0;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <Class>Calendar</Class>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>{$folderId}</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = "//ns:Sync/ns:Collections/ns:Collection";
+        $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue);
+        $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+
+        // Append two event objects and sync them
+        $this->appendObject($davFolder, 'event.ics1', 'event');
+        $this->appendObject($davFolder, 'event.ics2', 'event');
+
+        $request = str_replace("<SyncKey>0</SyncKey>", "<SyncKey>{$syncKey}</SyncKey>", $request);
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = "//ns:Sync/ns:Collections/ns:Collection";
+        $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue);
+        $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+
+        $root .= "/ns:Commands/ns:Add";
+        $this->assertStringMatchesFormat("CRC%s", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue);
+        $this->assertSame('20240715T170000Z', $xpath->query("{$root}/ns:ApplicationData/Calendar:StartTime")->item(0)->nodeValue);
+        $this->assertSame('Meeting', $xpath->query("{$root}/ns:ApplicationData/Calendar:Subject")->item(0)->nodeValue);
+        $this->assertSame('20240714T170000Z', $xpath->query("{$root}/ns:ApplicationData/Calendar:StartTime")->item(1)->nodeValue);
+        $this->assertSame('Party', $xpath->query("{$root}/ns:ApplicationData/Calendar:Subject")->item(1)->nodeValue);
+
+        return $syncKey;
+    }
+}
diff --git a/tests/Sync/Sync/ContactsTest.php b/tests/Sync/Sync/ContactsTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/Sync/ContactsTest.php
@@ -0,0 +1,250 @@
+<?php
+
+namespace Tests\Sync\Sync;
+
+class ContactsTest extends \Tests\SyncTestCase
+{
+    /**
+     * Test Sync command
+     */
+    public function testSync()
+    {
+        $davFolder = $this->isStorageDriver('kolab') ? 'Contacts' : 'Addressbook';
+        $this->emptyTestFolder($davFolder, 'contact');
+        $this->deleteTestFolder('Test Contacts Folder', 'contact'); // from other test files
+        $this->registerDevice();
+
+        // Test empty contacts folder
+        $folderId = 'Contacts::Syncroton';
+        $syncKey = 0;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>{$folderId}</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = "//ns:Sync/ns:Collections/ns:Collection";
+        $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue);
+        $this->assertSame(0, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+
+        // Append two contact objects and sync them
+        // TODO: Test a folder with contact groups inside
+        $this->appendObject($davFolder, 'contact.vcard1', 'contact');
+        $this->appendObject($davFolder, 'contact.vcard2', 'contact');
+
+        $request = str_replace("<SyncKey>0</SyncKey>", "<SyncKey>{$syncKey}</SyncKey>", $request);
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = "//ns:Sync/ns:Collections/ns:Collection";
+        $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue);
+        $this->assertSame(2, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+
+        $root .= "/ns:Commands/ns:Add";
+        $this->assertStringMatchesFormat("CRC%s", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue);
+        $this->assertSame('Jack', $xpath->query("{$root}/ns:ApplicationData/Contacts:FirstName")->item(0)->nodeValue);
+        $this->assertSame('Strong', $xpath->query("{$root}/ns:ApplicationData/Contacts:LastName")->item(0)->nodeValue);
+        $this->assertSame('Jane', $xpath->query("{$root}/ns:ApplicationData/Contacts:FirstName")->item(1)->nodeValue);
+        $this->assertSame('Doe', $xpath->query("{$root}/ns:ApplicationData/Contacts:LastName")->item(1)->nodeValue);
+
+        return $syncKey;
+    }
+
+    /**
+     * Test adding objects from client
+     *
+     * @depends testSync
+     */
+    public function testAddFromClient($syncKey)
+    {
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase" xmlns:Contacts="uri:Contacts">
+            <Collections>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>Contacts::Syncroton</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                    <Commands>
+                        <Add>
+                            <ClientId>42</ClientId>
+                            <ApplicationData>
+                                <Contacts:FirstName>Lars</Contacts:FirstName>
+                            </ApplicationData>
+                        </Add>
+                    </Commands>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0);
+        $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue);
+        $root = $xpath->query("ns:Responses/ns:Add", $root)->item(0);
+        $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue);
+        $this->assertSame('42', $xpath->query("ns:ClientId", $root)->item(0)->nodeValue);
+        $serverId = $xpath->query("ns:ServerId", $root)->item(0)->nodeValue;
+        $this->assertStringMatchesFormat("CRC%s", $serverId);
+
+        // TODO: Test the content on the server
+
+        return [$syncKey, $serverId];
+    }
+
+    /**
+     * Test updating objects from client
+     *
+     * @depends testAddFromClient
+     */
+    public function testChangeFromClient($params)
+    {
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase" xmlns:Contacts="uri:Contacts">
+            <Collections>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>{$params[0]}</SyncKey>
+                    <CollectionId>Contacts::Syncroton</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                    <Commands>
+                        <Change>
+                            <ServerId>{$params[1]}</ServerId>
+                            <ApplicationData>
+                                <Contacts:FirstName>First</Contacts:FirstName>
+                                <Contacts:LastName>Last</Contacts:LastName>
+                            </ApplicationData>
+                        </Change>
+                    </Commands>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0);
+        $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue);
+        $this->assertSame(strval(++$params[0]), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue);
+        $this->assertSame(0, $xpath->query("ns:Responses", $root)->length);
+
+        // TODO: Assert updated content on the server
+
+        return $params;
+    }
+
+    /**
+     * Test deleting objects from client
+     *
+     * @depends testChangeFromClient
+     */
+    public function testDeleteFromClient($params)
+    {
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase" xmlns:Contacts="uri:Contacts">
+            <Collections>
+                <Collection>
+                    <Class>Contacts</Class>
+                    <SyncKey>{$params[0]}</SyncKey>
+                    <CollectionId>Contacts::Syncroton</CollectionId>
+                    <DeletesAsMoves/>
+                    <GetChanges/>
+                    <Options>
+                        <AirSyncBase:BodyPreference>
+                            <AirSyncBase:Type>1</AirSyncBase:Type>
+                            <AirSyncBase:TruncationSize>5120</AirSyncBase:TruncationSize>
+                        </AirSyncBase:BodyPreference>
+                        <Conflict>1</Conflict>
+                    </Options>
+                    <Commands>
+                        <Delete>
+                            <ServerId>{$params[1]}</ServerId>
+                        </Delete>
+                    </Commands>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = $xpath->query("//ns:Sync/ns:Collections/ns:Collection")->item(0);
+        $this->assertSame('1', $xpath->query("ns:Status", $root)->item(0)->nodeValue);
+        $this->assertSame(strval(++$params[0]), $xpath->query("ns:SyncKey", $root)->item(0)->nodeValue);
+        $this->assertSame(0, $xpath->query("ns:Responses", $root)->length);
+
+        // TODO: Assert deleted contact on the server
+    }
+}
diff --git a/tests/Sync/Sync/EmailTest.php b/tests/Sync/Sync/EmailTest.php
new file mode 100644
--- /dev/null
+++ b/tests/Sync/Sync/EmailTest.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Tests\Sync\Sync;
+
+class EmailTest extends \Tests\SyncTestCase
+{
+    /**
+     * Test Sync command
+     */
+    public function testSync()
+    {
+        $this->emptyTestFolder('INBOX', 'mail');
+        $this->registerDevice();
+
+        // Test invalid collection identifier
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync">
+            <Collections>
+                <Collection>
+                    <SyncKey>0</SyncKey>
+                    <CollectionId>1111111111</CollectionId>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('12', $xpath->query("//ns:Sync/ns:Status")->item(0)->nodeValue);
+
+        // Test INBOX
+        $folderId = '38b950ebd62cd9a66929c89615d0fc04';
+        $syncKey = 0;
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync">
+            <Collections>
+                <Collection>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>{$folderId}</CollectionId>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $this->assertSame('1', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame('Email', $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:Class")->item(0)->nodeValue);
+        $this->assertSame($folderId, $xpath->query("//ns:Sync/ns:Collections/ns:Collection/ns:CollectionId")->item(0)->nodeValue);
+
+        // Test listing mail in INBOX, use WindowSize=1
+        // Append two mail messages
+        $this->appendMail('INBOX', 'mail.sync1');
+        $this->appendMail('INBOX', 'mail.sync2');
+
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>{$folderId}</CollectionId>
+                    <DeletesAsMoves>1</DeletesAsMoves>
+                    <GetChanges>1</GetChanges>
+                    <WindowSize>1</WindowSize>
+                    <Options>
+                        <FilterType>0</FilterType>
+                        <Conflict>1</Conflict>
+                        <BodyPreference xmlns="uri:AirSyncBase">
+                            <Type>2</Type>
+                            <TruncationSize>51200</TruncationSize>
+                            <AllOrNone>0</AllOrNone>
+                        </BodyPreference>
+                    </Options>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = "//ns:Sync/ns:Collections/ns:Collection";
+        $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue);
+        $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+
+        // Note: We assume messages are in IMAP default order, it may change in future
+        $root .= "/ns:Commands/ns:Add";
+        $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue);
+        $this->assertSame('test sync', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue);
+
+        // List the rest of the mail
+        $request = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <Sync xmlns="uri:AirSync" xmlns:AirSyncBase="uri:AirSyncBase">
+            <Collections>
+                <Collection>
+                    <SyncKey>{$syncKey}</SyncKey>
+                    <CollectionId>{$folderId}</CollectionId>
+                    <DeletesAsMoves>1</DeletesAsMoves>
+                    <GetChanges>1</GetChanges>
+                    <Options>
+                        <FilterType>0</FilterType>
+                        <Conflict>1</Conflict>
+                        <BodyPreference xmlns="uri:AirSyncBase">
+                            <Type>2</Type>
+                            <TruncationSize>51200</TruncationSize>
+                            <AllOrNone>0</AllOrNone>
+                        </BodyPreference>
+                    </Options>
+                </Collection>
+            </Collections>
+        </Sync>
+        EOF;
+
+        $response = $this->request($request, 'Sync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        $root = "//ns:Sync/ns:Collections/ns:Collection";
+        $this->assertSame('1', $xpath->query("{$root}/ns:Status")->item(0)->nodeValue);
+        $this->assertSame(strval(++$syncKey), $xpath->query("{$root}/ns:SyncKey")->item(0)->nodeValue);
+        $this->assertSame($folderId, $xpath->query("{$root}/ns:CollectionId")->item(0)->nodeValue);
+        $this->assertSame(1, $xpath->query("{$root}/ns:Commands/ns:Add")->count());
+
+        // Note: We assume messages are in IMAP default order, it may change in future
+        $root .= "/ns:Commands/ns:Add";
+        $this->assertStringMatchesFormat("{$folderId}::%d", $xpath->query("{$root}/ns:ServerId")->item(0)->nodeValue);
+        $this->assertSame('sync test with attachment', $xpath->query("{$root}/ns:ApplicationData/Email:Subject")->item(0)->nodeValue);
+
+        return $syncKey;
+    }
+
+    /**
+     * Test updating message properties from client
+     *
+     * @depends testSync
+     */
+    public function testChangeFromClient($syncKey)
+    {
+        $this->markTestIncomplete();
+
+        return $syncKey;
+    }
+
+    /**
+     * Test deleting messages from client
+     *
+     * @depends testChangeFromClient
+     */
+    public function testDeleteFromClient($syncKey)
+    {
+        $this->markTestIncomplete();
+    }
+}
diff --git a/tests/SyncTestCase.php b/tests/SyncTestCase.php
new file mode 100644
--- /dev/null
+++ b/tests/SyncTestCase.php
@@ -0,0 +1,362 @@
+<?php
+
+namespace Tests;
+
+class SyncTestCase extends \PHPUnit\Framework\TestCase
+{
+    protected static ?\GuzzleHttp\Client $client;
+    protected static ?string $deviceId;
+    protected static ?string $deviceType;
+    protected static ?string $username;
+    protected static ?string $password;
+    protected static bool $authenticated = false;
+
+    protected array $folders = [];
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setUp(): void
+    {
+        if (empty(self::$username)) {
+            $this->markTestSkipped('Not setup');
+        }
+
+        self::$deviceType = null;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public static function setUpBeforeClass(): void
+    {
+        $sync = \kolab_sync::get_instance();
+        $config = $sync->config;
+        $db = $sync->get_dbh();
+
+        self::$username = $config->get('activesync_test_username');
+        self::$password = $config->get('activesync_test_password');
+
+        if (empty(self::$username)) {
+            return;
+        }
+
+        self::$deviceId = 'test' . time();
+
+        $db->query('DELETE FROM syncroton_device');
+        $db->query('DELETE FROM syncroton_synckey');
+        $db->query('DELETE FROM syncroton_folder');
+        $db->query('DELETE FROM syncroton_data');
+        $db->query('DELETE FROM syncroton_data_folder');
+        $db->query('DELETE FROM syncroton_modseq');
+        $db->query('DELETE FROM syncroton_content');
+
+        self::$client = new \GuzzleHttp\Client([
+                'http_errors' => false,
+                'base_uri' => 'http://localhost:8000',
+                'verify' => false,
+                'auth' => [self::$username, self::$password],
+                'connect_timeout' => 10,
+                'timeout' => 10,
+                'headers' => [
+                    'Content-Type' => 'application/xml; charset=utf-8',
+                    'Depth' => '1',
+                ]
+        ]);
+
+        // TODO: execute: php -S localhost:8000
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public static function tearDownAfterClass(): void
+    {
+        if (self::$deviceId) {
+            $sync = \kolab_sync::get_instance();
+
+            if (self::$authenticated || $sync->authenticate(self::$username, self::$password)) {
+                $sync->password = self::$password;
+
+                $storage = $sync->storage();
+                $storage->device_delete(self::$deviceId);
+            }
+
+            $db = $sync->get_dbh();
+            $db->query('DELETE FROM syncroton_device');
+            $db->query('DELETE FROM syncroton_synckey');
+            $db->query('DELETE FROM syncroton_folder');
+        }
+    }
+
+    /**
+     * Append an email message to the IMAP folder
+     */
+    protected function appendMail($folder, $filename, $replace = [])
+    {
+        $imap = $this->getImapStorage();
+
+        $source = __DIR__ . '/src/' . $filename;
+
+        if (!file_exists($source)) {
+            exit("File does not exist: {$source}");
+        }
+
+        $is_file = true;
+
+        if (!empty($replace)) {
+            $is_file = false;
+            $source = file_get_contents($source);
+            foreach ($replace as $token => $value) {
+                $source = str_replace($token, $value, $source);
+            }
+        }
+
+        $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 = <<<EOF
+        <?xml version="1.0" encoding="utf-8"?>
+        <!DOCTYPE AirSync PUBLIC "-//AIRSYNC//DTD AirSync//EN" "http://www.microsoft.com/">
+        <FolderSync xmlns="uri:FolderHierarchy">
+            <SyncKey>0</SyncKey>
+        </FolderSync>
+        EOF;
+
+        $response = $this->request($request, 'FolderSync');
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $dom = $this->fromWbxml($response->getBody());
+        $xpath = $this->xpath($dom);
+
+        foreach ($xpath->query("//ns:FolderSync/ns:Changes/ns:Add") as $idx => $folder) {
+            $serverId = $folder->getElementsByTagName('ServerId')->item(0)->nodeValue;
+            $displayName = $folder->getElementsByTagName('DisplayName')->item(0)->nodeValue;
+            $this->folders[$serverId] = $displayName;
+        }
+    }
+
+    /**
+     * Convert XML into WBXML binary content
+     */
+    protected function toWbxml($xml)
+    {
+        $outputStream = fopen('php://temp', 'r+');
+        $encoder = new \Syncroton_Wbxml_Encoder($outputStream, 'UTF-8', 3);
+        $dom = new \DOMDocument();
+        $dom->loadXML($xml);
+        $encoder->encode($dom);
+        rewind($outputStream);
+
+        return stream_get_contents($outputStream);
+    }
+
+    /**
+     * Get XPath from a DOM
+     */
+    protected function xpath($dom)
+    {
+        $xpath = new \DOMXpath($dom);
+        $xpath->registerNamespace("ns", $dom->documentElement->namespaceURI);
+        $xpath->registerNamespace("AirSync", "uri:AirSync");
+        $xpath->registerNamespace("Calendar", "uri:Calendar");
+        $xpath->registerNamespace("Contacts", "uri:Contacts");
+        $xpath->registerNamespace("Email", "uri:Email");
+        $xpath->registerNamespace("Email2", "uri:Email2");
+        $xpath->registerNamespace("Settings", "uri:Settings");
+        $xpath->registerNamespace("Tasks", "uri:Tasks");
+
+        return $xpath;
+    }
+}
diff --git a/tests/body_converter.php b/tests/Unit/BodyConverterTest.php
rename from tests/body_converter.php
rename to tests/Unit/BodyConverterTest.php
--- a/tests/body_converter.php
+++ b/tests/Unit/BodyConverterTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class body_converter extends PHPUnit\Framework\TestCase
+class BodyConverterTest extends PHPUnit\Framework\TestCase
 {
     function data_html_to_text()
     {
diff --git a/tests/data_calendar.php b/tests/Unit/DataCalendarTest.php
rename from tests/data_calendar.php
rename to tests/Unit/DataCalendarTest.php
--- a/tests/data_calendar.php
+++ b/tests/Unit/DataCalendarTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class data_calendar extends PHPUnit\Framework\TestCase
+class DataCalendarTest extends PHPUnit\Framework\TestCase
 {
     /**
      * Test for kolab_sync_data_calendar::from_kolab_alarm()
diff --git a/tests/globalid_converter.php b/tests/Unit/DataEmailTest.php
rename from tests/globalid_converter.php
rename to tests/Unit/DataEmailTest.php
--- a/tests/globalid_converter.php
+++ b/tests/Unit/DataEmailTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class globalid_converter extends PHPUnit\Framework\TestCase
+class DataEmailTest extends PHPUnit\Framework\TestCase
 {
     /**
      * Test GlobalObjId encoding/decoding
diff --git a/tests/data_tasks.php b/tests/Unit/DataTasksTest.php
rename from tests/data_tasks.php
rename to tests/Unit/DataTasksTest.php
--- a/tests/data_tasks.php
+++ b/tests/Unit/DataTasksTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class data_tasks extends PHPUnit\Framework\TestCase
+class DataTasksTest extends PHPUnit\Framework\TestCase
 {
     function data_prio()
     {
diff --git a/tests/data.php b/tests/Unit/DataTest.php
rename from tests/data.php
rename to tests/Unit/DataTest.php
--- a/tests/data.php
+++ b/tests/Unit/DataTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class data extends PHPUnit\Framework\TestCase
+class DataTest extends PHPUnit\Framework\TestCase
 {
     /**
      * Test for kolab_sync_data::recurrence_to_kolab()
@@ -140,11 +140,13 @@
         return parent::recurrence_from_kolab($collection, $data, $result, $type);
     }
 
-    function toKolab(Syncroton_Model_IEntry $data, $folderId, $entry = null, $timezone = null)
+    function toKolab($data, $folderId, $entry = null, $timezone = null)
     {
+        return [];
     }
 
     function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false)
     {
+        return [];
     }
 }
diff --git a/tests/message.php b/tests/Unit/MessageTest.php
rename from tests/message.php
rename to tests/Unit/MessageTest.php
--- a/tests/message.php
+++ b/tests/Unit/MessageTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class message extends PHPUnit\Framework\TestCase
+class MessageTest extends PHPUnit\Framework\TestCase
 {
     /**
      * Test message parsing and headers setting
diff --git a/tests/timezone_converter.php b/tests/Unit/TimezoneConverterTest.php
rename from tests/timezone_converter.php
rename to tests/Unit/TimezoneConverterTest.php
--- a/tests/timezone_converter.php
+++ b/tests/Unit/TimezoneConverterTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class timezone_converter extends PHPUnit\Framework\TestCase
+class TimezoneConverterTest extends PHPUnit\Framework\TestCase
 {
     function test_list_timezones()
     {
diff --git a/tests/wbxml.php b/tests/Unit/WbxmlTest.php
rename from tests/wbxml.php
rename to tests/Unit/WbxmlTest.php
--- a/tests/wbxml.php
+++ b/tests/Unit/WbxmlTest.php
@@ -1,6 +1,6 @@
 <?php
 
-class wbxml extends PHPUnit\Framework\TestCase
+class WbxmlTest extends PHPUnit\Framework\TestCase
 {
     //function testDecode()
     //{
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -10,5 +10,6 @@
 define('TESTS_DIR', dirname(__FILE__) . '/');
 
 require_once(TESTS_DIR . '/../lib/init.php');
+require_once(TESTS_DIR . '/SyncTestCase.php');
 
 rcube::get_instance()->config->set('devel_mode', false);
diff --git a/tests/phpunit.xml b/tests/phpunit.xml
--- a/tests/phpunit.xml
+++ b/tests/phpunit.xml
@@ -2,15 +2,11 @@
     bootstrap="bootstrap.php"
     colors="true">
     <testsuites>
-        <testsuite name="All Tests">
-            <file>body_converter.php</file>
-            <file>data.php</file>
-            <file>data_calendar.php</file>
-            <file>data_tasks.php</file>
-            <file>globalid_converter.php</file>
-            <file>message.php</file>
-            <file>timezone_converter.php</file>
-            <file>wbxml.php</file>
+        <testsuite name="Unit">
+            <directory suffix="Test.php">Unit</directory>
+        </testsuite>
+        <testsuite name="Sync">
+            <directory suffix="Test.php">Sync</directory>
         </testsuite>
     </testsuites>
 </phpunit>
diff --git a/tests/src/contact.vcard1 b/tests/src/contact.vcard1
new file mode 100644
--- /dev/null
+++ b/tests/src/contact.vcard1
@@ -0,0 +1,6 @@
+BEGIN:VCARD
+VERSION:3.0
+UID:urn:uuid:abcdef-0123-4567-89ab-abcdefabcdef
+FN:Jane Doe
+N:Doe;Jane;J.;;
+END:VCARD
diff --git a/tests/src/contact.vcard2 b/tests/src/contact.vcard2
new file mode 100644
--- /dev/null
+++ b/tests/src/contact.vcard2
@@ -0,0 +1,6 @@
+BEGIN:VCARD
+VERSION:3.0
+UID:urn:uuid:abcdef-0123-4567-89ab-abcdefabc123
+FN:Jack Strong
+N:Strong;Jack;;;
+END:VCARD
diff --git a/tests/src/event.ics1 b/tests/src/event.ics1
new file mode 100644
--- /dev/null
+++ b/tests/src/event.ics1
@@ -0,0 +1,12 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//test/test//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:abcdef
+DTSTAMP:19970714T170000Z
+ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
+DTSTART:20240714T170000Z
+DTEND:20240714T180000Z
+SUMMARY:Party
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/src/event.ics2 b/tests/src/event.ics2
new file mode 100644
--- /dev/null
+++ b/tests/src/event.ics2
@@ -0,0 +1,12 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//test/test//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:123456
+DTSTAMP:19970714T170000Z
+ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
+DTSTART:20240715T170000Z
+DTEND:20240715T180000Z
+SUMMARY:Meeting
+END:VEVENT
+END:VCALENDAR
diff --git a/tests/src/mail.itip1 b/tests/src/mail.itip1
new file mode 100644
--- /dev/null
+++ b/tests/src/mail.itip1
@@ -0,0 +1,62 @@
+MIME-Version: 1.0
+Date: Thu, 07 Dec 2023 13:29:14 +0100
+Message-ID: <14ab307198d32cee00b38ffb54c9e577@nestle.kolab.ch>
+From: "Organizer" <$from>
+To: <$to>
+Subject: You've been invited to "Test"
+Content-Type: multipart/alternative;
+ boundary="=_f39ac9438326f676a8d562e163aa31e0"
+
+--=_f39ac9438326f676a8d562e163aa31e0
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8;
+ format=flowed
+
+*Test*
+--=_f39ac9438326f676a8d562e163aa31e0
+Content-Transfer-Encoding: 8bit
+Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=event.ics
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.3//EN
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VTIMEZONE
+TZID:Europe/Warsaw
+BEGIN:DAYLIGHT
+DTSTART:20230326T010000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+END:DAYLIGHT
+BEGIN:DAYLIGHT
+DTSTART:20240331T010000
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20231029T010000
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+UID:1154B829349633D80E143AAB5641170A-93BC4FC398A3FD52
+DTSTAMP:20231207T122914Z
+CREATED:20231207T122914Z
+LAST-MODIFIED:20231207T122914Z
+DTSTART;TZID=Europe/Warsaw:20231207T140000
+DTEND;TZID=Europe/Warsaw:20231207T143000
+SUMMARY:Test
+SEQUENCE:0
+TRANSP:OPAQUE
+ATTENDEE;CN="Attendee Name";PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPA
+ NT;CUTYPE=INDIVIDUAL;RSVP=TRUE:mailto:$to
+ORGANIZER;CN="Organizer Name":mailto:$from
+END:VEVENT
+END:VCALENDAR
+
+--=_f39ac9438326f676a8d562e163aa31e0--
diff --git a/tests/src/mail.sync1 b/tests/src/mail.sync1
new file mode 100644
--- /dev/null
+++ b/tests/src/mail.sync1
@@ -0,0 +1,10 @@
+Date: Thu, 09 Aug 2012 13:18:31 +0000
+Subject: test sync
+Message-ID: <sync1@domain.tld>
+From: "Sync 1" <user@domain.tld>
+To: "To 1" <kolab1@domain.tld>, "To 2" <kolab2@domain.tld>
+MIME-Version: 1.0
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+ZWVlYQ==
diff --git a/tests/src/mail.sync2 b/tests/src/mail.sync2
new file mode 100644
--- /dev/null
+++ b/tests/src/mail.sync2
@@ -0,0 +1,20 @@
+Date: Thu, 10 Aug 2012 13:18:31 +0000
+Subject: sync test with attachment
+Message-ID: <sync2@domain.tld>
+From: user@domain.tld
+To: kolab@domain.tld
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="BOUNDARY"
+
+--BOUNDARY
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: base64
+
+ZWVl
+--BOUNDARY
+Content-Transfer-Encoding: base64
+Content-Type: image/jpeg; name=logo.gif
+Content-Disposition: inline; filename=logo.gif; size=2574
+
+/9j/4AAQSkZJRgABAgEASABIAAD/4QqARXhpZgAATU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUA
+--BOUNDARY--