diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php index b51141a3..ba277953 100644 --- a/src/app/DataMigrator/Account.php +++ b/src/app/DataMigrator/Account.php @@ -1,112 +1,112 @@ :". - * For proxy authentication use: "**" as username. + * For user impersonation use: ?user= in the query part of the URI. * * @param string $input Account specification (URI) */ public function __construct(string $input) { if (!preg_match('|^[a-z]+://.*|', $input)) { throw new \Exception("Invalid URI specified"); } $url = parse_url($input); // Not valid URI if (!is_array($url) || empty($url)) { throw new \Exception("Invalid URI specified"); } 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['port'])) { $this->port = $url['port']; } if (isset($url['host'])) { $this->host = $url['host']; $this->uri = $this->scheme . '://' . $url['host'] . ($this->port ? ":{$this->port}" : null) . ($url['path'] ?? ''); } if (!empty($url['query'])) { parse_str($url['query'], $this->params); } + if (!empty($this->params['user'])) { + $this->loginas = $this->params['user']; + } + 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/IMAP.php b/src/app/DataMigrator/IMAP.php index 89f9a4df..fe59a416 100644 --- a/src/app/DataMigrator/IMAP.php +++ b/src/app/DataMigrator/IMAP.php @@ -1,463 +1,464 @@ account = $account; $this->engine = $engine; // TODO: Move this to self::authenticate()? - $config = self::getConfig($account->username, $account->password, $account->uri); + $config = self::getConfig($account); $this->imap = self::initIMAP($config); } /** * Object destructor */ public function __destruct() { try { $this->imap->closeConnection(); } catch (\Throwable $e) { // Ignore. It may throw when destructing the object in tests // We also don't really care abount an error on this operation } } /** * Authenticate */ public function authenticate(): void { } /** * Create a folder. * * @param Folder $folder Folder data * * @throws \Exception on error */ public function createFolder(Folder $folder): void { if ($folder->type != 'mail') { throw new \Exception("IMAP does not support folder of type {$folder->type}"); } if ($folder->fullname == 'INBOX') { // INBOX always exists return; } if (!$this->imap->createFolder($folder->fullname)) { \Log::warning("Failed to create the folder: {$this->imap->error}"); if (str_contains($this->imap->error, "Mailbox already exists")) { // Not an error } else { throw new \Exception("Failed to create an IMAP folder {$folder->fullname}"); } } // TODO: Migrate folder subscription state } /** * Create an item in a folder. * * @param Item $item Item to import * * @throws \Exception */ public function createItem(Item $item): void { $mailbox = $item->folder->fullname; if (strlen($item->content)) { $result = $this->imap->append( $mailbox, $item->content, $item->data['flags'], $item->data['internaldate'], true ); if ($result === false) { throw new \Exception("Failed to append IMAP message into {$mailbox}"); } } elseif ($item->filename) { $result = $this->imap->appendFromFile( $mailbox, $item->filename, null, $item->data['flags'], $item->data['internaldate'], true ); if ($result === false) { throw new \Exception("Failed to append IMAP message into {$mailbox}"); } } // When updating an existing email message we have to... if ($item->existing) { if (!empty($result)) { // Remove the old one $this->imap->flag($mailbox, $item->existing['uid'], 'DELETED'); $this->imap->expunge($mailbox, $item->existing['uid']); } else { // Update flags foreach ($item->existing['flags'] as $flag) { if (!in_array($flag, $item->data['flags'])) { $this->imap->unflag($mailbox, $item->existing['uid'], $flag); } } foreach ($item->data['flags'] as $flag) { if (!in_array($flag, $item->existing['flags'])) { $this->imap->flag($mailbox, $item->existing['uid'], $flag); } } } } } /** * Fetching an item */ public function fetchItem(Item $item): void { [$uid, $messageId] = explode(':', $item->id, 2); $mailbox = $item->folder->fullname; // Get message flags $header = $this->imap->fetchHeader($mailbox, (int) $uid, true, false, ['FLAGS']); if ($header === false) { throw new \Exception("Failed to get IMAP message headers for {$mailbox}/{$uid}"); } // Remove flags that we can't append (e.g. RECENT) $flags = $this->filterImapFlags(array_keys($header->flags)); // If message already exists in the destination account we should update only flags // and be done with it. On the other hand for Drafts it's not unusual to get completely // different body for the same Message-ID. Same can happen not only in Drafts, I suppose. // So, we compare size and INTERNALDATE timestamp. if ( !$item->existing || $header->timestamp != $item->existing['timestamp'] || $header->size != $item->existing['size'] ) { // Handle message content in memory (up to 20MB), bigger messages will use a temp file if ($header->size > Engine::MAX_ITEM_SIZE) { // Save the message content to a file $location = $item->folder->tempFileLocation($uid . '.eml'); $fp = fopen($location, 'w'); if (!$fp) { throw new \Exception("Failed to open 'php://temp' stream"); } $result = $this->imap->handlePartBody($mailbox, $uid, true, '', null, null, $fp); } else { $result = $this->imap->handlePartBody($mailbox, $uid, true); } if ($result === false) { if (!empty($fp)) { fclose($fp); } throw new \Exception("Failed to fetch IMAP message for {$mailbox}/{$uid}"); } if (!empty($fp) && !empty($location)) { $item->filename = $location; fclose($fp); } else { $item->content = $result; } } $item->data = [ 'flags' => $flags, 'internaldate' => $header->internaldate, ]; } /** * Fetch a list of folder items */ public function fetchItemList(Folder $folder, $callback, ImporterInterface $importer): void { // Get existing messages' headers from the destination mailbox $existing = $importer->getItems($folder); $mailbox = $folder->fullname; // TODO: We should probably first use SEARCH/SORT to skip messages marked as \Deleted // It would also allow us to get headers in chunks 200 messages at a time, or so. // TODO: fetchHeaders() fetches too many headers, we should slim-down, here we need // only UID FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM MESSAGE-ID)] $messages = $this->imap->fetchHeaders($mailbox, '1:*', true, false, ['Message-Id']); if ($messages === false) { throw new \Exception("Failed to get all IMAP message headers for {$mailbox}"); } if (empty($messages)) { \Log::debug("Nothing to migrate for {$mailbox}"); return; } $set = new ItemSet(); foreach ($messages as $message) { // If Message-Id header does not exist create it based on internaldate/From/Date $id = $this->getMessageId($message, $mailbox); // Skip message that exists and did not change $exists = null; if (isset($existing[$id])) { $flags = $this->filterImapFlags(array_keys($message->flags)); if ( $flags == $existing[$id]['flags'] && $message->timestamp == $existing[$id]['timestamp'] && $message->size == $existing[$id]['size'] ) { continue; } $exists = $existing[$id]; } $set->items[] = Item::fromArray([ 'id' => $message->uid . ':' . $id, 'folder' => $folder, 'existing' => $exists, ]); if (count($set->items) == self::CHUNK_SIZE) { $callback($set); $set = new ItemSet(); } } if (count($set->items)) { $callback($set); } // TODO: Delete messages that do not exist anymore? } /** * Get folders hierarchy */ public function getFolders($types = []): array { $folders = $this->imap->listMailboxes('', ''); if ($folders === false) { throw new \Exception("Failed to get list of IMAP folders"); } // TODO: Migrate folder subscription state $result = []; foreach ($folders as $folder) { if ($this->shouldSkip($folder)) { \Log::debug("Skipping folder {$folder}."); continue; } $result[] = Folder::fromArray([ 'fullname' => $folder, 'type' => 'mail' ]); } return $result; } /** * Get a list of folder items, limited to their essential propeties * used in incremental migration to skip unchanged items. */ public function getItems(Folder $folder): array { $mailbox = $folder->fullname; // TODO: We should probably first use SEARCH/SORT to skip messages marked as \Deleted // TODO: fetchHeaders() fetches too many headers, we should slim-down, here we need // only UID FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (DATE FROM MESSAGE-ID)] $messages = $this->imap->fetchHeaders($mailbox, '1:*', true, false, ['Message-Id']); if ($messages === false) { throw new \Exception("Failed to get IMAP message headers in {$mailbox}"); } $result = []; foreach ($messages as $message) { // Remove flags that we can't append (e.g. RECENT) $flags = $this->filterImapFlags(array_keys($message->flags)); // Generate message ID if the header does not exist $id = $this->getMessageId($message, $mailbox); $result[$id] = [ 'uid' => $message->uid, 'flags' => $flags, 'size' => $message->size, 'timestamp' => $message->timestamp, ]; } return $result; } /** * Initialize IMAP connection and authenticate the user */ - private static function initIMAP(array $config, string $login_as = null): \rcube_imap_generic + private static function initIMAP(array $config): \rcube_imap_generic { $imap = new \rcube_imap_generic(); if (\config('app.debug')) { $imap->setDebug(true, 'App\Backends\IMAP::logDebug'); } - if ($login_as) { - $config['options']['auth_cid'] = $config['user']; - $config['options']['auth_pw'] = $config['password']; - $config['options']['auth_type'] = 'PLAIN'; - $config['user'] = $login_as; - } - $imap->connect($config['host'], $config['user'], $config['password'], $config['options']); if (!$imap->connected()) { $message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error); \Log::error($message); throw new \Exception("Connection to IMAP failed"); } return $imap; } /** * Get IMAP configuration */ - private static function getConfig($user, $password, $uri): array + private static function getConfig(Account $account): array { - $uri = \parse_url($uri); + $uri = \parse_url($account->uri); $default_port = 143; $ssl_mode = null; if (isset($uri['scheme'])) { if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) { $default_port = 993; $ssl_mode = 'ssl'; } elseif ($uri['scheme'] === 'tls') { $ssl_mode = 'tls'; } } $config = [ 'host' => $uri['host'], - 'user' => $user, - 'password' => $password, + 'user' => $account->username, + 'password' => $account->password, 'options' => [ 'port' => !empty($uri['port']) ? $uri['port'] : $default_port, 'ssl_mode' => $ssl_mode, 'socket_options' => [ 'ssl' => [ // TODO: These configuration options make sense for "local" Kolab IMAP, // but when connecting to external one we might want to just disable // cert validation, or make it optional via Account URI parameters 'verify_peer' => \config('services.imap.verify_peer'), 'verify_peer_name' => \config('services.imap.verify_peer'), 'verify_host' => \config('services.imap.verify_host') ], ], ], ]; + // User impersonation. Example URI: imap://admin:password@hostname:143?user=user%40domain.tld + if ($account->loginas) { + $config['options']['auth_cid'] = $config['user']; + $config['options']['auth_pw'] = $config['password']; + $config['options']['auth_type'] = 'PLAIN'; + $config['user'] = $account->loginas; + } + return $config; } /** * Limit IMAP flags to these that can be migrated */ private function filterImapFlags($flags) { // TODO: Support custom flags migration return array_filter( $flags, function ($flag) { return isset($this->imap->flags[$flag]); } ); } /** * Check if the folder should not be migrated */ private function shouldSkip($folder): bool { // TODO: This should probably use NAMESPACE information if (preg_match('~(Shared Folders|Other Users)/.*~', $folder)) { return true; } return false; } /** * Return Message-Id, generate unique identifier if Message-Id does not exist */ private function getMessageId($message, $folder): string { if (!empty($message->messageID)) { return $message->messageID; } return md5($folder . $message->from . ($message->date ?: $message->timestamp)); } } diff --git a/src/tests/BackendsTrait.php b/src/tests/BackendsTrait.php index 5fb4115f..c8059db7 100644 --- a/src/tests/BackendsTrait.php +++ b/src/tests/BackendsTrait.php @@ -1,405 +1,406 @@ DAV::TYPE_VEVENT, Engine::TYPE_TASK => DAV::TYPE_VTODO, Engine::TYPE_CONTACT => DAV::TYPE_VCARD, Engine::TYPE_GROUP => DAV::TYPE_VCARD, ]; /** * Append an DAV object to a DAV folder */ protected function davAppend(Account $account, $foldername, $filenames, $type): void { $dav = $this->getDavClient($account); $folder = $this->davFindFolder($account, $foldername, $type); if (empty($folder)) { throw new \Exception("Failed to find folder {$account}/{$foldername}"); } foreach ((array) $filenames as $filename) { $path = __DIR__ . '/data/' . $filename; if (!file_exists($path)) { throw new \Exception("File does not exist: {$path}"); } $content = file_get_contents($path); $uid = preg_match('/\nUID:(?:urn:uuid:)?([a-z0-9-]+)/', $content, $m) ? $m[1] : null; if (empty($uid)) { throw new \Exception("Filed to find UID in {$path}"); } $location = rtrim($folder->href, '/') . '/' . $uid . '.' . pathinfo($filename, \PATHINFO_EXTENSION); $content = new DAV\Opaque($content); $content->href = $location; $content->contentType = $type == Engine::TYPE_CONTACT ? 'text/vcard; charset=utf-8' : 'text/calendar; charset=utf-8'; if ($dav->create($content) === false) { throw new \Exception("Failed to append object into {$account}/{$location}"); } } } /** * Delete a DAV folder */ protected function davCreateFolder(Account $account, $foldername, $type): void { if ($this->davFindFolder($account, $foldername, $type)) { return; } $dav = $this->getDavClient($account); $dav_type = $this->davTypes[$type]; $home = $dav->getHome($dav_type); $folder_id = Utils::uuidStr(); $collection_type = $dav_type == DAV::TYPE_VCARD ? 'addressbook' : 'calendar'; // We create all folders on the top-level $folder = new DAV\Folder(); $folder->name = $foldername; $folder->href = rtrim($home, '/') . '/' . $folder_id; $folder->components = [$dav_type]; $folder->types = ['collection', $collection_type]; if ($dav->folderCreate($folder) === false) { throw new \Exception("Failed to create folder {$account}/{$folder->href}"); } } /** * Create a DAV folder */ protected function davDeleteFolder(Account $account, $foldername, $type): void { $folder = $this->davFindFolder($account, $foldername, $type); if (empty($folder)) { return; } $dav = $this->getDavClient($account); if ($dav->folderDelete($folder->href) === false) { throw new \Exception("Failed to delete folder {$account}/{$foldername}"); } } /** * Remove all objects from a DAV folder */ protected function davEmptyFolder(Account $account, $foldername, $type): void { $dav = $this->getDavClient($account); foreach ($this->davList($account, $foldername, $type) as $object) { if ($dav->delete($object->href) === false) { throw new \Exception("Failed to delete {$account}/{object->href}"); } } } /** * Find a DAV folder */ protected function davFindFolder(Account $account, $foldername, $type) { $dav = $this->getDavClient($account); $list = $dav->listFolders($this->davTypes[$type]); if ($list === false) { throw new \Exception("Failed to list '{$type}' folders on {$account}"); } foreach ($list as $folder) { if (str_replace(' » ', '/', $folder->name) === $foldername) { return $folder; } } return null; } /** * List objects in a DAV folder */ protected function davList(Account $account, $foldername, $type): array { $folder = $this->davFindFolder($account, $foldername, $type); if (empty($folder)) { throw new \Exception("Failed to find folder {$account}/{$foldername}"); } $dav = $this->getDavClient($account); $search = new DAV\Search($this->davTypes[$type], true); $searchResult = $dav->search($folder->href, $search); if ($searchResult === false) { throw new \Exception("Failed to get items from a DAV folder {$account}/{$folder->href}"); } $result = []; foreach ($searchResult as $item) { $result[] = $item; } return $result; } /** * List DAV folders */ protected function davListFolders(Account $account, $type): array { $dav = $this->getDavClient($account); $list = $dav->listFolders($this->davTypes[$type]); if ($list === false) { throw new \Exception("Failed to list '{$type}' folders on {$account}"); } $result = []; foreach ($list as $folder) { // skip shared folders (iRony) if (str_starts_with($folder->name, 'shared » ') || $folder->name[0] == '(') { continue; } $result[$folder->href] = str_replace(' » ', '/', $folder->name); } return $result; } /** * Get configured/initialized DAV client */ protected function getDavClient(Account $account): DAV { $clientId = (string) $account; if (empty($this->clients[$clientId])) { $uri = preg_replace('/^dav/', 'http', $account->uri); $this->clients[$clientId] = new DAV($account->username, $account->password, $uri); } return $this->clients[$clientId]; } /** * Get configured/initialized IMAP client */ protected function getImapClient(Account $account): \rcube_imap_generic { $clientId = (string) $account; if (empty($this->clients[$clientId])) { $class = new \ReflectionClass(IMAP::class); $initIMAP = $class->getMethod('initIMAP'); $getConfig = $class->getMethod('getConfig'); $initIMAP->setAccessible(true); $getConfig->setAccessible(true); $config = [ 'user' => $account->username, 'password' => $account->password, ]; + $login_as = $account->params['user'] ?? null; $config = array_merge($getConfig->invoke(null), $config); - $this->clients[$clientId] = $initIMAP->invokeArgs(null, [$config]); + $this->clients[$clientId] = $initIMAP->invokeArgs(null, [$config, $login_as]); } return $this->clients[$clientId]; } /** * Initialize an account */ protected function initAccount(Account $account): void { // Remove all objects from all (personal) folders if ($account->scheme == 'dav' || $account->scheme == 'davs') { foreach (['event', 'task', 'contact'] as $type) { foreach ($this->davListFolders($account, $type) as $folder) { $this->davEmptyFolder($account, $folder, $type); } } } else { // TODO: Delete all folders except the default ones? foreach ($this->imapListFolders($account) as $folder) { $this->imapEmptyFolder($account, $folder); } } } /** * Append an email message to the IMAP folder */ protected function imapAppend(Account $account, $folder, $filename, $flags = [], $date = null): string { $imap = $this->getImapClient($account); $source = __DIR__ . '/data/' . $filename; if (!file_exists($source)) { throw new \Exception("File does not exist: {$source}"); } $source = file_get_contents($source); $source = preg_replace('/\r?\n/', "\r\n", $source); $uid = $imap->append($folder, $source, $flags, $date, true); if ($uid === false) { throw new \Exception("Failed to append mail into {$account}/{$folder}"); } return $uid; } /** * Create an IMAP folder */ protected function imapCreateFolder(Account $account, $folder): void { $imap = $this->getImapClient($account); if (!$imap->createFolder($folder)) { if (str_contains($imap->error, "Mailbox already exists")) { // Not an error } else { throw new \Exception("Failed to create an IMAP folder {$account}/{$folder}"); } } } /** * Delete an IMAP folder */ protected function imapDeleteFolder(Account $account, $folder): void { $imap = $this->getImapClient($account); // Check the folder existence first, to prevent Cyrus IMAP fatal error when // attempting to delete a non-existing folder $existing = $imap->listMailboxes('', $folder); if (is_array($existing) && in_array($folder, $existing)) { return; } if (!$imap->deleteFolder($folder)) { if (str_contains($imap->error, "Mailbox does not exist")) { // Ignore } else { throw new \Exception("Failed to delete an IMAP folder {$account}/{$folder}"); } } $imap->unsubscribe($folder); } /** * Remove all objects from a folder */ protected function imapEmptyFolder(Account $account, $folder): void { $imap = $this->getImapClient($account); $deleted = $imap->flag($folder, '1:*', 'DELETED'); if (!$deleted) { throw new \Exception("Failed to empty an IMAP folder {$account}/{$folder}"); } // send expunge command in order to have the deleted message really deleted from the folder $imap->expunge($folder, '1:*'); } /** * List emails over IMAP */ protected function imapList(Account $account, $folder): array { $imap = $this->getImapClient($account); $messages = $imap->fetchHeaders($folder, '1:*', true, false, ['Message-Id']); if ($messages === false) { throw new \Exception("Failed to get all IMAP message headers for {$account}/{$folder}"); } return $messages; } /** * List IMAP folders */ protected function imapListFolders(Account $account): array { $imap = $this->getImapClient($account); $folders = $imap->listMailboxes('', ''); if ($folders === false) { throw new \Exception("Failed to list IMAP folders for {$account}"); } $folders = array_filter( $folders, function ($folder) { return !preg_match('~(Shared Folders|Other Users)/.*~', $folder); } ); return $folders; } /** * Mark an email message as read over IMAP */ protected function imapFlagAs(Account $account, $folder, $uids, $flags): void { $imap = $this->getImapClient($account); foreach ($flags as $flag) { if (strpos($flag, 'UN') === 0) { $flagged = $imap->unflag($folder, $uids, substr($flag, 2)); } else { $flagged = $imap->flag($folder, $uids, $flag); } if (!$flagged) { throw new \Exception("Failed to flag an IMAP messages as SEEN in {$account}/{$folder}"); } } } } diff --git a/src/tests/Feature/DataMigrator/IMAPTest.php b/src/tests/Feature/DataMigrator/IMAPTest.php index 6b158f57..3aa254e6 100644 --- a/src/tests/Feature/DataMigrator/IMAPTest.php +++ b/src/tests/Feature/DataMigrator/IMAPTest.php @@ -1,151 +1,154 @@ initAccount($src); $this->initAccount($dst); // Add some mail to the source account $this->imapAppend($src, 'INBOX', 'mail/1.eml'); $this->imapAppend($src, 'INBOX', 'mail/2.eml', ['SEEN']); $this->imapCreateFolder($src, 'ImapDataMigrator'); $this->imapCreateFolder($src, 'ImapDataMigrator/Test'); $this->imapAppend($src, 'ImapDataMigrator/Test', 'mail/1.eml'); $this->imapAppend($src, 'ImapDataMigrator/Test', 'mail/2.eml'); // Clean up the destination folders structure $this->imapDeleteFolder($dst, 'ImapDataMigrator/Test'); $this->imapDeleteFolder($dst, 'ImapDataMigrator'); // Run the migration $migrator = new Engine(); $migrator->migrate($src, $dst, ['force' => true, 'sync' => true]); // Assert the destination mailbox $dstFolders = $this->imapListFolders($dst); $this->assertContains('ImapDataMigrator', $dstFolders); $this->assertContains('ImapDataMigrator/Test', $dstFolders); // Assert the migrated messages $dstMessages = $this->imapList($dst, 'INBOX'); $this->assertCount(2, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame(['SEEN'], array_keys($msg->flags)); $dstMessages = $this->imapList($dst, 'ImapDataMigrator/Test'); $this->assertCount(2, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); // TODO: Test INTERNALDATE migration } /** * Test IMAP to IMAP incremental migration run * * @group imap * @depends testInitialMigration */ public function testIncrementalMigration(): void { $uri = \config('services.imap.uri'); if (strpos($uri, '://') === false) { $uri = 'imap://' . $uri; } - $src = new Account(str_replace('://', '://john%40kolab.org:simple123@', $uri)); - $dst = new Account(str_replace('://', '://jack%40kolab.org:simple123@', $uri)); + // Let's test with impersonation now + $adminUser = \config('services.imap.admin_login'); + $adminPass = \config('services.imap.admin_password'); + $src = new Account(str_replace('://', "://$adminUser:$adminPass@", $uri) . '?user=john%40kolab.org'); + $dst = new Account(str_replace('://', "://$adminUser:$adminPass@", $uri) . '?user=jack%40kolab.org'); // Add some mails to the source account $srcMessages = $this->imapList($src, 'INBOX'); $msg1 = array_shift($srcMessages); $msg2 = array_shift($srcMessages); $this->imapAppend($src, 'INBOX', 'mail/3.eml'); $this->imapAppend($src, 'INBOX', 'mail/4.eml'); $this->imapFlagAs($src, 'INBOX', $msg1->uid, ['SEEN']); $this->imapFlagAs($src, 'INBOX', $msg2->uid, ['UNSEEN', 'FLAGGED']); // Run the migration $migrator = new Engine(); $migrator->migrate($src, $dst, ['force' => true, 'sync' => true]); // In INBOX two new messages and two old ones with changed flags // The order of messages tells us that there was no redundant APPEND+DELETE $dstMessages = $this->imapList($dst, 'INBOX'); $this->assertCount(4, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame(['SEEN'], array_keys($msg->flags)); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame(['FLAGGED'], array_keys($msg->flags)); $ids = array_map(fn ($msg) => $msg->messageID, $dstMessages); $this->assertSame(['',''], $ids); // Nothing changed in the other folder $dstMessages = $this->imapList($dst, 'ImapDataMigrator/Test'); $this->assertCount(2, $dstMessages); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); $msg = array_shift($dstMessages); $this->assertSame('', $msg->messageID); $this->assertSame([], $msg->flags); } }