diff --git a/src/app/DataMigrator/EWS/DistList.php b/src/app/DataMigrator/EWS/DistList.php index c569d7b8..671cf77b 100644 --- a/src/app/DataMigrator/EWS/DistList.php +++ b/src/app/DataMigrator/EWS/DistList.php @@ -1,72 +1,77 @@ [$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] : []); } // TODO: The group description property in Exchange is not available via EWS XML, // at least not at outlook.office.com (Exchange 2010). It is available in the - // MimeContent which is in email message format. - - // TODO: mailto: members are not supported by Kolab Webclient - // 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 + // MimeContent which is in email message format. However, Kolab Webclient does not + // make any use of the NOTE property for contact groups. // Process list members if ($members = $item->getMembers()) { // The Member property is either array (multiple members) or Type\MemberType // object (a group with just a one member). if (!is_array($members->Member)) { $members->Member = [$members->Member]; } foreach ($members->Member as $member) { $mailbox = $member->getMailbox(); $mailto = $mailbox->getEmailAddress(); $name = $mailbox->getName(); + $id = $mailbox->getItemId(); + + // "mailto:" members are not fully supported by Kolab Webclient. + // For members that are contacts (have ItemId specified) we use urn:uuid: + // syntax that has good support. - if ($mailto) { + if ($id) { + $contactUID = sha1($id->toArray()['Id']); + $vcard .= $this->formatProp('MEMBER', "urn:uuid:{$contactUID}"); + } elseif ($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 index d523dcf5..0ba08c5f 100644 --- a/src/app/DataMigrator/EWS/Item.php +++ b/src/app/DataMigrator/EWS/Item.php @@ -1,175 +1,176 @@ 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 $ewsItem = $this->driver->api->getItem($itemId, $this->getItemRequest()); $uid = $this->getUID($ewsItem); \Log::debug("[EWS] Saving item {$uid}..."); // Apply type-specific format converters $content = $this->processItem($ewsItem); 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 { $itemId = $item->getItemId()->toArray(); if ($this->uid === null) { // Tasks, contacts, distlists do not have an UID. We have to generate one // and inject it into the output file. - // We'll use the ItemId (excluding the ChangeKey part) as a base for the UID. + // We'll use the ItemId (excluding the ChangeKey part) as a base for the UID, + // this way we can e.g. get distlist members references working. $this->uid = sha1($itemId['Id']); // $this->uid = \App\Utils::uuidStr(); } $this->itemId = implode('!', $itemId); 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/tests/Unit/DataMigrator/EWS/DistListTest.php b/src/tests/Unit/DataMigrator/EWS/DistListTest.php index 77366f11..528bfda7 100644 --- a/src/tests/Unit/DataMigrator/EWS/DistListTest.php +++ b/src/tests/Unit/DataMigrator/EWS/DistListTest.php @@ -1,89 +1,90 @@ 'test']); $distlist = new EWS\DistList($ews, $folder); // FIXME: I haven't found a way to convert xml content into a Type instance // therefore we create it "manually", but it would be better to have both // vcard and xml in a single data file that we could just get content from. $item = Type::buildFromArray([ 'ItemId' => new Type\ItemIdType( 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', ), 'Subject' => 'subject list', 'LastModifiedTime' => '2024-06-27T13:44:32Z', 'DisplayName' => 'Lista', 'FileAs' => 'lista', 'Members' => (object) [ 'Member' => [ Type\MemberType::buildFromArray([ 'Key' => 'AAAAAIErH6S+oxAZnW4A3QEPVAIAAAGAYQBsAGUAYwBAAGEAbABlAGMALgBw' . 'AGwAAABTAE0AVABQAAAAYQBsAGUAYwBAAGEAbABlAGMALgBwAGwAAAA=', - 'Mailbox' => [ + 'Mailbox' => Type\Mailbox::buildFromArray([ 'Name' => 'Alec', 'EmailAddress' => 'alec@kolab.org', 'RoutingType' => 'SMTP', 'MailboxType' => 'OneOff', - ], + ]), 'Status' => 'Normal', ]), Type\MemberType::buildFromArray([ 'Key' => 'AAAAAIErH6S+oxAZnW4A3QEPVAIAAAGAYQBsAGUAYwBAAGEAbABlAGMALgBw' . 'AGwAAABTAE0AVABQAAAAYQBsAGUAYwBAAGEAbABlAGMALgBwAGwAAAB=', - 'Mailbox' => [ + 'Mailbox' => Type\Mailbox::buildFromArray([ 'Name' => 'Christian', 'EmailAddress' => 'christian@kolab.org', 'RoutingType' => 'SMTP', 'MailboxType' => 'OneOff', - ], + 'ItemId' => new Type\ItemIdType('AAA', 'BBB'), + ]), 'Status' => 'Normal', ]), ], ], ]); // Convert the Exchange item into vCard $vcard = $this->invokeMethod($distlist, 'processItem', [$item]); // Parse the vCard $distlist = new Vcard(); $this->invokeMethod($distlist, 'fromVcard', [$vcard]); $msId = implode('!', $item->getItemId()->toArray()); $this->assertSame(['X-MS-ID' => $msId], $distlist->custom); $this->assertMatchesRegularExpression('/^[a-f0-9]{40}$/', $distlist->uid); $this->assertSame('group', $distlist->kind); $this->assertSame('Lista', $distlist->fn); $this->assertSame('Kolab EWS Data Migrator', $distlist->prodid); $this->assertSame('2024-06-27T13:44:32Z', $distlist->rev); $members = [ 'mailto:%22Alec%22+%3Calec%40kolab.org%3E', - 'mailto:%22Christian%22+%3Cchristian%40kolab.org%3E', + 'urn:uuid:' . sha1('AAA'), ]; $this->assertSame($members, $distlist->members); } }