Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117803175
D5088.1775269242.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
31 KB
Referenced Files
None
Subscribers
None
D5088.1775269242.diff
View Options
diff --git a/src/app/DataMigrator/Driver/Kolab.php b/src/app/DataMigrator/Driver/Kolab.php
--- a/src/app/DataMigrator/Driver/Kolab.php
+++ b/src/app/DataMigrator/Driver/Kolab.php
@@ -94,6 +94,8 @@
*/
public function createItem(Item $item): void
{
+ // Note: Destination server is always Kolab4, we don't support migration into Kolab3
+
// IMAP
if ($item->folder->type == Engine::TYPE_MAIL) {
parent::createItem($item);
@@ -107,6 +109,10 @@
}
// TODO: Configuration (v3 tags)
+ if ($item->folder->type == Engine::TYPE_CONFIGURATION) {
+ Kolab\Tags::migrateKolab3Tag($this->imap, $item);
+ return;
+ }
}
/**
@@ -114,6 +120,8 @@
*/
public function fetchItem(Item $item): void
{
+ // Note: No support for migration from Kolab4 yet
+
// IMAP
if ($item->folder->type == Engine::TYPE_MAIL) {
parent::fetchItem($item);
@@ -126,14 +134,20 @@
return;
}
- // TODO: Configuration (v3 tags)
+ // Configuration (v3 tags)
+ if ($item->folder->type == Engine::TYPE_CONFIGURATION) {
+ Kolab\Tags::fetchKolab3Tag($this->imap, $item);
+ return;
+ }
}
/**
- * Fetch a list of folder items
+ * Fetch a list of folder items from the source server
*/
public function fetchItemList(Folder $folder, $callback, ImporterInterface $importer): void
{
+ // Note: No support for migration from Kolab4 yet
+
// IMAP
if ($folder->type == Engine::TYPE_MAIL) {
parent::fetchItemList($folder, $callback, $importer);
@@ -146,7 +160,17 @@
return;
}
- // TODO: Configuration (v3 tags)
+ // Configuration (v3 tags)
+ if ($folder->type == Engine::TYPE_CONFIGURATION) {
+ // Get existing tags from the destination account
+ $existing = $importer->getItems($folder);
+
+ foreach (Kolab\Tags::getKolab3Tags($this->imap, $folder->fullname, $existing) as $tag) {
+ $tag['folder'] = $folder;
+ $item = Item::fromArray($tag);
+ $callback($item);
+ }
+ }
}
/**
@@ -154,6 +178,7 @@
*/
public function getFolders($types = []): array
{
+ // Note: No support for migration from Kolab4 yet.
// Using only IMAP to get the list of all folders works with Kolab v3, but not v4.
// We could use IMAP, extract the XML, convert to iCal/vCard format and pass to DAV.
// But it will be easier to use DAV for contact/task/event folders migration.
@@ -229,11 +254,13 @@
}
/**
- * Get a list of folder items, limited to their essential propeties
+ * Get a list of folder items from the destination server, limited to their essential propeties
* used in incremental migration to skip unchanged items.
*/
public function getItems(Folder $folder): array
{
+ // Note: Destination server is always Kolab4, we don't support migration into Kolab3
+
// IMAP
if ($folder->type == Engine::TYPE_MAIL) {
return parent::getItems($folder);
@@ -246,8 +273,7 @@
// Configuration folder (v3 tags)
if ($folder->type == Engine::TYPE_CONFIGURATION) {
- // X-Kolab-Type: application/x-vnd.kolab.configuration.relation
- // TODO
+ return Kolab\Tags::getKolab4Tags($this->imap);
}
return [];
diff --git a/src/app/DataMigrator/Driver/Kolab/Tags.php b/src/app/DataMigrator/Driver/Kolab/Tags.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Driver/Kolab/Tags.php
@@ -0,0 +1,306 @@
+<?php
+
+namespace App\DataMigrator\Driver\Kolab;
+
+use App\DataMigrator\Interface\Item;
+
+/**
+ * Utilities to handle Kolab tags
+ */
+class Tags
+{
+ private const ANNOTATE_KEY_PREFIX = '/vendor/kolab/tag/v1/';
+ private const ANNOTATE_VALUE = '1';
+ private const METADATA_ROOT = 'INBOX';
+ private const METADATA_TAGS_KEY = '/private/vendor/kolab/tags/v1';
+
+
+ /**
+ * Get all tag properties, resolve tag members
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param Item $item Tag item
+ */
+ public static function fetchKolab3Tag($imap, Item $item): void
+ {
+ // Note: We already have all tag properties, we need to resolve
+ // member URLs into folder+message-id pairs so we can later annotate
+ // mail messages
+
+ // Get the username
+ $reflection = new \ReflectionClass($imap);
+ $property = $reflection->getProperty('user');
+ $property->setAccessible(true);
+ $user = $property->getValue($imap);
+
+ $members = [];
+
+ foreach (($item->data['member'] ?? []) as $member) {
+ // TODO: For now we'll ignore tasks/notes, but in the future we
+ // should migrate task tags into task CATEGORIES property (dunno notes).
+ if (!str_starts_with($member, 'imap://')) {
+ continue;
+ }
+
+ // Sample member: imap:///(user/username@domain|shared)/<folder>/<UID>?<search_params>
+ // parse_url does not work with imap:/// prefix
+ $url = parse_url(substr($member, 8));
+ $path = explode('/', $url['path']);
+ parse_str($url['query'], $params);
+
+ // Skip members without Message-ID
+ if (empty($params['message-id'])) {
+ continue;
+ }
+
+ $uid = array_pop($path);
+ $ns = array_shift($path);
+
+ // TODO: For now we ignore shared folders
+ if ($ns != 'user') {
+ continue;
+ }
+
+ $path = array_map('rawurldecode', $path);
+ $username = array_shift($path);
+ $folder = implode('/', $path);
+
+ if ($username == $user) {
+ if (!strlen($folder)) {
+ $folder = 'INBOX';
+ }
+ }
+
+ if (!isset($members[$folder])) {
+ $members[$folder] = [];
+ }
+
+ // Get the folder and message-id, we can't use UIDs, they are different in the target account
+ $members[$folder][] = $params['message-id'];
+ }
+
+ $item->data['member'] = $members;
+ }
+
+ /**
+ * Get tags from Kolab3 folder
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param string $mailbox Configuration folder name
+ * @param array $existing Tags existing at the destination account
+ */
+ public static function getKolab3Tags($imap, $mailbox, $existing = []): array
+ {
+ // Find relation objects
+ $search = 'NOT DELETED HEADER X-Kolab-Type "application/x-vnd.kolab.configuration.relation"';
+ $search = $imap->search($mailbox, $search, true);
+ if ($search->is_empty()) {
+ return [];
+ }
+
+ // Get messages' basic headers, include headers for the XML attachment
+ $uids = $search->get_compressed();
+ $messages = $imap->fetchHeaders($mailbox, $uids, true, false, [], ['BODY.PEEK[2.MIME]']);
+ $tags = [];
+
+ foreach ($messages as $message) {
+ // TODO: incremental migration, skip unchanged tags
+
+ $headers = \rcube_mime::parse_headers($message->bodypart['2.MIME'] ?? '');
+
+ // Sanity check, part 2 is expected to be Kolab XML attachment
+ if (stripos($headers['content-type'] ?? '', 'application/vnd.kolab+xml') === false) {
+ continue;
+ }
+
+ // Get the XML content
+ $encoding = $headers['content-transfer-encoding'] ?? '8bit';
+ $xml = $imap->handlePartBody($mailbox, $message->uid, true, 2, $encoding);
+
+ // Remove namespace so xpath queries below are short
+ $xml = str_replace(' xmlns="http://kolab.org"', '', $xml);
+ $xml = simplexml_load_string($xml);
+
+ if ($xml === false) {
+ throw new \Exception("Failed to parse XML for {$mailbox}/{$message->uid}");
+ }
+
+ $tag = ['member' => []];
+ foreach ($xml->xpath('/configuration/*') as $node) {
+ $nodeName = $node->getName();
+ if (in_array($nodeName, ['relationType', 'name', 'color', 'last-modification-date', 'member'])) {
+ if ($nodeName == 'member') {
+ $tag['member'][] = (string) $node;
+ } else {
+ $tag[$nodeName] = (string) $node;
+ }
+ }
+ }
+
+ if ($tag['relationType'] === 'tag') {
+ if (empty($tag['last-modification-date'])) {
+ $tag['last-modification-date'] = $message->internaldate;
+ }
+
+ $exists = null;
+ foreach ($existing as $existing_tag) {
+ if ($existing_tag['name'] == $tag['name']) {
+ if (isset($existing_tag['mtime']) && $existing_tag['mtime'] == $tag['last-modification-date']) {
+ // No changes to the tag, skip it
+ continue 2;
+ }
+
+ $exists = $existing_tag;
+ break;
+ }
+ }
+
+ $tags[] = [
+ 'id' => $message->uid,
+ 'class' => 'tag',
+ 'existing' => $exists,
+ 'data' => $tag,
+ ];
+ }
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Get list of Kolab4 tags
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ */
+ public static function getKolab4Tags($imap): array
+ {
+ $tags = [];
+
+ if ($meta = $imap->getMetadata(self::METADATA_ROOT, self::METADATA_TAGS_KEY)) {
+ $tags = json_decode($meta[self::METADATA_ROOT][self::METADATA_TAGS_KEY], true);
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Get list of Kolab4 tag members (message UIDs)
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param string $tag_name Tag name
+ * @param string $folder IMAP folder name
+ */
+ public static function getKolab4TagMembers($imap, string $tag_name, string $folder): array
+ {
+ $criteria = sprintf(
+ 'ANNOTATION %s value.priv %s',
+ $imap->escape(self::ANNOTATE_KEY_PREFIX . $tag_name),
+ $imap->escape(self::ANNOTATE_VALUE, true)
+ );
+
+ $search = $imap->search($folder, $criteria, true);
+
+ if ($search->is_error()) {
+ throw new \Exception("Failed to SEARCH in {$folder}. Error: {$imap->error}");
+ }
+
+ return $search->get();
+ }
+
+ /**
+ * Migrate Kolab3 tag into Kolab4 tag (including tag associations)
+ *
+ * @param \rcube_imap_generic $imap IMAP client (target account)
+ * @param Item $item Tag item
+ */
+ public static function migrateKolab3Tag($imap, Item $item): void
+ {
+ $tags = self::getKolab4Tags($imap);
+
+ $tag_name = $item->data['name'];
+ $found = false;
+
+ // Find the tag
+ foreach ($tags as &$tag) {
+ if ($tag['name'] == $tag_name) {
+ $tag['color'] = $item->data['color'] ?? null;
+ $tag['mtime'] = $item->data['last-modification-date'] ?? null;
+ $tag = array_filter($tag);
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ $tags[] = array_filter([
+ 'name' => $tag_name,
+ 'color' => $item->data['color'] ?? null,
+ 'mtime' => $item->data['last-modification-date'] ?? null,
+ ]);
+ }
+
+ self::saveKolab4Tags($imap, $tags);
+
+ // Migrate members
+ // For each folder, search messages by Message-ID and annotate them
+ // TODO: In incremental migration (if tag already exists) we probably should
+ // remove the annotation from all messages in all folders first.
+ foreach (($item->data['member'] ?? []) as $folder => $ids) {
+ $uids = [];
+ $search = '';
+ $prefix = '';
+ // Do search in chunks
+ foreach ($ids as $idx => $id) {
+ $str = ' HEADER MESSAGE-ID ' . $imap->escape($id);
+ $len = strlen($search) + strlen($str) + strlen($prefix) + 100;
+ $is_last = $idx == count($ids) - 1;
+
+ if ($len > 65536 || $is_last) {
+ if ($is_last) {
+ $prefix .= strlen($search) ? ' OR' : '';
+ $search .= $str;
+ }
+
+ $search = $imap->search($folder, $prefix . $search, true);
+ if ($search->is_error()) {
+ throw new \Exception("Failed to SEARCH in {$folder}. Error: {$imap->error}");
+ }
+
+ $uids = array_merge($uids, $search->get());
+ $search = '';
+ $prefix = '';
+ }
+
+ $prefix .= strlen($search) ? ' OR' : '';
+ $search .= $str;
+ }
+
+ if (!empty($uids)) {
+ $annotation = [];
+ $annotation[self::ANNOTATE_KEY_PREFIX . $tag_name] = ['value.priv' => self::ANNOTATE_VALUE];
+
+ $uids = $imap->compressMessageSet(array_unique($uids));
+ $res = $imap->storeMessageAnnotation($folder, $uids, $annotation);
+
+ if (!$res) {
+ throw new \Exception("Failed to ANNOTATE in {$folder}. Error: {$imap->error}");
+ }
+ }
+ }
+ }
+
+ /**
+ * Get list of Kolab4 tags
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param array $tags List of tags
+ */
+ public static function saveKolab4Tags($imap, array $tags): void
+ {
+ $metadata = json_encode($tags, JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE);
+
+ if (!$imap->setMetadata(self::METADATA_ROOT, [self::METADATA_TAGS_KEY => $metadata])) {
+ throw new \Exception("Failed to store tags in IMAP. Error: {$imap->error}");
+ }
+ }
+}
diff --git a/src/include/rcube_result_index.php b/src/include/rcube_result_index.php
--- a/src/include/rcube_result_index.php
+++ b/src/include/rcube_result_index.php
@@ -19,23 +19,6 @@
+-----------------------------------------------------------------------+
*/
-/**
- * Get first element from an array
- *
- * @param array $array Input array
- *
- * @return mixed First element if found, Null otherwise
- */
-function array_first($array)
-{
- if (is_array($array)) {
- reset($array);
- foreach ($array as $element) {
- return $element;
- }
- }
-}
-
/**
* Class for accessing IMAP's SORT/SEARCH/ESEARCH result
*
diff --git a/src/tests/Feature/DataMigrator/KolabTest.php b/src/tests/Feature/DataMigrator/KolabTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/DataMigrator/KolabTest.php
@@ -0,0 +1,260 @@
+<?php
+
+namespace Tests\Feature\DataMigrator;
+
+use App\DataMigrator\Account;
+use App\DataMigrator\Driver\Kolab\Tags as KolabTags;
+use App\DataMigrator\Engine;
+use App\DataMigrator\Queue as MigratorQueue;
+use Tests\BackendsTrait;
+use Tests\TestCase;
+
+/**
+ * @group slow
+ * @group dav
+ * @group imap
+ */
+class KolabTest extends TestCase
+{
+ use BackendsTrait;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ MigratorQueue::truncate();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ MigratorQueue::truncate();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test Kolab3 to Kolab4 migration
+ */
+ public function testInitialMigration3to4(): void
+ {
+ [$src, $src_imap, $src_dav, $dst, $dst_imap, $dst_dav] = $this->getTestAccounts();
+
+ $this->prepareKolab3Account($src_imap, $src_dav);
+ $this->prepareKolab4Account($dst_imap, $dst_dav);
+
+ // Run the migration
+ $migrator = new Engine();
+ $migrator->migrate($src, $dst, ['force' => true, 'sync' => true]);
+
+ // Assert the migrated mail
+ $messages = $this->imapList($dst_imap, 'INBOX');
+ $messages = \collect($messages)->keyBy('messageID')->all();
+ $this->assertCount(2, $messages);
+ $this->assertSame([], $messages['<sync1@kolab.org>']->flags);
+ $this->assertSame(['SEEN'], array_keys($messages['<sync2@kolab.org>']->flags));
+ $sync1_uid = $messages['<sync1@kolab.org>']->uid;
+ $sync2_uid = $messages['<sync2@kolab.org>']->uid;
+
+ $messages = $this->imapList($dst_imap, 'Drafts');
+ $messages = \collect($messages)->keyBy('messageID')->all();
+ $this->assertCount(1, $messages);
+ $this->assertSame(['SEEN'], array_keys($messages['<sync3@kolab.org>']->flags));
+
+ $this->assertCount(0, $this->imapList($dst_imap, 'Spam'));
+ $this->assertCount(0, $this->imapList($dst_imap, 'Trash'));
+
+ // Assert non-mail folders were not migrated
+ $mail_folders = $this->imapListFolders($dst_imap);
+ $this->assertNotContains('Configuration', $mail_folders);
+ $this->assertNotContains('Test', $mail_folders);
+
+ // Assert migrated tags
+ $imap = $this->getImapClient($dst_imap);
+ $tags = KolabTags::getKolab4Tags($imap);
+ $this->assertCount(1, $tags);
+ $this->assertSame('tag', $tags[0]['name']);
+ $this->assertSame('#E0431B', $tags[0]['color']);
+ $members = KolabTags::getKolab4TagMembers($imap, 'tag', 'INBOX');
+ $this->assertSame([(string) $sync1_uid, (string) $sync2_uid], $members);
+
+ // Assert the migrated events
+ $events = $this->davList($dst_dav, 'Calendar', Engine::TYPE_EVENT);
+ $events = \collect($events)->keyBy('uid')->all();
+ $this->assertCount(2, $events);
+ $this->assertSame('Party', $events['abcdef']->summary);
+ $this->assertSame('Meeting', $events['123456']->summary);
+
+ $events = $this->davList($dst_dav, 'Custom Calendar', Engine::TYPE_EVENT);
+ $events = \collect($events)->keyBy('uid')->all();
+ $this->assertCount(1, $events);
+ $this->assertSame('Test Summary', $events['aaa-aaa']->summary);
+
+ // Assert the migrated contacts
+ $contacts = $this->davList($dst_dav, 'Contacts', Engine::TYPE_CONTACT);
+ $contacts = \collect($contacts)->keyBy('uid')->all();
+ $this->assertCount(2, $contacts);
+ $this->assertSame('Jane Doe', $contacts['uid1']->fn);
+ $this->assertSame('Jack Strong', $contacts['uid2']->fn);
+
+ // Assert the migrated tasks
+ $tasks = $this->davList($dst_dav, 'Tasks', Engine::TYPE_TASK);
+ $tasks = \collect($tasks)->keyBy('uid')->all();
+ $this->assertCount(2, $tasks);
+ $this->assertSame('Task1', $tasks['ccc-ccc']->summary);
+ $this->assertSame('Task2', $tasks['ddd-ddd']->summary);
+ }
+
+ /**
+ * Test Kolab3 to Kolab4 incremental migration run
+ *
+ * @depends testInitialMigration3to4
+ */
+ public function testIncrementalMigration3to4(): void
+ {
+ [$src, $src_imap, $src_dav, $dst, $dst_imap, $dst_dav] = $this->getTestAccounts();
+
+ // Add/modify some source account data
+ $messages = $this->imapList($src_imap, 'INBOX');
+ $existing = \collect($messages)->keyBy('messageID')->all();
+ $this->imapFlagAs($src_imap, 'INBOX', $existing['<sync1@kolab.org>']->uid, ['SEEN', 'FLAGGED']);
+ $this->imapFlagAs($src_imap, 'INBOX', $existing['<sync2@kolab.org>']->uid, ['UNSEEN']);
+ $this->imapAppend($src_imap, 'Configuration', 'kolab3/tag2.eml');
+ $this->imapAppend($src_imap, 'Drafts', 'mail/4.eml', ['SEEN']);
+ $replace = [
+ // '/john@kolab.org/' => 'ned@kolab.org',
+ '/DTSTAMP:19970714T170000Z/' => 'DTSTAMP:20240714T170000Z',
+ '/SUMMARY:Party/' => 'SUMMARY:Test'
+ ];
+ $this->davAppend($src_dav, 'Calendar', ['event/1.ics'], Engine::TYPE_EVENT, $replace);
+
+ // Run the migration
+ $migrator = new Engine();
+ $migrator->migrate($src, $dst, ['force' => true,'sync' => true]);
+
+ // Assert the migrated mail
+ $messages = $this->imapList($dst_imap, 'INBOX');
+ $messages = \collect($messages)->keyBy('messageID')->all();
+ $this->assertCount(2, $messages);
+ $this->assertSame(['FLAGGED', 'SEEN'], array_keys($messages['<sync1@kolab.org>']->flags));
+ $this->assertSame([], $messages['<sync2@kolab.org>']->flags);
+ $sync2_uid = $messages['<sync2@kolab.org>']->uid;
+
+ $messages = $this->imapList($dst_imap, 'Drafts');
+ $messages = \collect($messages)->keyBy('messageID')->all();
+ $this->assertCount(2, $messages);
+ $this->assertSame(['SEEN'], array_keys($messages['<sync3@kolab.org>']->flags));
+ $this->assertSame(['SEEN'], array_keys($messages['<sync4@kolab.org>']->flags));
+ $sync3_uid = $messages['<sync3@kolab.org>']->uid;
+ $sync4_uid = $messages['<sync4@kolab.org>']->uid;
+
+ // Assert migrated tags
+ $imap = $this->getImapClient($dst_imap);
+ $tags = KolabTags::getKolab4Tags($imap);
+ $this->assertCount(2, $tags);
+ $this->assertSame('tag', $tags[0]['name']);
+ $this->assertSame('#E0431B', $tags[0]['color']);
+ $this->assertSame('test', $tags[1]['name']);
+ $this->assertSame('#FFFFFF', $tags[1]['color']);
+ $members = KolabTags::getKolab4TagMembers($imap, 'test', 'INBOX');
+ $this->assertSame([(string) $sync2_uid], $members);
+ $members = KolabTags::getKolab4TagMembers($imap, 'test', 'Drafts');
+ $this->assertSame([(string) $sync3_uid, (string) $sync4_uid], $members);
+
+ // Assert the migrated events
+ $events = $this->davList($dst_dav, 'Calendar', Engine::TYPE_EVENT);
+ $events = \collect($events)->keyBy('uid')->all();
+ $this->assertCount(2, $events);
+ $this->assertSame('Test', $events['abcdef']->summary);
+ }
+
+ /**
+ * Initialize accounts for tests
+ */
+ private function getTestAccounts()
+ {
+ $dav_uri = \config('services.dav.uri');
+ $dav_uri = preg_replace('|^http|', 'dav', $dav_uri);
+ $imap_uri = \config('services.imap.uri');
+ if (strpos($imap_uri, '://') === false) {
+ $imap_uri = 'imap://' . $imap_uri;
+ }
+
+ $kolab3_uri = preg_replace('|^[a-z]+://|', 'kolab://ned%40kolab.org:simple123@', $imap_uri)
+ . '?dav_host=' . preg_replace('|^davs?://|', '', $dav_uri);
+ $kolab4_uri = preg_replace('|^[a-z]+://|', 'kolab://jack%40kolab.org:simple123@', $imap_uri)
+ . '?dav_host=' . preg_replace('|^davs?://|', '', $dav_uri);
+
+ // Note: These are Kolab4 accounts, we'll modify the src account to imitate a Kolab3 account
+ // as much as we can. See self::prepareKolab3Account()
+
+ $src_imap = new Account(preg_replace('|://|', '://ned%40kolab.org:simple123@', $imap_uri));
+ $src_dav = new Account(preg_replace('|://|', '://ned%40kolab.org:simple123@', $dav_uri));
+ $src = new Account($kolab3_uri);
+ $dst_imap = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $imap_uri));
+ $dst_dav = new Account(preg_replace('|://|', '://jack%40kolab.org:simple123@', $dav_uri));
+ $dst = new Account($kolab4_uri);
+
+ return [$src, $src_imap, $src_dav, $dst, $dst_imap, $dst_dav];
+ }
+
+ /**
+ * Initial preparation of a Kolab v3 account for tests
+ */
+ private function prepareKolab3Account(Account $imap_account, Account $dav_account)
+ {
+ // Cleanup the account
+ $this->initAccount($imap_account);
+ $this->initAccount($dav_account);
+ $this->davDeleteFolder($dav_account, 'Custom Calendar', Engine::TYPE_EVENT);
+
+ $imap = $this->getImapClient($imap_account);
+
+ // Create Configuration folder with sample relation (tag) object
+ $this->imapCreateFolder($imap_account, 'Configuration');
+ $this->imapEmptyFolder($imap_account, 'Configuration');
+ if (!$imap->setMetadata('Configuration', ['/private/vendor/kolab/folder-type' => 'configuration'])) {
+ throw new \Exception("Failed to set metadata");
+ }
+ $this->imapAppend($imap_account, 'Configuration', 'kolab3/tag1.eml');
+ $this->imapAppend($imap_account, 'Configuration', 'kolab3/tag2.eml', ['DELETED']);
+
+ // Create a non-mail folder, we'll assert that it was skipped in migration
+ $this->imapCreateFolder($imap_account, 'Test');
+ if (!$imap->setMetadata('Test', ['/private/vendor/kolab/folder-type' => 'contact'])) {
+ throw new \Exception("Failed to set metadata");
+ }
+ if (!$imap->setMetadata('INBOX', ['/private/vendor/kolab/folder-type' => 'mail.inbox'])) {
+ throw new \Exception("Failed to set metadata");
+ }
+
+ // Insert some other data to migrate
+ $this->imapAppend($imap_account, 'INBOX', 'mail/1.eml');
+ $this->imapAppend($imap_account, 'INBOX', 'mail/2.eml', ['SEEN']);
+ $this->imapAppend($imap_account, 'Drafts', 'mail/3.eml', ['SEEN']);
+ $this->davAppend($dav_account, 'Calendar', ['event/1.ics', 'event/2.ics'], Engine::TYPE_EVENT);
+ $this->davCreateFolder($dav_account, 'Custom Calendar', Engine::TYPE_EVENT);
+ $this->davAppend($dav_account, 'Custom Calendar', ['event/3.ics'], Engine::TYPE_EVENT);
+ $this->davAppend($dav_account, 'Contacts', ['contact/1.vcf', 'contact/2.vcf'], Engine::TYPE_CONTACT);
+ $this->davAppend($dav_account, 'Tasks', ['task/1.ics', 'task/2.ics'], Engine::TYPE_TASK);
+ }
+
+ /**
+ * Initial preparation of a Kolab v4 account for tests
+ */
+ private function prepareKolab4Account(Account $imap_account, Account $dav_account)
+ {
+ $this->initAccount($imap_account);
+ $this->initAccount($dav_account);
+ $this->davDeleteFolder($dav_account, 'Custom Calendar', Engine::TYPE_EVENT);
+ $this->imapDeleteFolder($imap_account, 'Test');
+ $this->imapDeleteFolder($imap_account, 'Configuration');
+ $imap = $this->getImapClient($imap_account);
+ KolabTags::saveKolab4Tags($imap, []);
+ }
+}
diff --git a/src/tests/data/kolab3/tag1.eml b/src/tests/data/kolab3/tag1.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/kolab3/tag1.eml
@@ -0,0 +1,46 @@
+MIME-Version: 1.0
+From: ned@kolab.org
+To: ned@kolab.org
+Date: Fri, 10 Jan 2025 15:24:06 +0100
+X-Kolab-Type: application/x-vnd.kolab.configuration.relation
+X-Kolab-Mime-Version: 3.0
+Subject: a597dfc8-9876-4b1d-964d-f4fafc6f4481
+User-Agent: Kolab 16/Roundcube 1.5-git
+Content-Type: multipart/mixed;
+ boundary="=_9abdaa1a17f700a3819efeb6c73ee9fa"
+
+--=_9abdaa1a17f700a3819efeb6c73ee9fa
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=ISO-8859-1
+
+This is a Kolab Groupware object. To view this object you will need an emai=
+l client that understands the Kolab Groupware format. For a list of such em=
+ail clients please visit https://www.kolab.org/
+
+
+--=_9abdaa1a17f700a3819efeb6c73ee9fa
+Content-Transfer-Encoding: 8bit
+Content-Type: application/vnd.kolab+xml; charset=UTF-8;
+ name=kolab.xml
+Content-Disposition: attachment;
+ filename=kolab.xml;
+ size=869
+
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<configuration xmlns="http://kolab.org" version="3.0">
+ <uid>a597dfc8-9876-4b1d-964d-f4fafc6f4481</uid>
+ <prodid>Roundcube-libkolab-1.1 Libkolabxml-1.3.1</prodid>
+ <creation-date>2024-10-01T07:40:45Z</creation-date>
+ <last-modification-date>2025-01-10T14:24:06Z</last-modification-date>
+ <type>relation</type>
+ <name>tag</name>
+ <relationType>tag</relationType>
+ <color>#E0431B</color>
+ <priority>-1444709376</priority>
+ <member>urn:uuid:ccc-ccc</member>
+ <member>urn:uuid:ddd-ddd</member>
+ <member>imap:///user/ned%40kolab.org/INBOX/10?message-id=%3Csync1%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync</member>
+ <member>imap:///user/ned%40kolab.org/INBOX/11?message-id=%3Csync2%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync+with+attachment</member>
+</configuration>
+
+--=_9abdaa1a17f700a3819efeb6c73ee9fa--
diff --git a/src/tests/data/kolab3/tag2.eml b/src/tests/data/kolab3/tag2.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/kolab3/tag2.eml
@@ -0,0 +1,46 @@
+MIME-Version: 1.0
+From: ned@kolab.org
+To: ned@kolab.org
+Date: Fri, 10 Jan 2025 15:24:06 +0100
+X-Kolab-Type: application/x-vnd.kolab.configuration.relation
+X-Kolab-Mime-Version: 3.0
+Subject: a597dfc8-9876-4b1d-964d-f4fafc6f4482
+User-Agent: Kolab 16/Roundcube 1.5-git
+Content-Type: multipart/mixed;
+ boundary="=_9abdaa1a17f700a3819efeb6c73ee9fa"
+
+--=_9abdaa1a17f700a3819efeb6c73ee9fa
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=ISO-8859-1
+
+This is a Kolab Groupware object. To view this object you will need an emai=
+l client that understands the Kolab Groupware format. For a list of such em=
+ail clients please visit https://www.kolab.org/
+
+
+--=_9abdaa1a17f700a3819efeb6c73ee9fa
+Content-Transfer-Encoding: 8bit
+Content-Type: application/vnd.kolab+xml; charset=UTF-8;
+ name=kolab.xml
+Content-Disposition: attachment;
+ filename=kolab.xml;
+ size=869
+
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<configuration xmlns="http://kolab.org" version="3.0">
+ <uid>a597dfc8-9876-4b1d-964d-f4fafc6f4482</uid>
+ <prodid>Roundcube-libkolab-1.1 Libkolabxml-1.3.1</prodid>
+ <creation-date>2024-10-01T07:40:45Z</creation-date>
+ <last-modification-date>2025-01-10T14:24:06Z</last-modification-date>
+ <type>relation</type>
+ <name>test</name>
+ <relationType>tag</relationType>
+ <color>#FFFFFF</color>
+ <priority>1</priority>
+ <member>urn:uuid:ccc-ccc</member>
+ <member>imap:///user/ned%40kolab.org/INBOX/11?message-id=%3Csync2%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync+with+attachment</member>
+ <member>imap:///user/ned%40kolab.org/Drafts/120?message-id=%3Csync3%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+migrator</member>
+ <member>imap:///user/ned%40kolab.org/Drafts/121?message-id=%3Csync4%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync+4th</member>
+</configuration>
+
+--=_9abdaa1a17f700a3819efeb6c73ee9fa--
diff --git a/src/tests/data/task/1.ics b/src/tests/data/task/1.ics
new file mode 100644
--- /dev/null
+++ b/src/tests/data/task/1.ics
@@ -0,0 +1,14 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.5//EN
+CALSCALE:GREGORIAN
+BEGIN:VTODO
+UID:ccc-ccc
+DTSTAMP:20241218T101054Z
+CREATED:20241218T101054Z
+LAST-MODIFIED:20241218T101054Z
+SUMMARY:Task1
+SEQUENCE:0
+ORGANIZER;CN=Test:mailto:ned@kolab.org
+END:VTODO
+END:VCALENDAR
diff --git a/src/tests/data/task/2.ics b/src/tests/data/task/2.ics
new file mode 100644
--- /dev/null
+++ b/src/tests/data/task/2.ics
@@ -0,0 +1,14 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube 1.5-git//Sabre VObject 4.5.5//EN
+CALSCALE:GREGORIAN
+BEGIN:VTODO
+UID:ddd-ddd
+DTSTAMP:20241218T101054Z
+CREATED:20241218T101054Z
+LAST-MODIFIED:20241218T101054Z
+SUMMARY:Task2
+SEQUENCE:0
+ORGANIZER;CN=Test:mailto:ned@kolab.org
+END:VTODO
+END:VCALENDAR
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 2:20 AM (21 h, 35 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18827757
Default Alt Text
D5088.1775269242.diff (31 KB)
Attached To
Mode
D5088: Kolab Data Migrator: Tags migration
Attached
Detach File
Event Timeline