diff --git a/src/.gitignore b/src/.gitignore
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -25,5 +25,4 @@
 resources/countries.php
 resources/build/js/
 database/seeds/
-src/public/themes/active
 cache
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -9,6 +9,7 @@
     public const TYPE_VEVENT = 'VEVENT';
     public const TYPE_VTODO = 'VTODO';
     public const TYPE_VCARD = 'VCARD';
+    public const TYPE_NOTIFICATION = 'NOTIFICATION';
 
     protected const NAMESPACES = [
         self::TYPE_VEVENT => 'urn:ietf:params:xml:ns:caldav',
@@ -20,13 +21,14 @@
     protected $user;
     protected $password;
     protected $responseHeaders = [];
+    protected $homes;
 
     /**
      * Object constructor
      */
-    public function __construct($user, $password)
+    public function __construct($user, $password, $url = null)
     {
-        $this->url      = \config('services.dav.uri');
+        $this->url      = $url ?: \config('services.dav.uri');
         $this->user     = $user;
         $this->password = $password;
     }
@@ -34,23 +36,13 @@
     /**
      * Discover DAV home (root) collection of a specified type.
      *
-     * @param string $component Component to filter by (VEVENT, VTODO, VCARD)
-     *
-     * @return string|false Home collection location or False on error
+     * @return array|false Home locations or False on error
      */
-    public function discover(string $component = self::TYPE_VEVENT)
+    public function discover()
     {
-        $roots = [
-            self::TYPE_VEVENT => 'calendars',
-            self::TYPE_VTODO => 'calendars',
-            self::TYPE_VCARD => 'addressbooks',
-        ];
-
-        $homes = [
-            self::TYPE_VEVENT => 'calendar-home-set',
-            self::TYPE_VTODO => 'calendar-home-set',
-            self::TYPE_VCARD => 'addressbook-home-set',
-        ];
+        if (is_array($this->homes)) {
+            return $this->homes;
+        }
 
         $path = parse_url($this->url, PHP_URL_PATH);
 
@@ -62,64 +54,88 @@
             . '</d:propfind>';
 
         // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
-        $headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
-
-        $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, $headers);
+        $response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
 
         if (empty($response)) {
-            \Log::error("Failed to get current-user-principal for {$component} from the DAV server.");
+            \Log::error("Failed to get current-user-principal from the DAV server.");
             return false;
         }
 
         $elements = $response->getElementsByTagName('response');
+        $principal_href = '';
 
         foreach ($elements as $element) {
-            foreach ($element->getElementsByTagName('prop') as $prop) {
+            foreach ($element->getElementsByTagName('current-user-principal') as $prop) {
                 $principal_href = $prop->nodeValue;
                 break;
             }
         }
 
-        if (empty($principal_href)) {
-            \Log::error("No principal on the DAV server.");
-            return false;
-        }
-
-        if ($path && strpos($principal_href, $path) === 0) {
+        if ($path && str_starts_with($principal_href, $path)) {
             $principal_href = substr($principal_href, strlen($path));
         }
 
         $body = '<?xml version="1.0" encoding="utf-8"?>'
-            . '<d:propfind xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
+            . '<d:propfind xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav">'
                 . '<d:prop>'
-                    . '<c:' . $homes[$component] . ' />'
+                    . '<cal:calendar-home-set/>'
+                    . '<card:addressbook-home-set/>'
+                    . '<d:notification-URL/>'
                 . '</d:prop>'
             . '</d:propfind>';
 
         $response = $this->request($principal_href, 'PROPFIND', $body);
 
         if (empty($response)) {
-            \Log::error("Failed to get homes for {$component} from the DAV server.");
+            \Log::error("Failed to get home collections from the DAV server.");
             return false;
         }
 
-        $root_href = false;
         $elements = $response->getElementsByTagName('response');
+        $homes = [];
 
-        foreach ($elements as $element) {
-            foreach ($element->getElementsByTagName('prop') as $prop) {
-                $root_href = $prop->nodeValue;
-                break;
+        if ($element = $response->getElementsByTagName('response')->item(0)) {
+            if ($prop = $element->getElementsByTagName('prop')->item(0)) {
+                foreach ($prop->childNodes as $home) {
+                    if ($home->firstChild && $home->firstChild->localName == 'href') {
+                        $href = $home->firstChild->nodeValue;
+
+                        if ($path && str_starts_with($href, $path)) {
+                            $href = substr($href, strlen($path));
+                        }
+
+                        $homes[$home->localName] = $href;
+                    }
+                }
             }
         }
 
-        if (!empty($root_href)) {
-            if ($path && strpos($root_href, $path) === 0) {
-                $root_href = substr($root_href, strlen($path));
-            }
+        return $this->homes = $homes;
+    }
+
+    /**
+     * Get user home folder of specified type
+     *
+     * @param string $type Home type or component name
+     *
+     * @return string|null Folder location href
+     */
+    public function getHome($type)
+    {
+        $options = [
+            self::TYPE_VEVENT => 'calendar-home-set',
+            self::TYPE_VTODO => 'calendar-home-set',
+            self::TYPE_VCARD => 'addressbook-home-set',
+            self::TYPE_NOTIFICATION => 'notification-URL',
+        ];
+
+        $homes = $this->discover();
+
+        if (is_array($homes) && isset($options[$type])) {
+            return $homes[$options[$type]] ?? null;
         }
 
-        return $root_href;
+        return null;
     }
 
     /**
@@ -142,9 +158,9 @@
      */
     public function listFolders(string $component)
     {
-        $root_href = $this->discover($component);
+        $root_href = $this->getHome($component);
 
-        if ($root_href === false) {
+        if ($root_href === null) {
             return false;
         }
 
@@ -209,7 +225,8 @@
         $response = $this->request($object->href, 'PUT', $object, $headers);
 
         if ($response !== false) {
-            if ($etag = $this->responseHeaders['etag']) {
+            if (!empty($this->responseHeaders['ETag'])) {
+                $etag = $this->responseHeaders['ETag'][0];
                 if (preg_match('|^".*"$|', $etag)) {
                     $etag = substr($etag, 1, -1);
                 }
@@ -314,6 +331,22 @@
         return $response !== false;
     }
 
+    /**
+     * Check server options (and authentication)
+     *
+     * @return false|array DAV capabilities on success, False on error
+     */
+    public function options()
+    {
+        $response = $this->request('', 'OPTIONS');
+
+        if ($response !== false) {
+            return preg_split('/,\s+/', implode(',', $this->responseHeaders['DAV'] ?? []));
+        }
+
+        return false;
+    }
+
     /**
      * Search DAV objects in a folder.
      *
@@ -430,7 +463,7 @@
     {
         $doc = new \DOMDocument('1.0', 'UTF-8');
 
-        if (stripos($xml, '<?xml') === 0) {
+        if (str_starts_with($xml, '<?xml')) {
             if (!$doc->loadXML($xml)) {
                 throw new \Exception("Failed to parse XML");
             }
@@ -456,7 +489,11 @@
             $head .= "{$header_name}: {$header_value}\n";
         }
 
-        if (stripos($body, '<?xml') === 0) {
+        if ($body instanceof DAV\CommonObject) {
+            $body = (string) $body;
+        }
+
+        if (str_starts_with($body, '<?xml')) {
             $doc = new \DOMDocument('1.0', 'UTF-8');
 
             $doc->formatOutput = true;
@@ -469,7 +506,7 @@
             $body = $doc->saveXML();
         }
 
-        return $head . "\n" . rtrim($body);
+        return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : '');
     }
 
     /**
@@ -504,7 +541,7 @@
 
         $this->responseHeaders = [];
 
-        if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) {
+        if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) {
             $path = substr($path, strlen($rootPath));
         }
 
@@ -526,7 +563,8 @@
         }
 
         if ($debug) {
-            \Log::debug("C: {$method}: {$url}\n" . $this->debugBody($body, $headers));
+            $body = $this->debugBody($body, $headers);
+            \Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : ''));
         }
 
         $response = $client->send($method, $url);
diff --git a/src/app/Backends/DAV/Opaque.php b/src/app/Backends/DAV/Opaque.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Opaque.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Opaque extends CommonObject
+{
+    protected $content;
+
+    public function __construct($filename)
+    {
+        $this->content = file_get_contents($filename);
+    }
+
+    /**
+     * Create string representation of the DAV object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return $this->content;
+    }
+}
diff --git a/src/app/Console/Commands/Data/MigrateCommand.php b/src/app/Console/Commands/Data/MigrateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Data/MigrateCommand.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Console\Commands\Data;
+
+use App\DataMigrator;
+
+use Illuminate\Console\Command;
+
+/**
+ * Migrate user data from an external service to Kolab.
+ *
+ * Example usage:
+ *
+ * ```
+ * php artisan data:migrate \
+ *   "ews://$user:$pass@$server?client_id=$client_id&client_secret=$client_secret&tenant_id=$tenant_id" \
+ *   "dav://$dest_user:$dest_pass@$dest_server"
+ * ```
+ */
+class MigrateCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'data:migrate
+                                {src : Source account}
+                                {dst : Destination account}
+                                {--type= : Object type(s)}
+                                {--force : Force existing queue removal}';
+//                                {--export-only : Only export data}
+//                                {--import-only : Only import previously exported data}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Migrate user data from an external service';
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $src = new DataMigrator\Account($this->argument('src'));
+        $dst = new DataMigrator\Account($this->argument('dst'));
+        $options = [
+            'type' => $this->option('type'),
+            'force' => $this->option('force'),
+            'stdout' => true,
+        ];
+
+        $migrator = new DataMigrator\Engine();
+        $migrator->migrate($src, $dst, $options);
+    }
+}
diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Account.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace App\DataMigrator;
+
+/**
+ * Data object representing user account on an external service
+ */
+class Account
+{
+    /** @var string User name (login) */
+    public $username;
+
+    /** @var string User password */
+    public $password;
+
+    /** @var string User email address */
+    public $email;
+
+    /** @var string Hostname */
+    public $host;
+
+    /** @var string Connection scheme (service type) */
+    public $scheme;
+
+    /** @var string Full account location URI (w/o parameters) */
+    public $uri;
+
+    /** @var string Username for proxy auth */
+    public $loginas;
+
+    /** @var array Additional parameters from the input */
+    public $params;
+
+    /** @var string Full account definition */
+    protected $input;
+
+
+    /**
+     * Object constructor
+     *
+     * Input can be a valid URI or "<username>:<password>".
+     * For proxy authentication use: "<proxy-user>**<username>" as username.
+     *
+     * @param string $input Account specification
+     */
+    public function __construct(string $input)
+    {
+        $url = parse_url($input);
+
+        // Not valid URI, try the other form of input
+        if ($url === false || !array_key_exists('scheme', $url)) {
+            list($user, $password) = explode(':', $input, 2);
+            $url = ['user' => $user, 'pass' => $password];
+        }
+
+        if (isset($url['user'])) {
+            $this->username = urldecode($url['user']);
+
+            if (strpos($this->username, '**')) {
+                list($this->username, $this->loginas) = explode('**', $this->username, 2);
+            }
+        }
+
+        if (isset($url['pass'])) {
+            $this->password = urldecode($url['pass']);
+        }
+
+        if (isset($url['scheme'])) {
+            $this->scheme = strtolower($url['scheme']);
+        }
+
+        if (isset($url['host'])) {
+            $this->host = $url['host'];
+            $this->uri = $this->scheme . '://' . $url['host'] . ($url['path'] ?? '');
+        }
+
+        if (!empty($url['query'])) {
+            parse_str($url['query'], $this->params);
+        }
+
+        if (strpos($this->loginas, '@')) {
+            $this->email = $this->loginas;
+        } elseif (strpos($this->username, '@')) {
+            $this->email = $this->username;
+        }
+
+        $this->input = $input;
+    }
+
+    /**
+     * Returns string representation of the object.
+     * You can use the result as an input to the object constructor.
+     *
+     * @return string Account string representation
+     */
+    public function __toString(): string
+    {
+        return $this->input;
+    }
+}
diff --git a/src/app/DataMigrator/DAV.php b/src/app/DataMigrator/DAV.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/DAV.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace App\DataMigrator;
+
+use App\Backends\DAV as DAVClient;
+use App\Backends\DAV\Opaque as DAVOpaque;
+use App\Backends\DAV\Folder as DAVFolder;
+use App\DataMigrator\Interface\Folder;
+use App\Utils;
+
+class DAV implements Interface\ImporterInterface
+{
+    /** @var DAVClient DAV Backend */
+    protected $client;
+
+    /** @var Account Account to operate on */
+    protected $account;
+
+    /** @var Engine Data migrator engine */
+    protected $engine;
+
+    /** @var array Settings */
+    protected $settings;
+
+
+    /**
+     * Object constructor
+     */
+    public function __construct(Account $account, Engine $engine)
+    {
+        $username = $account->username . ($account->loginas ? "**{$account->loginas}" : '');
+        $baseUri = rtrim($account->uri, '/');
+        $baseUri = preg_replace('|^dav|', 'http', $baseUri);
+
+        $this->settings = [
+            'baseUri'  => $baseUri,
+            'userName' => $username,
+            'password' => $account->password,
+        ];
+
+        $this->client = new DAVClient($username, $account->password, $baseUri);
+        $this->engine = $engine;
+        $this->account = $account;
+    }
+
+    /**
+     * Check user credentials.
+     *
+     * @throws \Exception
+     */
+    public function authenticate()
+    {
+        try {
+            $result = $this->client->options();
+        } catch (\Exception $e) {
+            throw new \Exception("Invalid DAV credentials or server.");
+        }
+    }
+
+    /**
+     * Create an item in a folder.
+     *
+     * @param string $filename File location
+     * @param Folder $folder   Folder
+     *
+     * @throws \Exception
+     */
+    public function createItemFromFile(string $filename, Folder $folder): void
+    {
+        $href = $this->getFolderPath($folder) . '/' . pathinfo($filename, PATHINFO_BASENAME);
+
+        $object = new DAVOpaque($filename);
+        $object->href = $href;
+
+        switch ($folder->type) {
+            case Engine::TYPE_EVENT:
+            case Engine::TYPE_TASK:
+                $object->contentType = 'text/calendar; charset=utf-8';
+                break;
+
+            case Engine::TYPE_CONTACT:
+                $object->contentType = 'text/vcard; charset=utf-8';
+                break;
+        }
+
+        if ($this->client->create($object) === false) {
+            throw new \Exception("Failed to save object into DAV server at {$href}");
+        }
+    }
+
+    /**
+     * Create a folder.
+     *
+     * @param Folder $folder Folder data
+     *
+     * @throws \Exception on error
+     */
+    public function createFolder(Folder $folder): void
+    {
+        $dav_type = $this->type2DAV($folder->type);
+        $folders = $this->client->listFolders($dav_type);
+
+        if ($folders === false) {
+            throw new \Exception("Failed to list folders on the DAV server");
+        }
+
+        // Note: iRony flattens the list by modifying the folder name
+        // This is not going to work with Cyrus DAV, but anyway folder
+        // hierarchies support is not full in Kolab 4.
+        foreach ($folders as $dav_folder) {
+            if (str_replace(' » ', '/', $dav_folder->name) === $folder->fullname) {
+                // do nothing, folder already exists
+                return;
+            }
+        }
+
+        $home = $this->client->getHome($dav_type);
+        $folder_id = Utils::uuidStr();
+        $collection_type = $dav_type == DAVClient::TYPE_VCARD ? 'addressbook' : 'calendar';
+
+        // We create all folders on the top-level
+        $dav_folder = new DAVFolder();
+        $dav_folder->name = $folder->fullname;
+        $dav_folder->href = rtrim($home, '/') . '/' . $folder_id;
+        $dav_folder->components = [$dav_type];
+        $dav_folder->types = ['collection', $collection_type];
+
+        if ($this->client->folderCreate($dav_folder) === false) {
+            throw new \Exception("Failed to create a DAV folder {$dav_folder->href}");
+        }
+    }
+
+    /**
+     * Get folder relative URI
+     */
+    protected function getFolderPath(Folder $folder): string
+    {
+        $folders = $this->client->listFolders($this->type2DAV($folder->type));
+
+        if ($folders === false) {
+            throw new \Exception("Failed to list folders on the DAV server");
+        }
+
+        // Note: iRony flattens the list by modifying the folder name
+        // This is not going to work with Cyrus DAV, but anyway folder
+        // hierarchies support is not full in Kolab 4.
+        foreach ($folders as $dav_folder) {
+            if (str_replace(' » ', '/', $dav_folder->name) === $folder->fullname) {
+                return rtrim($dav_folder->href, '/');
+            }
+        }
+
+        throw new \Exception("Folder not found: {$folder->fullname}");
+    }
+
+    /**
+     * Map Kolab type into DAV object type
+     */
+    protected static function type2DAV(string $type): string
+    {
+        switch ($type) {
+            case Engine::TYPE_EVENT:
+                return DAVClient::TYPE_VEVENT;
+            case Engine::TYPE_TASK:
+                return DAVClient::TYPE_VTODO;
+            case Engine::TYPE_CONTACT:
+            case Engine::TYPE_GROUP:
+                return DAVClient::TYPE_VCARD;
+            default:
+                throw new \Exception("Cannot map type '{$type}' to DAV");
+        }
+    }
+}
diff --git a/src/app/DataMigrator/EWS.php b/src/app/DataMigrator/EWS.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS.php
@@ -0,0 +1,422 @@
+<?php
+
+namespace App\DataMigrator;
+
+use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\Item;
+use garethp\ews\API;
+use garethp\ews\API\Type;
+use Illuminate\Support\Facades\Http;
+
+/**
+ * Data migration from Exchange (EWS)
+ */
+class EWS implements Interface\ExporterInterface
+{
+    /** @var API EWS API object */
+    public $api;
+
+    /** @var array Supported folder types */
+    protected $folder_classes = [
+        EWS\Appointment::FOLDER_TYPE,
+        EWS\Contact::FOLDER_TYPE,
+        EWS\Task::FOLDER_TYPE,
+        // TODO: mail and sticky notes are exported as eml files.
+        //       We could use imapsync to synchronize mail, but for notes
+        //       the only option will be to convert them to Kolab format here
+        //       and upload to Kolab via IMAP, I guess
+        // EWS\Note::FOLDER_TYPE,
+        // EWS\StickyNote::FOLDER_TYPE,
+    ];
+
+    /** @var array Interal folders to skip */
+    protected $folder_exceptions = [
+        'AllContacts',
+        'AllPersonMetadata',
+        'AllTodoTasks',
+        'Document Centric Conversations',
+        'ExternalContacts',
+        'Favorites',
+        'Flagged Emails',
+        'GraphFilesAndWorkingSetSearchFolder',
+        'My Contacts',
+        'MyContactsExtended',
+        'Orion Notes',
+        'Outbox',
+        'PersonMetadata',
+        'People I Know',
+        'RelevantContacts',
+        'SharedFilesSearchFolder',
+        'Sharing',
+        'To-Do Search',
+        'UserCuratedContacts',
+        'XrmActivityStreamSearch',
+        'XrmCompanySearch',
+        'XrmDealSearch',
+        'XrmSearch',
+        'Folder Memberships',
+        // TODO: These are different depending on a user locale
+        'Calendar/United States holidays',
+        'Kalendarz/Polska — dni wolne od pracy', // pl
+    ];
+
+    /** @var array Map of EWS folder types to Kolab types */
+    protected $type_map = [
+        EWS\Appointment::FOLDER_TYPE => Engine::TYPE_EVENT,
+        EWS\Contact::FOLDER_TYPE => Engine::TYPE_CONTACT,
+        EWS\Task::FOLDER_TYPE => Engine::TYPE_TASK,
+    ];
+
+    /** @var Account Account to operate on */
+    protected $account;
+
+    /** @var Engine Data migrator engine */
+    protected $engine;
+
+
+    /**
+     * Object constructor
+     */
+    public function __construct(Account $account, Engine $engine)
+    {
+        $this->account = $account;
+        $this->engine = $engine;
+    }
+
+    /**
+     * Server autodiscovery
+     */
+    public static function autodiscover(string $user, string $password): ?string
+    {
+        // You should never run the Autodiscover more than once.
+        // It can make between 1 and 5 calls before giving up, or before finding your server,
+        // depending on how many different attempts it needs to make.
+
+        // TODO: Autodiscovery may fail with an exception thrown. Handle this nicely.
+        // TODO: Looks like this autodiscovery also does not work w/Basic Auth?
+
+        $api = API\ExchangeAutodiscover::getAPI($user, $password);
+
+        $server = $api->getClient()->getServer();
+        $version = $api->getClient()->getVersion();
+
+        return sprintf('ews://%s:%s@%s', urlencode($user), urlencode($password), $server);
+    }
+
+    /**
+     * Authenticate to EWS (initialize the EWS client)
+     */
+    public function authenticate()
+    {
+        if (!empty($this->account->params['client_id'])) {
+            $this->api = $this->authenticateWithOAuth2(
+                $this->account->host,
+                $this->account->username,
+                $this->account->params['client_id'],
+                $this->account->params['client_secret'],
+                $this->account->params['tenant_id']
+            );
+        } else {
+            // Note: This initializes the client, but not yet connects to the server
+            // TODO: To know that the credentials work we'll have to do some API call.
+            $this->api = $this->authenticateWithPassword(
+                $this->account->host,
+                $this->account->username,
+                $this->account->password,
+                $this->account->loginas
+            );
+        }
+    }
+
+    /**
+     * Autodiscover the server and authenticate the user
+     */
+    protected function authenticateWithPassword(string $server, string $user, string $password, string $loginas = null)
+    {
+        // Note: Since 2023-01-01 EWS at Office365 requires OAuth2, no way back to basic auth.
+
+        \Log::debug("[EWS] Using basic authentication on $server...");
+
+        $options = [];
+
+        if ($loginas) {
+            $options['impersonation'] = $loginas;
+        }
+
+        $this->engine->setOption('ews', [
+            'options' => $options,
+            'server' => $server,
+        ]);
+
+        return API::withUsernameAndPassword($server, $user, $password, $this->apiOptions($options));
+    }
+
+    /**
+     * Authenticate with a token (Office365)
+     */
+    protected function authenticateWithToken(string $server, string $user, string $token, $expires_at = null)
+    {
+        \Log::debug("[EWS] Using token authentication on $server...");
+
+        $options = ['impersonation' => $user];
+
+        $this->engine->setOption('ews', [
+            'options' => $options,
+            'server' => $server,
+            'token' => $token,
+            'expires_at' => $expires_at,
+        ]);
+
+        return API::withCallbackToken($server, $token, $this->apiOptions($options));
+    }
+
+    /**
+     * Authenticate with OAuth2 (Office365) - get the token
+     */
+    protected function authenticateWithOAuth2(string $server,
+        string $user, string $client_id, string $client_secret, string $tenant_id)
+    {
+        // See https://github.com/Garethp/php-ews/blob/master/examples/basic/authenticatingWithOAuth.php
+        // See https://github.com/Garethp/php-ews/issues/236#issuecomment-1292521527
+        // To register OAuth2 app goto https://entra.microsoft.com > Applications > App registrations
+
+        \Log::debug("[EWS] Fetching OAuth2 token from $server...");
+
+        $scope = 'https://outlook.office365.com/.default';
+        $token_uri = "https://login.microsoftonline.com/{$tenant_id}/oauth2/v2.0/token";
+        // $authUri = "https://login.microsoftonline.com/{$tenant_id}/oauth2/authorize";
+
+        $response = Http::asForm()
+            ->timeout(5)
+            ->post($token_uri, [
+                'client_id' => $client_id,
+                'client_secret' => $client_secret,
+                'scope' => $scope,
+                'grant_type' => 'client_credentials',
+            ])
+            ->throwUnlessStatus(200);
+
+        $token = $response->json('access_token');
+
+        // Note: Office365 default token expiration time is ~1h,
+        $expires_in = $response->json('expires_in');
+        $expires_at = now()->addSeconds($expires_in)->toDateTimeString();
+
+        return $this->authenticateWithToken($server, $user, $token, $expires_at);
+    }
+
+    /**
+     * Get folders hierarchy
+     */
+    public function getFolders($types = []): array
+    {
+        // Get full folders hierarchy
+        $options = [
+            'Traversal' => 'Deep',
+        ];
+
+        $folders = $this->api->getChildrenFolders('root', $options);
+
+        $result = [];
+
+        foreach ($folders as $folder) {
+            $class = $folder->getFolderClass();
+            $type = $this->type_map[$class] ?? null;
+
+            // Skip folder types we do not support (need)
+            if (empty($type) || (!empty($types) && !in_array($type, $types))) {
+                continue;
+            }
+
+            // Note: Folder names are localized
+            $name = $fullname = $folder->getDisplayName();
+            $id = $folder->getFolderId()->getId();
+            $parentId = $folder->getParentFolderId()->getId();
+
+            // Create folder name with full path
+            if ($parentId && !empty($result[$parentId])) {
+                $fullname = $result[$parentId]->fullname . '/' . $name;
+            }
+
+            // Top-level folder, check if it's a special folder we should ignore
+            // FIXME: Is there a better way to distinguish user folders from system ones?
+            if (
+                in_array($fullname, $this->folder_exceptions)
+                || strpos($fullname, 'OwaFV15.1All') === 0
+            ) {
+                continue;
+            }
+
+            $result[$id] = Folder::fromArray([
+                'id' => $folder->getFolderId()->toArray(true),
+                'total' => $folder->getTotalCount(),
+                'class' => $class,
+                'type' => $this->type_map[$class] ?? null,
+                'name' => $name,
+                'fullname' => $fullname,
+            ]);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Fetch a list of folder items
+     */
+    public function fetchItemList(Folder $folder, $callback): void
+    {
+        // Job processing - initialize environment
+        $this->initEnv($this->engine->queue);
+
+        // The folder is empty, we can stop here
+        if (empty($folder->total)) {
+            return;
+        }
+
+        $request = [
+            // Exchange's maximum is 1000
+            'IndexedPageItemView' => ['MaxEntriesReturned' => 100, 'Offset' => 0, 'BasePoint' => 'Beginning'],
+            'ParentFolderIds' => $folder->id,
+            'Traversal' => 'Shallow',
+            'ItemShape' => [
+                'BaseShape' => 'IdOnly',
+                'AdditionalProperties' => [
+                    'FieldURI' => ['FieldURI' => 'item:ItemClass'],
+                ],
+            ],
+        ];
+
+        $request = Type::buildFromArray($request);
+
+        // Note: It is not possible to get mimeContent with FindItem request
+        //       That's why we first get the list of object identifiers and
+        //       then call GetItem on each separately.
+
+        // TODO: It might be feasible to get all properties for object types
+        //       for which we don't use MimeContent, for better performance.
+
+        // Request first page
+        $response = $this->api->getClient()->FindItem($request);
+
+        // @phpstan-ignore-next-line
+        foreach ($response as $item) {
+            if ($item = $this->toItem($item, $folder)) {
+                $callback($item);
+            }
+        }
+
+        // Request other pages until we got all
+        while (!$response->isIncludesLastItemInRange()) {
+            // @phpstan-ignore-next-line
+            $response = $this->api->getNextPage($response);
+
+            foreach ($response as $item) {
+                if ($item = $this->toItem($item, $folder)) {
+                    $callback($item);
+                }
+            }
+        }
+    }
+
+    /**
+     * Fetching an item
+     */
+    public function fetchItem(Item $item): string
+    {
+        // Job processing - initialize environment
+        $this->initEnv($this->engine->queue);
+
+        if ($driver = EWS\Item::factory($this, $item)) {
+            return $driver->fetchItem($item);
+        }
+
+        throw new \Exception("Failed to fetch an item from EWS");
+    }
+
+    /**
+     * Get the source account
+     */
+    public function getSourceAccount(): Account
+    {
+        return $this->engine->source;
+    }
+
+    /**
+     * Get the destination account
+     */
+    public function getDestinationAccount(): Account
+    {
+        return $this->engine->destination;
+    }
+
+    /**
+     * Synchronize specified object
+     */
+    protected function toItem(Type $item, Folder $folder): ?Item
+    {
+        $item = Item::fromArray([
+            'id' => $item->getItemId()->toArray(),
+            'class' => $item->getItemClass(),
+            'folder' => $folder,
+        ]);
+
+        // TODO: We don't need to instantiate Item at this point, instead
+        // implement EWS\Item::validateClass() method
+        if ($driver = EWS\Item::factory($this, $item)) {
+            return $item;
+        }
+
+        return null;
+    }
+
+    /**
+     * Set common API options
+     */
+    protected function apiOptions(array $options): array
+    {
+        if (empty($options['version'])) {
+            $options['version'] = API\ExchangeWebServices::VERSION_2013;
+        }
+
+        // If you want to inject your own GuzzleClient for the requests
+        // $options['httpClient]' = $client;
+
+        // In debug mode record all responses
+        /*
+        if (\config('app.debug')) {
+            $options['httpPlayback'] = [
+                'mode' => 'record',
+                'recordLocation' => \storage_path('ews'),
+            ];
+        }
+        */
+
+        return $options;
+    }
+
+    /**
+     * Initialize environment for job execution
+     *
+     * @param Queue $queue Queue
+     */
+    protected function initEnv(Queue $queue): void
+    {
+        $ews = $queue->data['options']['ews'];
+
+        if (!empty($ews['token'])) {
+            // TODO: Refresh the token if needed
+            $this->api = API::withCallbackToken(
+                $ews['server'],
+                $ews['token'],
+                $this->apiOptions($ews['options'])
+            );
+        } else {
+            $this->api = API::withUsernameAndPassword(
+                $ews['server'],
+                $this->account->username,
+                $this->account->password,
+                $this->apiOptions($ews['options'])
+            );
+        }
+    }
+}
diff --git a/src/app/DataMigrator/EWS/Appointment.php b/src/app/DataMigrator/EWS/Appointment.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Appointment.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API;
+use garethp\ews\API\Type;
+
+/**
+ * Appointment (calendar event) object handler
+ */
+class Appointment extends Item
+{
+    public const FOLDER_TYPE = 'IPF.Appointment';
+    public const TYPE        = 'IPM.Appointment';
+    public const FILE_EXT    = 'ics';
+
+
+    /**
+     * Get GetItem request parameters
+     */
+    protected function getItemRequest(): array
+    {
+        $request = parent::getItemRequest();
+
+        // Request IncludeMimeContent as it's not included by default
+        $request['ItemShape']['IncludeMimeContent'] = true;
+
+        // Get UID property, it's not included in the Default set
+        // FIXME: How to add the property to the set, not replace the whole set
+        $request['ItemShape']['AdditionalProperties']['FieldURI'] = ['FieldURI' => 'calendar:UID'];
+
+        return $request;
+    }
+
+    /**
+     * Process event object
+     */
+    protected function processItem(Type $item)
+    {
+        // Decode MIME content
+        $ical = base64_decode((string) $item->getMimeContent());
+
+        $itemId = implode("\r\n ", str_split($this->itemId, 75 - strlen('X-MS-ID:')));
+
+        $ical = str_replace("\r\nBEGIN:VEVENT\r\n", "\r\nBEGIN:VEVENT\r\nX-MS-ID:{$itemId}\r\n", $ical);
+
+        // TODO: replace source email with destination email address in ORGANIZER/ATTENDEE
+
+        // Inject attachment bodies into the iCalendar content
+        // Calendar event attachments are exported as:
+        // ATTACH:CID:81490FBA13A3DC2BF071B894C96B44BA51BEAAED@eurprd05.prod.outlook.com
+        if ($item->getHasAttachments()) {
+            // FIXME: I've tried hard and no matter what ContentId property is always empty
+            //        This means we can't match the CID from iCalendar with the attachment.
+            //        That's why we'll just remove all ATTACH:CID:... occurrences
+            //        and inject attachments to the main event
+            $ical = preg_replace('/\r\nATTACH:CID:[^\r]+\r\n(\r\n [^\r\n]*)?/', '', $ical);
+
+            foreach ((array) $item->getAttachments()->getFileAttachment() as $attachment) {
+                $_attachment = $this->getAttachment($attachment);
+
+                $ctype = $_attachment->getContentType();
+                $body = $_attachment->getContent();
+
+                // It looks like Exchange may have an issue with plain text files.
+                // We'll skip empty files
+                if (!strlen($body)) {
+                    continue;
+                }
+
+                // FIXME: This is imo inconsistence on php-ews side that MimeContent
+                //        is base64 encoded, but Content isn't
+                // TODO: We should not do it in memory to not exceed the memory limit
+                $body = base64_encode($body);
+                $body = rtrim(chunk_split($body, 74, "\r\n "), ' ');
+
+                // Inject the attachment at the end of the first VEVENT block
+                // TODO: We should not do it in memory to not exceed the memory limit
+                $append = "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE={$ctype}:\r\n {$body}";
+                $pos = strpos($ical, "\r\nEND:VEVENT");
+                $ical = substr_replace($ical, $append, $pos + 2, 0);
+            }
+        }
+
+        return $ical;
+    }
+
+    /**
+     * Get Item UID (Generate a new one if needed)
+     */
+    protected function getUID(Type $item): string
+    {
+        if ($this->uid === null) {
+            // Only appointments have UID property
+            $this->uid = $item->getUID();
+        }
+
+        return $this->uid;
+    }
+}
diff --git a/src/app/DataMigrator/EWS/Contact.php b/src/app/DataMigrator/EWS/Contact.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Contact.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * Contact object handler
+ */
+class Contact extends Item
+{
+    public const FOLDER_TYPE = 'IPF.Contact';
+    public const TYPE        = 'IPM.Contact';
+    public const FILE_EXT    = 'vcf';
+
+    /**
+     * Get GetItem request parameters
+     */
+    protected function getItemRequest(): array
+    {
+        $request = parent::getItemRequest();
+
+        // Request IncludeMimeContent as it's not included by default
+        $request['ItemShape']['IncludeMimeContent'] = true;
+
+        return $request;
+    }
+
+    /**
+     * Process contact object
+     */
+    protected function processItem(Type $item)
+    {
+        // Decode MIME content
+        $vcard = base64_decode((string) $item->getMimeContent());
+
+        // Remove empty properties that EWS is exporting
+        $vcard = preg_replace('|\n[^:]+:;*\r|', '', $vcard);
+
+        // Inject UID (and Exchange item ID) to the vCard
+        $uid = $this->getUID($item);
+        $itemId = implode("\r\n ", str_split($this->itemId, 75 - strlen('X-MS-ID:')));
+
+        $vcard = str_replace("BEGIN:VCARD", "BEGIN:VCARD\r\nUID:{$uid}\r\nX-MS-ID:{$itemId}", $vcard);
+
+        // Note: Looks like PHOTO property is exported properly, so we
+        //       don't have to handle attachments as we do for calendar items
+
+        return $vcard;
+    }
+}
diff --git a/src/app/DataMigrator/EWS/DistList.php b/src/app/DataMigrator/EWS/DistList.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/DistList.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * Distribution List object handler
+ */
+class DistList extends Item
+{
+    public const FOLDER_TYPE = 'IPF.Contact';
+    public const TYPE        = 'IPM.DistList';
+    public const FILE_EXT    = 'vcf';
+
+    /**
+     * Convert distribution list object to vCard
+     */
+    protected function processItem(Type $item)
+    {
+        // Groups (Distribution Lists) are not exported in vCard format, they use eml
+
+        $data = [
+            'UID' => [$this->getUID($item)],
+            'KIND' => ['group'],
+            'FN' => [$item->getDisplayName()],
+            'REV' => [$item->getLastModifiedTime(), ['VALUE' => 'DATE-TIME']],
+            'X-MS-ID' => [$this->itemId],
+        ];
+
+        $vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:Kolab EWS Data Migrator\r\n";
+
+        foreach ($data as $key => $prop) {
+            $vcard .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []);
+        }
+
+        // Process list members
+        // Note: The fact that getMembers() returns stdClass is probably a bug in php-ews
+        foreach ($item->getMembers()->Member as $member) {
+            $mailbox = $member->getMailbox();
+            $mailto = $mailbox->getEmailAddress();
+            $name = $mailbox->getName();
+
+            // FIXME: Investigate if mailto: members are handled properly by Kolab
+            //        or we need to use MEMBER:urn:uuid:9bd97510-9dbb-4810-a144-6180962df5e0 syntax
+            //        But do not forget lists can have members that are not contacts
+
+            if ($mailto) {
+                if ($name && $name != $mailto) {
+                    $mailto = urlencode(sprintf('"%s" <%s>', addcslashes($name, '"'), $mailto));
+                }
+
+                $vcard .= $this->formatProp('MEMBER', "mailto:{$mailto}");
+            }
+        }
+
+        $vcard .= "END:VCARD\r\n";
+
+        return $vcard;
+    }
+}
diff --git a/src/app/DataMigrator/EWS/Item.php b/src/app/DataMigrator/EWS/Item.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Item.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use App\DataMigrator\EWS;
+use App\DataMigrator\Interface\Folder as FolderInterface;
+use App\DataMigrator\Interface\Item as ItemInterface;
+use garethp\ews\API;
+use garethp\ews\API\Type;
+
+/**
+ * Abstraction for object handlers
+ */
+abstract class Item
+{
+    /** @var EWS Data migrator object */
+    protected $driver;
+
+    /** @var FolderInterface Current folder */
+    protected $folder;
+
+    /** @var string Current item ID */
+    protected $itemId;
+
+    /** @var string Current item UID */
+    protected $uid;
+
+
+    /**
+     * Object constructor
+     */
+    public function __construct(EWS $driver, FolderInterface $folder)
+    {
+        $this->driver = $driver;
+        $this->folder = $folder;
+    }
+
+    /**
+     * Factory method.
+     * Returns object suitable to handle specified item type.
+     */
+    public static function factory(EWS $driver, ItemInterface $item)
+    {
+        $item_class = str_replace('IPM.', '', $item->class);
+        $item_class = "\App\DataMigrator\EWS\\{$item_class}";
+
+        if (class_exists($item_class)) {
+            return new $item_class($driver, $item->folder);
+        }
+    }
+
+    /**
+     * Fetch the specified object and put into a file
+     */
+    public function fetchItem(ItemInterface $item)
+    {
+        $itemId = $item->id;
+
+        // Fetch the item
+        $item = $this->driver->api->getItem($itemId, $this->getItemRequest());
+
+        $this->itemId = implode('!', $itemId);
+
+        $uid = $this->getUID($item);
+
+        \Log::debug("[EWS] Saving item {$uid}...");
+
+        // Apply type-specific format converters
+        $content = $this->processItem($item);
+
+        if (!is_string($content)) {
+            return;
+        }
+
+        $uid = preg_replace('/[^a-zA-Z0-9_:@-]/', '', $uid);
+
+        $location = $this->folder->location;
+
+        if (!file_exists($location)) {
+            mkdir($location, 0740, true);
+        }
+
+        $location .= '/' . $uid . '.' . $this->fileExtension();
+
+        file_put_contents($location, $content);
+
+        return $location;
+    }
+
+    /**
+     * Item conversion code
+     */
+    abstract protected function processItem(Type $item);
+
+    /**
+     * Get GetItem request parameters
+     */
+    protected function getItemRequest(): array
+    {
+        $request = [
+            'ItemShape' => [
+                // Reqest default set of properties
+                'BaseShape' => 'Default',
+                // Additional properties, e.g. LastModifiedTime
+                // FIXME: How to add multiple properties here?
+                'AdditionalProperties' => [
+                    'FieldURI' => ['FieldURI' => 'item:LastModifiedTime'],
+                ]
+            ]
+        ];
+
+        return $request;
+    }
+
+    /**
+     * Fetch attachment object from Exchange
+     */
+    protected function getAttachment(Type\FileAttachmentType $attachment)
+    {
+        $request = [
+            'AttachmentIds' => [
+                $attachment->getAttachmentId()->toXmlObject()
+            ],
+            'AttachmentShape' => [
+                'IncludeMimeContent' => true,
+            ]
+        ];
+
+        return $this->driver->api->getClient()->GetAttachment($request);
+    }
+
+    /**
+     * Get Item UID (Generate a new one if needed)
+     */
+    protected function getUID(Type $item): string
+    {
+        if ($this->uid === null) {
+            // We should generate an UID for objects that do not have it
+            // and inject it into the output file
+            // FIXME: Should we use e.g. md5($itemId->getId()) instead?
+            $this->uid = \App\Utils::uuidStr();
+        }
+
+        return $this->uid;
+    }
+
+    /**
+     * Filename extension for cached file in-processing
+     */
+    protected function fileExtension(): string
+    {
+        return constant(static::class . '::FILE_EXT') ?: 'txt';
+    }
+
+    /**
+     * VCard/iCal property formatting
+     */
+    protected function formatProp($name, $value, array $params = []): string
+    {
+        $cal = new \Sabre\VObject\Component\VCalendar();
+        $prop = new \Sabre\VObject\Property\Text($cal, $name, $value, $params);
+
+        $value = $prop->serialize();
+
+        // Revert escaping for some props
+        if ($name == 'RRULE') {
+            $value = str_replace("\\", '', $value);
+        }
+
+        return $value;
+    }
+}
diff --git a/src/app/DataMigrator/EWS/Note.php b/src/app/DataMigrator/EWS/Note.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Note.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * E-Mail object handler
+ */
+class Note extends Item
+{
+    public const FOLDER_TYPE = 'IPF.Note';
+    public const TYPE        = 'IPM.Note';
+    public const FILE_EXT    = 'eml';
+
+    /**
+     * Get GetItem request parameters
+     */
+    protected function getItemRequest(): array
+    {
+        $request = parent::getItemRequest();
+
+        // Request IncludeMimeContent as it's not included by default
+        $request['ItemShape']['IncludeMimeContent'] = true;
+
+        // For email we need all properties
+        $request['ItemShape']['BaseShape'] = 'AllProperties';
+
+        return $request;
+    }
+
+    /**
+     * Process contact object
+     */
+    protected function processItem(Type $item)
+    {
+        $email = base64_decode((string) $item->getMimeContent());
+
+        return $email;
+    }
+}
diff --git a/src/app/DataMigrator/EWS/Task.php b/src/app/DataMigrator/EWS/Task.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Task.php
@@ -0,0 +1,305 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * Task objects handler
+ */
+class Task extends Item
+{
+    public const FOLDER_TYPE = 'IPF.Task';
+    public const TYPE        = 'IPM.Task';
+    public const FILE_EXT    = 'ics';
+
+    /**
+     * Get GetItem request parameters
+     */
+    protected function getItemRequest(): array
+    {
+        $request = parent::getItemRequest();
+
+        // For tasks we need all properties
+        $request['ItemShape']['BaseShape'] = 'AllProperties';
+
+        return $request;
+    }
+
+    /**
+     * Process task object
+     */
+    protected function processItem(Type $item)
+    {
+        // Tasks are exported as Email messages in useless format
+        // (does not contain all relevant properties)
+        // We'll build the iCalendar using properties directly
+        // TODO: This probably should be done with sabre-vobject
+
+        // FIXME: Looks like tasks do not have timezone specified in EWS
+        //        and dates are in UTC, shall we remove the 'Z' from dates to make them floating?
+
+        $data = [
+            'UID' => [$this->getUID($item)],
+            'DTSTAMP' => [$this->formatDate($item->getLastModifiedTime()), ['VALUE' => 'DATE-TIME']],
+            'CREATED' => [$this->formatDate($item->getDateTimeCreated()), ['VALUE' => 'DATE-TIME']],
+            'SEQUENCE' => [intval($item->getChangeCount())],
+            'SUMMARY' => [$item->getSubject()],
+            'DESCRIPTION' => [(string) $item->getBody()],
+            'PERCENT-COMPLETE' => [intval($item->getPercentComplete())],
+            'STATUS' => [strtoupper($item->getStatus())],
+            'X-MS-ID' => [$this->itemId],
+        ];
+
+        if ($dueDate = $item->getDueDate()) {
+            $data['DUE'] = [$this->formatDate($dueDate), ['VALUE' => 'DATE-TIME']];
+        }
+
+        if ($startDate = $item->getStartDate()) {
+            $data['DTSTART'] = [$this->formatDate($startDate), ['VALUE' => 'DATE-TIME']];
+        }
+
+        if (($categories = $item->getCategories()) && $categories->String) {
+            $data['CATEGORIES'] = [$categories->String];
+        }
+
+        if ($sensitivity = $item->getSensitivity()) {
+            $sensitivity_map = [
+                'CONFIDENTIAL' => 'CONFIDENTIAL',
+                'NORMAL' => 'PUBLIC',
+                'PERSONAL' => 'PUBLIC',
+                'PRIVATE' => 'PRIVATE',
+            ];
+
+            $data['CLASS'] = [$sensitivity_map[strtoupper($sensitivity)] ?? 'PUBLIC'];
+        }
+
+        if ($importance = $item->getImportance()) {
+            $importance_map = [
+                'HIGH' => '9',
+                'NORMAL' => '5',
+                'LOW' => '1',
+            ];
+
+            $data['PRIORITY'] = [$importance_map[strtoupper($importance)] ?? '0'];
+        }
+
+        $this->setTaskOrganizer($data, $item);
+        $this->setTaskRecurrence($data, $item);
+
+        $ical = "BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nVERSION:2.0\r\nPRODID:Kolab EWS Data Migrator\r\nBEGIN:VTODO\r\n";
+
+        foreach ($data as $key => $prop) {
+            $ical .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []);
+        }
+
+        // Attachments
+        if ($item->getHasAttachments()) {
+            foreach ((array) $item->getAttachments()->getFileAttachment() as $attachment) {
+                $_attachment = $this->getAttachment($attachment);
+
+                $ctype = $_attachment->getContentType();
+                $body = $_attachment->getContent();
+
+                // It looks like Exchange may have an issue with plain text files.
+                // We'll skip empty files
+                if (!strlen($body)) {
+                    continue;
+                }
+
+                // FIXME: This is imo inconsistence on php-ews side that MimeContent
+                //        is base64 encoded, but Content isn't
+                // TODO: We should not do it in memory to not exceed the memory limit
+                $body = base64_encode($body);
+                $body = rtrim(chunk_split($body, 74, "\r\n "), ' ');
+
+                // Inject the attachment at the end of the VTODO block
+                // TODO: We should not do it in memory to not exceed the memory limit
+                $ical .= "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE={$ctype}:\r\n {$body}";
+            }
+        }
+
+        $ical .= $this->getVAlarm($item);
+        $ical .= "END:VTODO\r\n";
+        $ical .= "END:VCALENDAR\r\n";
+
+        return $ical;
+    }
+
+    /**
+     * Set task organizer/attendee
+     */
+    protected function setTaskOrganizer(array &$data, Type $task)
+    {
+        // FIXME: Looks like the owner might be an email address or just a full user name
+        $owner = $task->getOwner();
+        $source = $this->driver->getSourceAccount();
+        $destination = $this->driver->getDestinationAccount();
+
+        if (strpos($owner, '@') && $owner != $source->email) {
+            // Task owned by another person
+            $data['ORGANIZER'] = ["mailto:{$owner}"];
+
+            // FIXME: Because attendees are not specified in EWS, assume the user is an attendee
+            if ($destination->email) {
+                $params = ['ROLE' => 'REQ-PARTICIPANT', 'CUTYPE' => 'INDIVIDUAL'];
+                $data['ATTENDEE'] = ["mailto:{$destination->email}", $params];
+            }
+
+            return;
+        }
+
+        // Otherwise it must be owned by the user
+        if ($destination->email) {
+            $data['ORGANIZER'] = ["mailto:{$destination->email}"];
+        }
+    }
+
+    /**
+     * Set task recurrence rule
+     */
+    protected function setTaskRecurrence(array &$data, Type $task)
+    {
+        if (empty($task->getIsRecurring()) || empty($task->getRecurrence())) {
+            return;
+        }
+
+        $r = $task->getRecurrence();
+        $rrule = [];
+
+        if ($recurrence = $r->getDailyRecurrence()) {
+            $rrule['FREQ'] = 'DAILY';
+            $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+        } elseif ($recurrence = $r->getWeeklyRecurrence()) {
+            $rrule['FREQ'] = 'WEEKLY';
+            $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+            $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek());
+            $rrule['WKST'] = $this->mapDays($recurrence->getFirstDayOfWeek());
+        } elseif ($recurrence = $r->getAbsoluteMonthlyRecurrence()) {
+            $rrule['FREQ'] = 'MONTHLY';
+            $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+            $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth();
+        } elseif ($recurrence = $r->getRelativeMonthlyRecurrence()) {
+            $rrule['FREQ'] = 'MONTHLY';
+            $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+            $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex());
+        } elseif ($recurrence = $r->getAbsoluteYearlyRecurrence()) {
+            $rrule['FREQ'] = 'YEARLY';
+            $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth());
+            $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth();
+        } elseif ($recurrence = $r->getRelativeYearlyRecurrence()) {
+            $rrule['FREQ'] = 'YEARLY';
+            $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth());
+            $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex());
+        } else {
+            // There might be *Regeneration rules that we don't support
+            \Log::debug("[EWS] Unsupported Recurrence property value. Ignored.");
+        }
+
+        if (!empty($rrule)) {
+            if ($recurrence = $r->getNumberedRecurrence()) {
+                $rrule['COUNT'] = $recurrence->getNumberOfOccurrences();
+            } elseif ($recurrence = $r->getEndDateRecurrence()) {
+                $rrule['UNTIL'] = $this->formatDate($recurrence->getEndDate());
+            }
+
+            $rrule = array_filter($rrule);
+            $rrule = trim(array_reduce(
+                array_keys($rrule),
+                function ($carry, $key) use ($rrule) {
+                    return $carry . ';' . $key . '=' . $rrule[$key];
+                }
+            ), ';');
+
+            $data['RRULE'] = [$rrule];
+        }
+    }
+
+    /**
+     * Get VALARM block for the task Reminder
+     */
+    protected function getVAlarm(Type $task): string
+    {
+        // FIXME: To me it looks like ReminderMinutesBeforeStart property is not used
+
+        $date = $this->formatDate($task->getReminderDueBy());
+
+        if (empty($task->getReminderIsSet()) || empty($date)) {
+            return '';
+        }
+
+        return "BEGIN:VALARM\r\nACTION:DISPLAY\r\n"
+            . "TRIGGER;VALUE=DATE-TIME:{$date}\r\n"
+            . "END:VALARM\r\n";
+    }
+
+    /**
+     * Convert EWS representation of recurrence days to iCal
+     */
+    protected function mapDays(string $days, string $index = ''): string
+    {
+        if (preg_match('/(Day|Weekday|WeekendDay)/', $days)) {
+            // not supported
+            return '';
+        }
+
+        $days_map = [
+            'Sunday' => 'SU',
+            'Monday' => 'MO',
+            'Tuesday' => 'TU',
+            'Wednesday' => 'WE',
+            'Thursday' => 'TH',
+            'Friday' => 'FR',
+            'Saturday' => 'SA',
+        ];
+
+        $index_map = [
+            'First' => 1,
+            'Second' => 2,
+            'Third' => 3,
+            'Fourth' => 4,
+            'Last' => -1,
+        ];
+
+        $days = explode(' ', $days);
+        $days = array_map(
+            function ($day) use ($days_map, $index_map, $index) {
+                return ($index ? $index_map[$index] : '') . $days_map[$day];
+            },
+            $days
+        );
+
+        return implode(',', $days);
+    }
+
+    /**
+     * Convert EWS representation of recurrence month to iCal
+     */
+    protected function mapMonths(string $months): string
+    {
+        $months_map = ['January', 'February', 'March', 'April', 'May', 'June',
+            'July', 'August', 'September', 'October', 'November', 'December'];
+
+        $months = explode(' ', $months);
+        $months = array_map(
+            function ($month) use ($months_map) {
+                return array_search($month, $months_map) + 1;
+            },
+            $months
+        );
+
+        return implode(',', $months);
+    }
+
+    /**
+     * Format EWS date-time into a iCalendar date-time
+     */
+    protected function formatDate($datetime)
+    {
+        if (empty($datetime)) {
+            return null;
+        }
+
+        return str_replace(['Z', '-', ':'], '', $datetime);
+    }
+}
diff --git a/src/app/DataMigrator/Engine.php b/src/app/DataMigrator/Engine.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Engine.php
@@ -0,0 +1,273 @@
+<?php
+
+namespace App\DataMigrator;
+
+use App\DataMigrator\Interface\ExporterInterface;
+use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\ImporterInterface;
+use App\DataMigrator\Interface\Item;
+use Illuminate\Support\Str;
+
+/**
+ * Data migration engine
+ */
+class Engine
+{
+    public const TYPE_CONTACT = 'contact';
+    public const TYPE_EVENT = 'event';
+    public const TYPE_GROUP = 'group';
+    public const TYPE_MAIL = 'mail';
+    public const TYPE_NOTE = 'note';
+    public const TYPE_TASK = 'task';
+
+    /** @var Account Source account */
+    public $source;
+
+    /** @var Account Destination account */
+    public $destination;
+
+    /** @var ?Queue Data migration queue (session) */
+    public $queue;
+
+    /** @var ExporterInterface Data exporter */
+    protected $exporter;
+
+    /** @var ImporterInterface Data importer */
+    protected $importer;
+
+    /** @var array Data migration options */
+    protected $options = [];
+
+
+    /**
+     * Execute migration for the specified user
+     */
+    public function migrate(Account $source, Account $destination, array $options = [])
+    {
+        $this->source = $source;
+        $this->destination = $destination;
+        $this->options = $options;
+
+        // Create a unique identifier for the migration request
+        $queue_id = md5(strval($source) . strval($destination) . $options['type']);
+
+        // If queue exists, we'll display the progress only
+        if ($queue = Queue::find($queue_id)) {
+            // If queue contains no jobs, assume invalid
+            // TODO: An better API to manage (reset) queues
+            if (!$queue->jobs_started || !empty($options['force'])) {
+                $queue->delete();
+            } else {
+                while (true) {
+                    $this->debug(sprintf("Progress [%d of %d]\n", $queue->jobs_finished, $queue->jobs_started));
+
+                    if ($queue->jobs_started == $queue->jobs_finished) {
+                        break;
+                    }
+
+                    sleep(1);
+                    $queue->refresh();
+                }
+
+                return;
+            }
+        }
+
+        // Initialize the source
+        $this->exporter = $this->initDriver($source, ExporterInterface::class);
+        $this->exporter->authenticate();
+
+        // Initialize the destination
+        $this->importer = $this->initDriver($destination, ImporterInterface::class);
+        $this->importer->authenticate();
+
+        // $this->debug("Source/destination user credentials verified.");
+        $this->debug("Fetching folders hierarchy...");
+
+        // Create a queue
+        $this->createQueue($queue_id);
+
+        // We'll store output in storage/<username> tree
+        $location = storage_path('export/') . $source->email;
+
+        if (!file_exists($location)) {
+            mkdir($location, 0740, true);
+        }
+
+        $types = preg_split('/\s*,\s*/', strtolower($this->options['type'] ?? ''));
+
+        $folders = $this->exporter->getFolders($types);
+        $count = 0;
+
+        foreach ($folders as $folder) {
+            $this->debug("Processing folder {$folder->fullname}...");
+
+            $folder->queueId = $queue_id;
+            $folder->location = $location;
+
+            // Dispatch the job (for async execution)
+            Jobs\FolderJob::dispatch($folder);
+            $count++;
+        }
+
+        $this->queue->bumpJobsStarted($count);
+
+        $this->debug(sprintf('Done. %d %s created in queue: %s.', $count, Str::plural('job', $count), $queue_id));
+    }
+
+    /**
+     * Processing of a folder synchronization
+     */
+    public function processFolder(Folder $folder): void
+    {
+        // Job processing - initialize environment
+        $this->envFromQueue($folder->queueId);
+
+        // Create the folder on the destination server
+        $this->importer->createFolder($folder);
+
+        $count = 0;
+
+        // Fetch items from the source
+        $this->exporter->fetchItemList(
+            $folder,
+            function (Item $item) use (&$count) {
+                // Dispatch the job (for async execution)
+                Jobs\ItemJob::dispatch($item);
+                $count++;
+            }
+        );
+
+        if ($count) {
+            $this->queue->bumpJobsStarted($count);
+        }
+
+        $this->queue->bumpJobsFinished();
+    }
+
+    /**
+     * Processing of item synchronization
+     */
+    public function processItem(Item $item): void
+    {
+        // Job processing - initialize environment
+        $this->envFromQueue($item->folder->queueId);
+
+        if ($filename = $this->exporter->fetchItem($item)) {
+            $this->importer->createItemFromFile($filename, $item->folder);
+            // TODO: remove the file
+        }
+
+        $this->queue->bumpJobsFinished();
+    }
+
+    /**
+     * Print progress/debug information
+     */
+    public function debug($line)
+    {
+        if (!empty($this->options['stdout'])) {
+            $output = new \Symfony\Component\Console\Output\ConsoleOutput();
+            $output->writeln("$line");
+        } else {
+            \Log::debug("[DataMigrator] $line");
+        }
+    }
+
+    /**
+     * Set migration queue option. Use this if you need to pass
+     * some data between queue processes.
+     */
+    public function setOption(string $name, $value): void
+    {
+        $this->options[$name] = $value;
+
+        if ($this->queue) {
+            $this->queue->data = $this->queueData();
+            $this->queue->save();
+        }
+    }
+
+    /**
+     * Create a queue for the request
+     *
+     * @param string $queue_id Unique queue identifier
+     */
+    protected function createQueue(string $queue_id): void
+    {
+        $this->queue = new Queue();
+        $this->queue->id = $queue_id;
+        $this->queue->data = $this->queueData();
+        $this->queue->save();
+    }
+
+    /**
+     * Prepare queue data
+     */
+    protected function queueData()
+    {
+        $options = $this->options;
+        unset($options['stdout']); // jobs aren't in stdout anymore
+
+        // TODO: data should be encrypted
+        return [
+            'source' => (string) $this->source,
+            'destination' => (string) $this->destination,
+            'options' => $options,
+        ];
+    }
+
+    /**
+     * Initialize environment for job execution
+     *
+     * @param string $queueId Queue identifier
+     */
+    protected function envFromQueue(string $queueId): void
+    {
+        $this->queue = Queue::findOrFail($queueId);
+
+        $this->source = new Account($this->queue->data['source']);
+        $this->destination = new Account($this->queue->data['destination']);
+        $this->options = $this->queue->data['options'];
+
+        $this->importer = $this->initDriver($this->destination, ImporterInterface::class);
+        $this->exporter = $this->initDriver($this->source, ExporterInterface::class);
+    }
+
+    /**
+     * Initialize (and select) migration driver
+     */
+    protected function initDriver(Account $account, string $interface)
+    {
+        switch ($account->scheme) {
+            case 'ews':
+                $driver = new EWS($account, $this);
+                break;
+
+            case 'dav':
+            case 'davs':
+                $driver = new DAV($account, $this);
+                break;
+            /*
+            case 'imap':
+            case 'imaps':
+                $driver = new IMAP($account, $this);
+                break;
+            */
+
+            default:
+                throw new \Exception("Failed to init driver for '{$account->scheme}'");
+        }
+
+        // Make sure driver is used in the direction it supports
+        if (!is_a($driver, $interface)) {
+            throw new \Exception(sprintf(
+                "'%s' driver does not implement %s",
+                class_basename($driver),
+                class_basename($interface)
+            ));
+        }
+
+        return $driver;
+    }
+}
diff --git a/src/app/DataMigrator/Interface/ExporterInterface.php b/src/app/DataMigrator/Interface/ExporterInterface.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/ExporterInterface.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+use App\DataMigrator\Account;
+use App\DataMigrator\Engine;
+
+interface ExporterInterface
+{
+    /**
+     * Object constructor
+     */
+    public function __construct(Account $account, Engine $engine);
+
+    /**
+     * Check user credentials.
+     *
+     * @throws \Exception
+     */
+    public function authenticate();
+
+    /**
+     * Get folders hierarchy
+     */
+    public function getFolders($types = []): array;
+
+    /**
+     * Fetch a list of folder items
+     */
+    public function fetchItemList(Folder $folder, $callback): void;
+
+    /**
+     * Fetching an item
+     */
+    public function fetchItem(Item $item): string;
+}
diff --git a/src/app/DataMigrator/Interface/Folder.php b/src/app/DataMigrator/Interface/Folder.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/Folder.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+use App\DataMigrator\Queue;
+
+/**
+ * Data object representing a folder
+ */
+class Folder
+{
+    /** @var mixed Folder identifier */
+    public $id;
+
+    /** @var int Number of items in the folder */
+    public $total;
+
+    /** @var string Folder class */
+    public $class;
+
+    /** @var string Folder Kolab object type */
+    public $type;
+
+    /** @var string Folder name */
+    public $name;
+
+    /** @var string Folder name with path */
+    public $fullname;
+
+    /** @var string Storage location (for temporary data) */
+    public $location;
+
+    /** @var string Migration queue identifier */
+    public $queueId;
+
+
+    public static function fromArray(array $data = [])
+    {
+        $obj = new self();
+
+        foreach ($data as $key => $value) {
+            $obj->{$key} = $value;
+        }
+
+        return $obj;
+    }
+}
diff --git a/src/app/DataMigrator/Interface/ImporterInterface.php b/src/app/DataMigrator/Interface/ImporterInterface.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/ImporterInterface.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+use App\DataMigrator\Account;
+use App\DataMigrator\Engine;
+
+interface ImporterInterface
+{
+    /**
+     * Object constructor
+     */
+    public function __construct(Account $account, Engine $engine);
+
+    /**
+     * Check user credentials.
+     *
+     * @throws \Exception
+     */
+    public function authenticate();
+
+    /**
+     * Create an item in a folder.
+     *
+     * @param string $filename File location
+     * @param Folder $folder   Folder object
+     *
+     * @throws \Exception
+     */
+    public function createItemFromFile(string $filename, Folder $folder): void;
+
+    /**
+     * Create a folder.
+     *
+     * @param Folder $folder Folder object
+     *
+     * @throws \Exception
+     */
+    public function createFolder(Folder $folder): void;
+}
diff --git a/src/app/DataMigrator/Interface/Item.php b/src/app/DataMigrator/Interface/Item.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/Item.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+/**
+ * Data object representing a data item
+ */
+class Item
+{
+    /** @var mixed Item identifier */
+    public $id;
+
+    /** @var Folder Folder */
+    public $folder;
+
+    /** @var string Object class */
+    public $class;
+
+
+    public static function fromArray(array $data = [])
+    {
+        $obj = new self();
+
+        foreach ($data as $key => $value) {
+            $obj->{$key} = $value;
+        }
+
+        return $obj;
+    }
+}
diff --git a/src/app/DataMigrator/Jobs/FolderJob.php b/src/app/DataMigrator/Jobs/FolderJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Jobs/FolderJob.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace App\DataMigrator\Jobs;
+
+use App\DataMigrator\Engine;
+use App\DataMigrator\Interface\Folder;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class FolderJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    /** @var int The number of times the job may be attempted. */
+    public $tries = 3;
+
+    /** @var Folder Job data */
+    protected $folder;
+
+
+    /**
+     * Create a new job instance.
+     *
+     * @param Folder $folder Folder to process
+     *
+     * @return void
+     */
+    public function __construct(Folder $folder)
+    {
+        $this->folder = $folder;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $migrator = new Engine();
+        $migrator->processFolder($this->folder);
+    }
+
+    /**
+     * The job failed to process.
+     *
+     * @param \Exception $exception
+     *
+     * @return void
+     */
+    public function failed(\Exception $exception)
+    {
+        // TODO: Count failed jobs in the queue
+        // I'm not sure how to do this after the final failure (after X tries)
+        // In other words how do we know all jobs in a queue finished (successfully or not)
+        // Probably we have to set $tries = 1
+    }
+}
diff --git a/src/app/DataMigrator/Jobs/ItemJob.php b/src/app/DataMigrator/Jobs/ItemJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Jobs/ItemJob.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace App\DataMigrator\Jobs;
+
+use App\DataMigrator\Engine;
+use App\DataMigrator\Interface\Item;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class ItemJob implements ShouldQueue
+{
+    use Dispatchable;
+    use InteractsWithQueue;
+    use Queueable;
+    use SerializesModels;
+
+    /** @var int The number of times the job may be attempted. */
+    public $tries = 3;
+
+    /** @var Item Job data */
+    protected $item;
+
+
+    /**
+     * Create a new job instance.
+     *
+     * @param Item $item Item to process
+     *
+     * @return void
+     */
+    public function __construct(Item $item)
+    {
+        $this->item = $item;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $migrator = new Engine();
+        $migrator->processItem($this->item);
+    }
+
+    /**
+     * The job failed to process.
+     *
+     * @param \Exception $exception
+     *
+     * @return void
+     */
+    public function failed(\Exception $exception)
+    {
+        // TODO: Count failed jobs in the queue
+        // I'm not sure how to do this after the final failure (after X tries)
+        // In other words how do we know all jobs in a queue finished (successfully or not)
+        // Probably we have to set $tries = 1
+    }
+}
diff --git a/src/app/DataMigrator/Queue.php b/src/app/DataMigrator/Queue.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Queue.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace App\DataMigrator;
+
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * The eloquent definition of a DataMigratorQueue
+ */
+class Queue extends Model
+{
+    /** @var string Database table name */
+    protected $table = 'data_migrator_queues';
+
+    /**
+     * Indicates if the IDs are auto-incrementing.
+     *
+     * @var bool
+     */
+    public $incrementing = false;
+
+    /**
+     * The "type" of the auto-incrementing ID.
+     *
+     * @var string
+     */
+    protected $keyType = 'string';
+
+    /**
+     * Indicates if the model should be timestamped.
+     *
+     * @var bool
+     */
+    public $timestamps = true;
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array<string, string>
+     */
+    protected $casts = ['data' => 'array'];
+
+    /**
+     * The model's default values for attributes.
+     *
+     * @var array
+     */
+    protected $attributes = [
+        'jobs_started' => 0,
+        'jobs_finished' => 0,
+        'data' => '', // must not be []
+    ];
+
+
+    /**
+     * Fast and race-condition free method of bumping the jobs_started value
+     */
+    public function bumpJobsStarted(int $num = null)
+    {
+        DB::update(
+            "update data_migrator_queues set jobs_started = jobs_started + ? where id = ?",
+            [$num ?: 1, $this->id]
+        );
+    }
+
+    /**
+     * Fast and race-condition free method of bumping the jobs_finished value
+     */
+    public function bumpJobsFinished(int $num = null)
+    {
+        DB::update(
+            "update data_migrator_queues set jobs_finished = jobs_finished + ? where id = ?",
+            [$num ?: 1, $this->id]
+        );
+    }
+}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -19,6 +19,7 @@
         "barryvdh/laravel-dompdf": "^2.0.1",
         "doctrine/dbal": "^3.6",
         "dyrynda/laravel-nullable-fields": "^4.3.0",
+        "garethp/php-ews": "dev-master",
         "guzzlehttp/guzzle": "^7.8.0",
         "kolab/net_ldap3": "dev-master",
         "laravel/framework": "^10.15.0",
diff --git a/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php b/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('data_migrator_queues', function (Blueprint $table) {
+            $table->string('id', 32);
+            $table->integer('jobs_started');
+            $table->integer('jobs_finished');
+            $table->mediumText('data');
+            $table->timestamps();
+
+            $table->primary('id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::drop('data_migrator_queues');
+    }
+};
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -4,6 +4,7 @@
     ignoreErrors:
         - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
         - '#Call to an undefined method Tests\\Browser::#'
+        - '#Call to an undefined method garethp\\ews\\API\\Type::#'
     level: 5
     parallel:
         processTimeout: 300.0