diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php index f0f86f33..f5ac4ab6 100644 --- a/src/app/Backends/DAV.php +++ b/src/app/Backends/DAV.php @@ -1,569 +1,587 @@ 'urn:ietf:params:xml:ns:caldav', self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav', self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav', ]; protected $url; protected $user; protected $password; protected $responseHeaders = []; protected $homes; /** * Object constructor */ public function __construct($user, $password, $url = null) { $this->url = $url ?: \config('services.dav.uri'); $this->user = $user; $this->password = $password; } /** * Discover DAV home (root) collection of a specified type. * * @return array|false Home locations or False on error */ public function discover() { if (is_array($this->homes)) { return $this->homes; } $path = parse_url($this->url, PHP_URL_PATH); $body = '' . '' . '' . '' . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { \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('current-user-principal') as $prop) { $principal_href = $prop->nodeValue; break; } } if ($path && str_starts_with($principal_href, $path)) { $principal_href = substr($principal_href, strlen($path)); } + $ns = [ + 'xmlns:d="DAV:"', + 'xmlns:cal="urn:ietf:params:xml:ns:caldav"', + 'xmlns:card="urn:ietf:params:xml:ns:carddav"', + ]; + $body = '' - . '' + . '' . '' . '' . '' . '' . '' . ''; $response = $this->request($principal_href, 'PROPFIND', $body); if (empty($response)) { \Log::error("Failed to get home collections from the DAV server."); return false; } $elements = $response->getElementsByTagName('response'); $homes = []; 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; } } } } 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 null; } /** * Check if we can connect to the DAV server * * @return bool True on success, False otherwise */ public static function healthcheck(): bool { // TODO return true; } /** * Get list of folders of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return false|array List of folders' metadata or False on error */ public function listFolders(string $component) { $root_href = $this->getHome($component); if ($root_href === null) { return false; } $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"'; $props = ''; if ($component != self::TYPE_VCARD) { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"'; $props = '' . '' . ''; } $body = '' . '' . '' . '' . '' . '' . $props . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $headers = ['Depth' => 1, 'Prefer' => 'return-minimal']; $response = $this->request($root_href, 'PROPFIND', $body, $headers); if (empty($response)) { \Log::error("Failed to get folders list from the DAV server."); return false; } $folders = []; foreach ($response->getElementsByTagName('response') as $element) { $folder = DAV\Folder::fromDomElement($element); // Note: Addressbooks don't have 'type' specified if ( ($component == self::TYPE_VCARD && in_array('addressbook', $folder->types)) || in_array($component, $folder->components) ) { $folders[] = $folder; } } return $folders; } /** * Create a DAV object in a folder * * @param DAV\CommonObject $object Object * * @return false|DAV\CommonObject Object on success, False on error */ public function create(DAV\CommonObject $object) { $headers = ['Content-Type' => $object->contentType]; - $response = $this->request($object->href, 'PUT', $object, $headers); + $content = (string) $object; + + if (!strlen($content)) { + throw new \Exception("Cannot PUT an empty DAV object"); + } + + $response = $this->request($object->href, 'PUT', $content, $headers); if ($response !== false) { if (!empty($this->responseHeaders['ETag'])) { $etag = $this->responseHeaders['ETag'][0]; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } $object->etag = $etag; } return $object; } return false; } /** * Update a DAV object in a folder * * @param DAV\CommonObject $object Object * * @return false|DAV\CommonObject Object on success, False on error */ public function update(DAV\CommonObject $object) { return $this->create($object); } /** * Delete a DAV object from a folder * * @param string $location Object location * * @return bool True on success, False on error */ public function delete(string $location) { $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); return $response !== false; } /** * Create a DAV folder (collection) * * @param DAV\Folder $folder Folder object * * @return bool True on success, False on error */ public function folderCreate(DAV\Folder $folder) { $response = $this->request($folder->href, 'MKCOL', $folder->toXML('mkcol')); return $response !== false; } /** * Delete a DAV folder (collection) * * @param string $location Folder location * * @return bool True on success, False on error */ public function folderDelete($location) { $response = $this->request($location, 'DELETE'); return $response !== false; } /** * Get all properties of a folder. * * @param string $location Object location * * @return false|DAV\Folder Folder metadata or False on error */ public function folderInfo(string $location) { $body = DAV\Folder::propfindXML(); // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']); if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0))) { return DAV\Folder::fromDomElement($element); } return false; } /** * Update a DAV folder (collection) * * @param DAV\Folder $folder Folder object * * @return bool True on success, False on error */ public function folderUpdate(DAV\Folder $folder) { // Note: Changing resourcetype property is forbidden (at least by Cyrus) $response = $this->request($folder->href, 'PROPPATCH', $folder->toXML('propertyupdate')); 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. * * @param string $location Folder location * @param DAV\Search $search Search request parameters - * @param ?callable $callback Callback to execute on every object + * @param callable $callback A callback to execute on every item * - * @return false|array Objects metadata on success, False on error + * @return false|array List of objects on success, False on error */ public function search(string $location, DAV\Search $search, $callback = null) { $headers = ['Depth' => $search->depth, 'Prefer' => 'return-minimal']; $response = $this->request($location, 'REPORT', $search, $headers); if (empty($response)) { \Log::error("Failed to get objects from the DAV server."); return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $object = $this->objectFromElement($element, $search->component); if ($callback) { $object = $callback($object); } - $objects[] = $object; + if ($object) { + if (is_array($object)) { + $objects[$object[0]] = $object[1]; + } else { + $objects[] = $object; + } + } } return $objects; } /** * Fetch DAV objects data from a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) - * @param array $hrefs List of objects' locations to fetch (empty for all objects) + * @param array $hrefs List of objects' locations to fetch * * @return false|array Objects metadata on success, False on error */ public function getObjects(string $location, string $component, array $hrefs = []) { if (empty($hrefs)) { return []; } $body = ''; foreach ($hrefs as $href) { $body .= '' . $href . ''; } $queries = [ self::TYPE_VEVENT => 'calendar-multiget', self::TYPE_VTODO => 'calendar-multiget', self::TYPE_VCARD => 'addressbook-multiget', ]; $types = [ self::TYPE_VEVENT => 'calendar-data', self::TYPE_VTODO => 'calendar-data', self::TYPE_VCARD => 'address-data', ]; $body = '' . ' ' . '' . '' . '' . '' . $body . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { \Log::error("Failed to get objects from the DAV server."); return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->objectFromElement($element, $component); } return $objects; } /** * Parse XML content */ protected function parseXML($xml) { $doc = new \DOMDocument('1.0', 'UTF-8'); if (str_starts_with($xml, 'loadXML($xml)) { throw new \Exception("Failed to parse XML"); } $doc->formatOutput = true; } return $doc; } /** * Parse request/response body for debug purposes */ protected function debugBody($body, $headers) { $head = ''; foreach ($headers as $header_name => $header_value) { if (is_array($header_value)) { $header_value = implode("\n\t", $header_value); } $head .= "{$header_name}: {$header_value}\n"; } if ($body instanceof DAV\CommonObject) { $body = (string) $body; } if (str_starts_with($body, 'formatOutput = true; $doc->preserveWhiteSpace = false; if (!$doc->loadXML($body)) { throw new \Exception("Failed to parse XML"); } $body = $doc->saveXML(); } return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : ''); } /** * Create DAV\CommonObject from a DOMElement */ protected function objectFromElement($element, $component) { switch ($component) { case self::TYPE_VEVENT: $object = DAV\Vevent::fromDomElement($element); break; case self::TYPE_VTODO: $object = DAV\Vtodo::fromDomElement($element); break; case self::TYPE_VCARD: $object = DAV\Vcard::fromDomElement($element); break; default: throw new \Exception("Unknown component: {$component}"); } return $object; } /** * Execute HTTP request to a DAV server */ protected function request($path, $method, $body = '', $headers = []) { $debug = \config('app.debug'); $url = $this->url; $this->responseHeaders = []; if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) { $path = substr($path, strlen($rootPath)); } $url .= $path; $client = Http::withBasicAuth($this->user, $this->password); // $client = Http::withToken($token); // Bearer token if ($body) { if (!isset($headers['Content-Type'])) { $headers['Content-Type'] = 'application/xml; charset=utf-8'; } $client->withBody($body, $headers['Content-Type']); } if (!empty($headers)) { $client->withHeaders($headers); } if ($debug) { $body = $this->debugBody($body, $headers); \Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : '')); } $response = $client->send($method, $url); $body = $response->body(); $code = $response->status(); if ($debug) { \Log::debug("S: [{$code}]\n" . $this->debugBody($body, $response->headers())); } // Throw an exception if a client or server error occurred... $response->throw(); $this->responseHeaders = $response->headers(); return $this->parseXML($body); } } diff --git a/src/app/Backends/DAV/Opaque.php b/src/app/Backends/DAV/Opaque.php index 65a88b87..40744d0e 100644 --- a/src/app/Backends/DAV/Opaque.php +++ b/src/app/Backends/DAV/Opaque.php @@ -1,23 +1,27 @@ content = file_get_contents($filename); + if ($is_file) { + $this->content = file_get_contents($content); + } else { + $this->content = $content; + } } /** * Create string representation of the DAV object * * @return string */ public function __toString() { return $this->content; } } diff --git a/src/app/Backends/DAV/Search.php b/src/app/Backends/DAV/Search.php index 3c9bc7be..9291a7af 100644 --- a/src/app/Backends/DAV/Search.php +++ b/src/app/Backends/DAV/Search.php @@ -1,79 +1,90 @@ component = $component; + $this->withContent = $withContent; } /** * Create string representation of the search * * @return string */ public function __toString() { $ns = implode(' ', [ 'xmlns:d="DAV:"', 'xmlns:c="' . DAV::NAMESPACES[$this->component] . '"', ]); // Return properties $props = []; foreach ($this->properties as $prop) { $props[] = '<' . $prop . '/>'; } // Warning: Looks like some servers (iRony) ignore address-data/calendar-data // and return full VCARD/VCALENDAR. Which leads to unwanted loads of data in a response. if (!empty($this->dataProperties)) { $more_props = []; foreach ($this->dataProperties as $prop) { $more_props[] = ''; } if ($this->component == DAV::TYPE_VCARD) { $props[] = '' . implode('', $more_props) . ''; } else { $props[] = '' . '' . '' . '' . implode('', $more_props) . '' . ''; } + } elseif ($this->withContent) { + if ($this->component == DAV::TYPE_VCARD) { + $props[] = ''; + } else { + $props[] = ''; + } } // Search filter $filter = ''; if ($this->component == DAV::TYPE_VCARD) { $query = 'addressbook-query'; } else { $query = 'calendar-query'; - $filter = ''; - $filter = "{$filter}"; + $filter = '' + . '' + . '' + . ''; } if (empty($props)) { $props = ''; } else { $props = '' . implode('', $props) . ''; } return '' . "" . $props . $filter . ""; } } diff --git a/src/app/Backends/DAV/Vcard.php b/src/app/Backends/DAV/Vcard.php index b71edb39..d846e1dc 100644 --- a/src/app/Backends/DAV/Vcard.php +++ b/src/app/Backends/DAV/Vcard.php @@ -1,90 +1,136 @@ getElementsByTagName('address-data')->item(0)) { $object->fromVcard($data->nodeValue); } return $object; } /** * Set object properties from a vcard * * @param string $vcard vCard string */ protected function fromVcard(string $vcard): void { $vobject = Reader::read($vcard, Reader::OPTION_FORGIVING | Reader::OPTION_IGNORE_INVALID_LINES); if ($vobject->name != 'VCARD') { // FIXME: throw an exception? return; } + $this->vobject = $vobject; + $this->members = []; + $string_properties = [ + 'CLASS', 'FN', + 'KIND', + 'NOTE', + 'PRODID', 'REV', 'UID', + 'VERSION', ]; foreach ($vobject->children() as $prop) { if (!($prop instanceof Property)) { continue; } switch ($prop->name) { // TODO: Map all vCard properties to class properties + case 'EMAIL': + $props = []; + foreach ($prop->parameters() as $name => $value) { + $key = Str::camel(strtolower($name)); + $props[$key] = (string) $value; + } + + $props['email'] = (string) $prop; + $this->email[] = $props; + break; + + case 'MEMBER': + foreach ($prop as $member) { + $value = (string) $member; + if (preg_match('/^mailto:/', $value)) { + $this->members[] = $value; + } + } + break; + default: // map string properties if (in_array($prop->name, $string_properties)) { $key = Str::camel(strtolower($prop->name)); $this->{$key} = (string) $prop; } // custom properties if (\str_starts_with($prop->name, 'X-')) { $this->custom[$prop->name] = (string) $prop; } } } + + if (!empty($this->custom['X-ADDRESSBOOKSERVER-KIND']) && empty($this->kind)) { + $this->kind = strtolower($this->custom['X-ADDRESSBOOKSERVER-KIND']); + } } /** * Create string representation of the DAV object (vcard) * * @return string */ public function __toString() { - // TODO: This will be needed when we want to create/update objects - return ''; + if (!$this->vobject) { + // TODO we currently can only serialize a message back that we just read + throw new \Exception("Writing from properties is not implemented"); + } + + return Writer::write($this->vobject); } } diff --git a/src/app/Backends/DAV/Vevent.php b/src/app/Backends/DAV/Vevent.php index a5c40091..4d11812b 100644 --- a/src/app/Backends/DAV/Vevent.php +++ b/src/app/Backends/DAV/Vevent.php @@ -1,289 +1,294 @@ getElementsByTagName('calendar-data')->item(0)) { $object->fromIcal($data->nodeValue); } return $object; } /** * Set object properties from an iCalendar * * @param string $ical iCalendar string */ protected function fromIcal(string $ical): void { - $options = VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES; - $this->vobject = VObject\Reader::read($ical, $options); + $this->vobject = Reader::read($ical, Reader::OPTION_FORGIVING | Reader::OPTION_IGNORE_INVALID_LINES); if ($this->vobject->name != 'VCALENDAR') { return; } $selfType = strtoupper(class_basename(get_class($this))); + if (!empty($this->vobject->PRODID)) { + $this->prodid = (string) $this->vobject->PRODID; + } + foreach ($this->vobject->getComponents() as $component) { if ($component->name == $selfType) { $this->fromVObject($component); return; } } } /** * Set object properties from a Sabre/VObject component object * - * @param VObject\Component $vobject Sabre/VObject component + * @param Component $vobject Sabre/VObject component */ - protected function fromVObject(VObject\Component $vobject): void + protected function fromVObject(Component $vobject): void { $string_properties = [ + 'CLASS', 'COMMENT', 'DESCRIPTION', 'LOCATION', + 'PRIORITY', 'SEQUENCE', 'STATUS', 'SUMMARY', 'TRANSP', 'UID', 'URL', ]; // map string properties foreach ($string_properties as $prop) { if (isset($vobject->{$prop})) { $key = Str::camel(strtolower($prop)); $this->{$key} = (string) $vobject->{$prop}; } } // map other properties foreach ($vobject->children() as $prop) { - if (!($prop instanceof VObject\Property)) { + if (!($prop instanceof Property)) { continue; } switch ($prop->name) { case 'DTSTART': case 'DTEND': - case 'DUE': case 'CREATED': case 'LAST-MODIFIED': case 'DTSTAMP': $key = Str::camel(strtolower($prop->name)); // These are of type Sabre\VObject\Property\ICalendar\DateTime $this->{$key} = $prop; break; case 'RRULE': - $params = !empty($this->recurrence) ? $this->recurrence : []; + $params = []; foreach ($prop->getParts() as $k => $v) { $params[Str::camel(strtolower($k))] = is_array($v) ? implode(',', $v) : $v; } if (!empty($params['until'])) { $params['until'] = new \DateTime($params['until']); } if (empty($params['interval'])) { $params['interval'] = 1; } - $this->recurrence = array_filter($params); + $this->rrule = array_filter($params); break; case 'EXDATE': case 'RDATE': $key = strtolower($prop->name); - $dates = []; // TODO - - if (!empty($this->recurrence[$key])) { - $this->recurrence[$key] = array_merge($this->recurrence[$key], $dates); - } else { - $this->recurrence[$key] = $dates; - } + // TODO break; case 'ATTENDEE': case 'ORGANIZER': $attendee = [ 'rsvp' => false, 'email' => preg_replace('!^mailto:!i', '', (string) $prop), ]; $attendeeProps = ['CN', 'PARTSTAT', 'ROLE', 'CUTYPE', 'RSVP', 'DELEGATED-FROM', 'DELEGATED-TO', 'SCHEDULE-STATUS', 'SCHEDULE-AGENT', 'SENT-BY']; foreach ($prop->parameters() as $name => $value) { $key = Str::camel(strtolower($name)); switch ($name) { case 'RSVP': - $params[$key] = strtolower($value) == 'true'; + $attendee[$key] = strtolower($value) == 'true'; break; case 'CN': - $params[$key] = str_replace('\,', ',', strval($value)); + $attendee[$key] = str_replace('\,', ',', strval($value)); break; default: if (in_array($name, $attendeeProps)) { - $params[$key] = strval($value); + $attendee[$key] = strval($value); } break; } } if ($prop->name == 'ORGANIZER') { $attendee['role'] = 'ORGANIZER'; $attendee['partstat'] = 'ACCEPTED'; $this->organizer = $attendee; } elseif (empty($this->organizer) || $attendee['email'] != $this->organizer['email']) { $this->attendees[] = $attendee; } break; default: if (\str_starts_with($prop->name, 'X-')) { $this->custom[$prop->name] = (string) $prop; } } } // Check DURATION property if no end date is set /* if (empty($this->dtend) && !empty($this->dtstart) && !empty($vobject->DURATION)) { try { $duration = new \DateInterval((string) $vobject->DURATION); $end = clone $this->dtstart; $end->add($duration); $this->dtend = $end; } catch (\Exception $e) { // TODO: Error? } } */ // Find alarms foreach ($vobject->select('VALARM') as $valarm) { $action = 'DISPLAY'; $trigger = null; $alarm = []; foreach ($valarm->children() as $prop) { $value = strval($prop); switch ($prop->name) { case 'TRIGGER': foreach ($prop->parameters as $param) { if ($param->name == 'VALUE' && $param->getValue() == 'DATE-TIME') { $trigger = '@' . $prop->getDateTime()->format('U'); $alarm['trigger'] = $prop->getDateTime(); } elseif ($param->name == 'RELATED') { $alarm['related'] = $param->getValue(); } } /* if (!$trigger && ($values = libcalendaring::parse_alarm_value($value))) { $trigger = $values[2]; } */ if (empty($alarm['trigger'])) { $alarm['trigger'] = rtrim(preg_replace('/([A-Z])0[WDHMS]/', '\\1', $value), 'T'); // if all 0-values have been stripped, assume 'at time' if ($alarm['trigger'] == 'P') { $alarm['trigger'] = 'PT0S'; } } break; case 'ACTION': $action = $alarm['action'] = strtoupper($value); break; case 'SUMMARY': case 'DESCRIPTION': case 'DURATION': $alarm[strtolower($prop->name)] = $value; break; case 'REPEAT': $alarm['repeat'] = (int) $value; break; case 'ATTENDEE': $alarm['attendees'][] = preg_replace('!^mailto:!i', '', $value); break; } } if ($action != 'NONE') { if (!empty($alarm['trigger'])) { $this->valarms[] = $alarm; } } } } /** * Create string representation of the DAV object (iCalendar) * * @return string */ public function __toString() { if (!$this->vobject) { - //TODO we currently can only serialize a message back that we just read + // TODO we currently can only serialize a message back that we just read throw new \Exception("Writing from properties is not implemented"); } - return VObject\Writer::write($this->vobject); + return Writer::write($this->vobject); } } diff --git a/src/app/Backends/DAV/Vtodo.php b/src/app/Backends/DAV/Vtodo.php index 4584e0ce..9ae1bfc6 100644 --- a/src/app/Backends/DAV/Vtodo.php +++ b/src/app/Backends/DAV/Vtodo.php @@ -1,7 +1,53 @@ {$prop})) { + $key = Str::camel(strtolower($prop)); + $this->{$key} = (string) $vobject->{$prop}; + } + } + + // map other properties + foreach ($vobject->children() as $prop) { + if (!($prop instanceof Property)) { + continue; + } + + switch ($prop->name) { + case 'DUE': + // This is of type Sabre\VObject\Property\ICalendar\DateTime + $this->due = $prop; + break; + + case 'PERCENT-COMPLETE': + $this->percentComplete = $prop->getValue(); + break; + } + } + } } diff --git a/src/app/Console/Commands/Data/MigrateCommand.php b/src/app/Console/Commands/Data/MigrateCommand.php index c64f8ab1..245bb99a 100644 --- a/src/app/Console/Commands/Data/MigrateCommand.php +++ b/src/app/Console/Commands/Data/MigrateCommand.php @@ -1,64 +1,63 @@ argument('src')); $dst = new DataMigrator\Account($this->argument('dst')); $options = [ 'type' => $this->option('type'), 'force' => $this->option('force'), 'sync' => $this->option('sync'), '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 index 530a5f23..b51141a3 100644 --- a/src/app/DataMigrator/Account.php +++ b/src/app/DataMigrator/Account.php @@ -1,109 +1,112 @@ :". * For proxy authentication use: "**" as username. * - * @param string $input Account specification + * @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, 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]; + // 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 (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 index f4e3bb39..bdfd290b 100644 --- a/src/app/DataMigrator/DAV.php +++ b/src/app/DataMigrator/DAV.php @@ -1,367 +1,411 @@ username . ($account->loginas ? "**{$account->loginas}" : ''); $baseUri = rtrim($account->uri, '/'); $baseUri = preg_replace('|^dav|', 'http', $baseUri); $this->client = new DAVClient($username, $account->password, $baseUri); $this->engine = $engine; $this->account = $account; } /** * Check user credentials. * * @throws \Exception */ public function authenticate(): void { try { $this->client->options(); } catch (\Exception $e) { throw new \Exception("Invalid DAV credentials or server."); } } /** * Create an item in a folder. * * @param Item $item Item to import * * @throws \Exception */ public function createItem(Item $item): void { - // TODO: For now we do DELETE + PUT. It's because the UID might have changed (which - // is the case with e.g. contacts from EWS) causing a discrepancy between UID and href. - // This is not necessarily a problem and would not happen to calendar events. - // So, maybe we could improve that so DELETE is not needed. if ($item->existing) { - try { - $this->client->delete($item->existing); - } catch (\Illuminate\Http\Client\RequestException $e) { - // ignore 404 response, item removed in meantime? - if ($e->getCode() != 404) { - throw $e; - } - } + $href = $item->existing; + } else { + $href = $this->getFolderPath($item->folder) . '/' . pathinfo($item->filename, PATHINFO_BASENAME); } - $href = $this->getFolderPath($item->folder) . '/' . pathinfo($item->filename, PATHINFO_BASENAME); - - $object = new DAVOpaque($item->filename); + $object = new DAVOpaque($item->filename, true); $object->href = $href; switch ($item->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 DAV object 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}"); } } /** * Fetching an item */ public function fetchItem(Item $item): void { // Save the item content to a file $location = $item->folder->location; if (!file_exists($location)) { mkdir($location, 0740, true); } $location .= '/' . basename($item->id); $result = $this->client->getObjects(dirname($item->id), $this->type2DAV($item->folder->type), [$item->id]); if ($result === false) { throw new \Exception("Failed to fetch DAV item for {$item->id}"); } // TODO: Do any content changes, e.g. organizer/attendee email migration if (file_put_contents($location, (string) $result[0]) === false) { throw new \Exception("Failed to write to {$location}"); } $item->filename = $location; } /** * 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); - $set = new ItemSet(); - $dav_type = $this->type2DAV($folder->type); $location = $this->getFolderPath($folder); - $search = new DAVSearch($dav_type); + $search = new DAVSearch($dav_type, false); // TODO: We request only properties relevant to incremental migration, // i.e. to find that something exists and its last update time. // Some servers (iRony) do ignore that and return full VCARD/VEVENT/VTODO // content, if there's many objects we'll have a memory limit issue. // Also, this list should be controlled by the exporter. - $search->dataProperties = ['UID', 'REV']; + $search->dataProperties = ['UID', 'REV', 'DTSTAMP']; + + $set = new ItemSet(); $result = $this->client->search( $location, $search, - function ($item) use (&$set, $folder, $callback) { - // TODO: Skip an item that exists and did not change - $exists = false; + function ($item) use (&$set, $dav_type, $folder, $existing, $callback) { + // Skip an item that exists and did not change + $exists = null; + if (!empty($existing[$item->uid])) { + $exists = $existing[$item->uid]['href']; + switch ($dav_type) { + case DAVClient::TYPE_VCARD: + if ($existing[$item->uid]['rev'] == $item->rev) { + return null; + } + break; + case DAVClient::TYPE_VEVENT: + case DAVClient::TYPE_VTODO: + if ($existing[$item->uid]['dtstamp'] == (string) $item->dtstamp) { + return null; + } + break; + } + } $set->items[] = Item::fromArray([ 'id' => $item->href, 'folder' => $folder, 'existing' => $exists, ]); if (count($set->items) == self::CHUNK_SIZE) { $callback($set); $set = new ItemSet(); } + + return null; } ); - if ($result === false) { - throw new \Exception("Failed to get items from a DAV folder {$location}"); - } - if (count($set->items)) { $callback($set); } + if ($result === false) { + throw new \Exception("Failed to get items from a DAV folder {$location}"); + } + // TODO: Delete items that do not exist anymore? } /** - * Get a list of folder items, limited to their essential propeties + * Get a list of items, limited to their essential propeties * used in incremental migration. * * @param Folder $folder Folder data * * @throws \Exception on error */ public function getItems(Folder $folder): array { $dav_type = $this->type2DAV($folder->type); $location = $this->getFolderPath($folder); $search = new DAVSearch($dav_type); // TODO: We request only properties relevant to incremental migration, // i.e. to find that something exists and its last update time. // Some servers (iRony) do ignore that and return full VCARD/VEVENT/VTODO // content, if there's many objects we'll have a memory limit issue. // Also, this list should be controlled by the exporter. - $search->dataProperties = ['UID', 'X-MS-ID', 'REV']; + $search->dataProperties = ['UID', 'X-MS-ID', 'REV', 'DTSTAMP']; $items = $this->client->search( $location, $search, - // @phpstan-ignore-next-line function ($item) use ($dav_type) { // Slim down the result to properties we might need - $result = [ + $object = [ 'href' => $item->href, 'uid' => $item->uid, - 'x-ms-id' => $item->custom['X-MS-ID'] ?? null, ]; - /* + + if (!empty($item->custom['X-MS-ID'])) { + $object['x-ms-id'] = $item->custom['X-MS-ID']; + } + switch ($dav_type) { case DAVClient::TYPE_VCARD: - $result['rev'] = $item->rev; + $object['rev'] = $item->rev; + break; + case DAVClient::TYPE_VEVENT: + case DAVClient::TYPE_VTODO: + $object['dtstamp'] = (string) $item->dtstamp; break; } - */ - return $result; + return [$item->uid, $object]; } ); if ($items === false) { throw new \Exception("Failed to get items from a DAV folder {$location}"); } return $items; } /** * Get folders hierarchy */ public function getFolders($types = []): array { $result = []; foreach (['VEVENT', 'VTODO', 'VCARD'] as $component) { $type = $this->typeFromDAV($component); // Skip folder types we do not support (need) if (!empty($types) && !in_array($type, $types)) { continue; } - // TODO: Skip other users folders - $folders = $this->client->listFolders($component); foreach ($folders as $folder) { + // Skip other users/shared folders + if ($this->shouldSkip($folder)) { + continue; + } + $result[$folder->href] = Folder::fromArray([ - 'fullname' => $folder->name, + 'fullname' => str_replace(' » ', '/', $folder->name), 'href' => $folder->href, 'type' => $type, ]); } } return $result; } /** * Get folder relative URI */ protected function getFolderPath(Folder $folder): string { + $cache_key = $folder->type . '!' . $folder->fullname; + if (isset($this->folderPaths[$cache_key])) { + return $this->folderPaths[$cache_key]; + } + $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, '/'); + return $this->folderPaths[$cache_key] = 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"); } } /** * Map DAV object type into Kolab type */ protected static function typeFromDAV(string $type): string { switch ($type) { case DAVClient::TYPE_VEVENT: return Engine::TYPE_EVENT; case DAVClient::TYPE_VTODO: return Engine::TYPE_TASK; case DAVClient::TYPE_VCARD: // TODO what about groups return Engine::TYPE_CONTACT; default: throw new \Exception("Cannot map type '{$type}' from DAV"); } } + + /** + * Check if the folder should not be migrated + */ + private function shouldSkip($folder): bool + { + // When dealing with iRony DAV other user folders names have distinct names + // there's no other way to recognize them than by the name pattern. + // ;et's hope that users do not have personal folders with names starting with a bracket. + + if (preg_match('~\(.*\) .*~', $folder->name)) { + return true; + } + + if (str_starts_with($folder->name, 'shared » ')) { + return true; + } + + // TODO: Cyrus DAV shared folders + + return false; + } } diff --git a/src/app/DataMigrator/EWS.php b/src/app/DataMigrator/EWS.php index e71177ba..a4a41f4b 100644 --- a/src/app/DataMigrator/EWS.php +++ b/src/app/DataMigrator/EWS.php @@ -1,464 +1,470 @@ 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(): void { 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) - { + 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, Interface\ImporterInterface $importer): void { // Job processing - initialize environment $this->initEnv($this->engine->queue); // The folder is empty, we can stop here if (empty($folder->total)) { // TODO: Delete all existing items? return; } // Get items already imported // TODO: This might be slow and/or memory expensive, we should consider // whether storing list of imported items in some cache wouldn't be a better // solution. Of course, cache would not get changes in the destination account. $existing = $importer->getItems($folder); // Create X-MS-ID index for easier search in existing items // Note: For some objects we could use UID (events), but for some we don't have UID in Exchange. // Also because fetching extra properties here is problematic, we use X-MS-ID. $existingIndex = []; array_walk( $existing, function (&$item, $idx) use (&$existingIndex) { if (!empty($item['x-ms-id'])) { [$id, $changeKey] = explode('!', $item['x-ms-id']); $item['changeKey'] = $changeKey; $existingIndex[$id] = $idx; unset($item['x-ms-id']); } } ); $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, $existing, $existingIndex)) { $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, $existing, $existingIndex)) { $callback($item); } } } // TODO: Delete items that do not exist anymore? } /** * Fetching an item */ public function fetchItem(Item $item): void { // Job processing - initialize environment $this->initEnv($this->engine->queue); if ($driver = EWS\Item::factory($this, $item)) { $item->filename = $driver->fetchItem($item); } if (empty($item->filename)) { 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, $existing, $existingIndex): ?Item { $id = $item->getItemId()->toArray(); - $exists = false; + $exists = null; // Detect an existing item, skip if nothing changed if (isset($existingIndex[$id['Id']])) { $idx = $existingIndex[$id['Id']]; if ($existing[$idx]['changeKey'] == $id['ChangeKey']) { return null; } $exists = $existing[$idx]['href']; } $item = Item::fromArray([ 'id' => $id, 'class' => $item->getItemClass(), 'folder' => $folder, 'existing' => $exists, ]); // 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'), ]; } - */ + + // Options for testing + foreach (['httpClient', 'httpPlayback'] as $opt) { + if (($val = $this->engine->getOption($opt)) !== null) { + $options[$opt] = $val; + } + } 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 index 8816177c..e7602e91 100644 --- a/src/app/DataMigrator/EWS/Appointment.php +++ b/src/app/DataMigrator/EWS/Appointment.php @@ -1,100 +1,102 @@ 'calendar:UID']; return $request; } /** * Process event object */ protected function processItem(Type $item) { + // Initialize $this->itemId (for some unit tests) + $this->getUID($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(); - } + // Only appointments have UID property + $this->uid = $item->getUID(); - return $this->uid; + // This also sets $this->itemId; + return parent::getUID($item); } } diff --git a/src/app/DataMigrator/EWS/Contact.php b/src/app/DataMigrator/EWS/Contact.php index 9a1b7346..2df6fe8c 100644 --- a/src/app/DataMigrator/EWS/Contact.php +++ b/src/app/DataMigrator/EWS/Contact.php @@ -1,51 +1,86 @@ 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:'))); + // TODO: Use DAV\Vcard instead of string matching and replacement + $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 + // TODO: Use vCard v4 for anniversary and spouse? Roundcube works with what's below + + // Spouse: X-MS-SPOUSE;TYPE=N:Partner Name + if (preg_match('/(X-MS-SPOUSE[;:][^\r\n]+)/', $vcard, $matches)) { + $spouse = preg_replace('/^[^:]+:/', '', $matches[1]); + $vcard = str_replace($matches[1], "X-SPOUSE:{$spouse}", $vcard); + } + + // TODO: X-MS-ANNIVERSARY;VALUE=DATE:2020-11-12 + if (preg_match('/(X-MS-ANNIVERSARY[;:][^\r\n]+)/', $vcard, $matches)) { + $date = preg_replace('/^[^:]+:/', '', $matches[1]); + $vcard = str_replace($matches[1], "X-ANNIVERSARY:{$date}", $vcard); + } + + // Exchange 2010 for some reason do not include email addresses in the vCard + if (!preg_match('/\nEMAIL[^:]*:[^\r\n]+/', $vcard) && ($emailEntries = $item->getEmailAddresses())) { + $emails = []; + + // Note that the Entry property is either an array (multiple addresses) + // or an object (single address). Not a great API design. + if (!is_array($emailEntries->Entry)) { + $emailEntries->Entry = [$emailEntries->Entry]; + } + + foreach ($emailEntries->Entry as $email) { + $emails[] = 'EMAIL;TYPE=internet:' . strval($email); + } + + if ($emails) { + $vcard = str_replace("BEGIN:VCARD\r\n", "BEGIN:VCARD\r\n" . implode("\r\n", $emails) . "\r\n", $vcard); + } + } + return $vcard; } } diff --git a/src/app/DataMigrator/EWS/DistList.php b/src/app/DataMigrator/EWS/DistList.php index 5cab31dd..c569d7b8 100644 --- a/src/app/DataMigrator/EWS/DistList.php +++ b/src/app/DataMigrator/EWS/DistList.php @@ -1,61 +1,72 @@ [$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 + // 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)); - } + 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]; + } - $vcard .= $this->formatProp('MEMBER', "mailto:{$mailto}"); + foreach ($members->Member as $member) { + $mailbox = $member->getMailbox(); + $mailto = $mailbox->getEmailAddress(); + $name = $mailbox->getName(); + + 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 index a9691438..d523dcf5 100644 --- a/src/app/DataMigrator/EWS/Item.php +++ b/src/app/DataMigrator/EWS/Item.php @@ -1,174 +1,175 @@ 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()); + $ewsItem = $this->driver->api->getItem($itemId, $this->getItemRequest()); - $this->itemId = implode('!', $itemId); - - $uid = $this->getUID($item); + $uid = $this->getUID($ewsItem); \Log::debug("[EWS] Saving item {$uid}..."); // Apply type-specific format converters - $content = $this->processItem($item); + $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) { - // 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? - // It looks that ItemId on EWS consists of three parts separated with a slash, - // maybe using the last part as UID would be a solution - $this->uid = \App\Utils::uuidStr(); + // 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. + $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/app/DataMigrator/EWS/Task.php b/src/app/DataMigrator/EWS/Task.php index ca06b895..33b2a7a6 100644 --- a/src/app/DataMigrator/EWS/Task.php +++ b/src/app/DataMigrator/EWS/Task.php @@ -1,305 +1,319 @@ [$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 ($status = $item->getStatus()) { + $status = strtoupper($status); + $status_map = [ + 'COMPLETED' => 'COMPLETED', + 'INPROGRESS' => 'IN-PROGRESS', + 'DEFERRED' => 'X-DEFERRED', + 'NOTSTARTED' => 'X-NOTSTARTED', + 'WAITINGONOTHERS' => 'X-WAITINGFOROTHERS', + ]; + + if (isset($status_map[$status])) { + $data['STATUS'] = [$status_map[$status]]; + } + } + 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 index 651d077c..c238d307 100644 --- a/src/app/DataMigrator/Engine.php +++ b/src/app/DataMigrator/Engine.php @@ -1,340 +1,347 @@ 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']); + $queue_id = md5(strval($source) . strval($destination) . ($options['type'] ?? '')); // TODO: When running in 'sync' mode we shouldn't create a queue at all // 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/ tree $location = storage_path('export/') . $source->email; if (!file_exists($location)) { mkdir($location, 0740, true); } - $types = preg_split('/\s*,\s*/', strtolower($this->options['type'] ?? '')); + $types = preg_split('/\s*,\s*/', strtolower($options['type'] ?? '')); + + $this->debug("Fetching folders hierarchy..."); $folders = $this->exporter->getFolders($types); $count = 0; $async = empty($options['sync']); foreach ($folders as $folder) { $this->debug("Processing folder {$folder->fullname}..."); $folder->queueId = $queue_id; $folder->location = $location; if ($async) { // Dispatch the job (for async execution) Jobs\FolderJob::dispatch($folder); $count++; } else { $this->processFolder($folder); } } if ($count) { $this->queue->bumpJobsStarted($count); } if ($async) { $this->debug(sprintf('Done. %d %s created in queue: %s.', $count, Str::plural('job', $count), $queue_id)); } else { $this->debug(sprintf('Done (queue: %s).', $queue_id)); } } /** * Processing of a folder synchronization */ public function processFolder(Folder $folder): void { // Job processing - initialize environment if (!$this->queue) { $this->envFromQueue($folder->queueId); } // Create the folder on the destination server $this->importer->createFolder($folder); $count = 0; $async = empty($this->options['sync']); // Fetch items from the source $this->exporter->fetchItemList( $folder, function ($item_or_set) use (&$count, $async) { if ($async) { // Dispatch the job (for async execution) if ($item_or_set instanceof ItemSet) { Jobs\ItemSetJob::dispatch($item_or_set); } else { Jobs\ItemJob::dispatch($item_or_set); } $count++; } else { if ($item_or_set instanceof ItemSet) { $this->processItemSet($item_or_set); } else { $this->processItem($item_or_set); } } }, $this->importer ); if ($count) { $this->queue->bumpJobsStarted($count); } if ($async) { $this->queue->bumpJobsFinished(); } } /** * Processing of item synchronization */ public function processItem(Item $item): void { // Job processing - initialize environment if (!$this->queue) { $this->envFromQueue($item->folder->queueId); } $this->exporter->fetchItem($item); $this->importer->createItem($item); if (!empty($item->filename)) { unlink($item->filename); } if (empty($this->options['sync'])) { $this->queue->bumpJobsFinished(); } } /** * Processing of item-set synchronization */ public function processItemSet(ItemSet $set): void { // Job processing - initialize environment if (!$this->queue) { $this->envFromQueue($set->items[0]->folder->queueId); } // TODO: Some exporters, e.g. DAV, might optimize fetching multiple items in one go, // we'll need a new API to do that foreach ($set->items as $item) { $this->exporter->fetchItem($item); $this->importer->createItem($item); if (!empty($item->filename)) { unlink($item->filename); } } // TODO: We should probably also track number of items migrated if (empty($this->options['sync'])) { $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"); } } + /** + * Get migration option value. + */ + public function getOption(string $name) + { + return $this->options[$name] ?? null; + } + /** * 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': case 'tls': case 'ssl': $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/IMAP.php b/src/app/DataMigrator/IMAP.php index 0623cb61..14d105b2 100644 --- a/src/app/DataMigrator/IMAP.php +++ b/src/app/DataMigrator/IMAP.php @@ -1,377 +1,437 @@ account = $account; $this->engine = $engine; // TODO: Move this to self::authenticate()? $config = self::getConfig($account->username, $account->password, $account->uri); $this->imap = self::initIMAP($config); } /** * 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; - // TODO: When updating an email we have to just update flags - if ($item->filename) { $result = $this->imap->appendFromFile( - $mailbox, $item->filename, null, $item->data['flags'], $item->data['internaldate'], true + $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)); - // TODO: If message already exists in the destination account we should update 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'] + ) { + // Save the message content to a file + $location = $item->folder->location; + + if (!file_exists($location)) { + mkdir($location, 0740, true); + } - // Save the message content to a file - $location = $item->folder->location; + // TODO: What if parent folder not yet exists? + $location .= '/' . $uid . '.eml'; - if (!file_exists($location)) { - mkdir($location, 0740, true); - } + // TODO: We should consider streaming the message, it should be possible + // with append() and handlePartBody(), but I don't know if anyone tried that. - // TODO: What if parent folder not yet exists? - $location .= '/' . $uid . '.eml'; + $fp = fopen($location, 'w'); - // TODO: We should consider streaming the message, it should be possible - // with append() and handlePartBody(), but I don't know if anyone tried that. + if (!$fp) { + throw new \Exception("Failed to write to {$location}"); + } - $fp = fopen($location, 'w'); + $result = $this->imap->handlePartBody($mailbox, $uid, true, '', null, null, $fp); - if (!$fp) { - throw new \Exception("Failed to write to {$location}"); - } + if ($result === false) { + fclose($fp); + throw new \Exception("Failed to fetch IMAP message for {$mailbox}/{$uid}"); + } - $result = $this->imap->handlePartBody($mailbox, $uid, true, '', null, null, $fp); + $item->filename = $location; - if ($result === false) { fclose($fp); - throw new \Exception("Failed to fetch IMAP message for {$mailbox}/{$uid}"); } - $item->filename = $location; $item->data = [ 'flags' => $flags, 'internaldate' => $header->internaldate, ]; - - fclose($fp); } /** * 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) { - // TODO: If Message-Id header does not exist create it based on internaldate/From/Date + // 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 = false; - if (isset($existing[$message->messageID])) { - // TODO: Compare flags (compare message size, internaldate?) - continue; + $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 . ':' . $message->messageID, + '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)); - // TODO: Generate message ID if the header does not exist - $result[$message->messageID] = [ + // 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 { $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 { $uri = \parse_url($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, '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('imap.verify_peer'), 'verify_peer_name' => \config('imap.verify_peer'), 'verify_host' => \config('imap.verify_host') ], ], ], ]; 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 in_array($flag, $this->imap->flags); + 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 - // TODO: This should also skip other user folders - if (preg_match("/Shared Folders\/.*/", $folder)) { + 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/app/DataMigrator/Interface/Item.php b/src/app/DataMigrator/Interface/Item.php index d242eb76..061c494a 100644 --- a/src/app/DataMigrator/Interface/Item.php +++ b/src/app/DataMigrator/Interface/Item.php @@ -1,39 +1,44 @@ $value) { $obj->{$key} = $value; } return $obj; } } diff --git a/src/bootstrap/app.php b/src/bootstrap/app.php index a70ba683..0cbb38c5 100644 --- a/src/bootstrap/app.php +++ b/src/bootstrap/app.php @@ -1,83 +1,83 @@ singleton( Illuminate\Contracts\Http\Kernel::class, App\Http\Kernel::class ); $app->singleton( Illuminate\Contracts\Console\Kernel::class, App\Console\Kernel::class ); $app->singleton( Illuminate\Contracts\Debug\ExceptionHandler::class, App\Exceptions\Handler::class ); /* |-------------------------------------------------------------------------- | Return The Application |-------------------------------------------------------------------------- | | This script returns the application instance. The instance is given to | the calling script so we can separate the building of the instances | from the actual running of the application and sending responses. | */ return $app; diff --git a/src/tests/BackendsTrait.php b/src/tests/BackendsTrait.php new file mode 100644 index 00000000..277a8209 --- /dev/null +++ b/src/tests/BackendsTrait.php @@ -0,0 +1,395 @@ + 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, + ]; + + $config = array_merge($getConfig->invoke(null), $config); + + $this->clients[$clientId] = $initIMAP->invokeArgs(null, [$config]); + } + + 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}"); + } + + $uid = $imap->appendFromFile($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); + + 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/DAVTest.php b/src/tests/Feature/DataMigrator/DAVTest.php new file mode 100644 index 00000000..a174251c --- /dev/null +++ b/src/tests/Feature/DataMigrator/DAVTest.php @@ -0,0 +1,119 @@ +initAccount($src); + $this->initAccount($dst); + + // Add some items to the source account + $this->davAppend($src, 'Calendar', ['event/1.ics', 'event/2.ics'], Engine::TYPE_EVENT); + $this->davCreateFolder($src, 'DavDataMigrator', Engine::TYPE_CONTACT); + $this->davCreateFolder($src, 'DavDataMigrator/Test', Engine::TYPE_CONTACT); + $this->davAppend($src, 'DavDataMigrator/Test', ['contact/1.vcf', 'contact/2.vcf'], Engine::TYPE_CONTACT); + + // Clean up the destination folders structure + $this->davDeleteFolder($dst, 'DavDataMigrator', Engine::TYPE_CONTACT); + $this->davDeleteFolder($dst, 'DavDataMigrator/Test', Engine::TYPE_CONTACT); + + // Run the migration + $migrator = new Engine(); + $migrator->migrate($src, $dst, ['force' => true, 'sync' => true, 'type' => 'event,contact']); + + // Assert the destination account + $dstFolders = $this->davListFolders($dst, Engine::TYPE_CONTACT); + $this->assertContains('DavDataMigrator', $dstFolders); + $this->assertContains('DavDataMigrator/Test', $dstFolders); + + // Assert the migrated events + $dstObjects = $this->davList($dst, 'Calendar', Engine::TYPE_EVENT); + $events = \collect($dstObjects)->keyBy('uid')->all(); + $this->assertCount(2, $events); + $this->assertSame('Party', $events['abcdef']->summary); + $this->assertSame('Meeting', $events['123456']->summary); + + // Assert the migrated contacts and contact folders + $dstObjects = $this->davList($dst, 'DavDataMigrator/Test', Engine::TYPE_CONTACT); + $contacts = \collect($dstObjects)->keyBy('uid')->all(); + $this->assertCount(2, $contacts); + $this->assertSame('Jane Doe', $contacts['uid1']->fn); + $this->assertSame('Jack Strong', $contacts['uid2']->fn); + } + + /** + * Test DAV to DAV incremental migration run + * + * @group dav + * @depends testInitialMigration + */ + public function testIncrementalMigration(): void + { + $uri = \config('services.dav.uri'); + + $src = new Account(preg_replace('|^[a-z]+://|', 'dav://john%40kolab.org:simple123@', $uri)); + $dst = new Account(preg_replace('|^[a-z]+://|', 'dav://jack%40kolab.org:simple123@', $uri)); + + // Add an event and modify another one + $srcEvents = $this->davList($src, 'Calendar', Engine::TYPE_EVENT); + $this->davAppend($src, 'Calendar', ['event/3.ics', 'event/1.1.ics'], Engine::TYPE_EVENT); + + // Run the migration + $migrator = new Engine(); + $migrator->migrate($src, $dst, ['force' => true, 'sync' => true, 'type' => Engine::TYPE_EVENT]); + + // Assert the migrated events + $dstObjects = $this->davList($dst, 'Calendar', Engine::TYPE_EVENT); + $events = \collect($dstObjects)->keyBy('uid')->all(); + $this->assertCount(3, $events); + $this->assertSame('Party Update', $events['abcdef']->summary); + $this->assertSame('Meeting', $events['123456']->summary); + $this->assertSame('Test Summary', $events['aaa-aaa']->summary); + + // TODO: Assert that unmodified objects aren't migrated again + } +} diff --git a/src/tests/Feature/DataMigrator/EWSTest.php b/src/tests/Feature/DataMigrator/EWSTest.php new file mode 100644 index 00000000..b6173f97 --- /dev/null +++ b/src/tests/Feature/DataMigrator/EWSTest.php @@ -0,0 +1,208 @@ +davEmptyFolder($dst, 'Calendar', Engine::TYPE_EVENT); + $this->davEmptyFolder($dst, 'Tasks', Engine::TYPE_TASK); + $this->davDeleteFolder($dst, 'Kontakty', Engine::TYPE_CONTACT); + + $options = [ + 'force' => true, + 'sync' => true, + 'type' => 'event,contact,task', + // Mocking, use HTTP responses from the playback file + 'httpPlayback' => [ + 'mode' => 'playback', + 'recordLocation' => $this->buildPlaybackFile('initial'), + ], + ]; + + // Run the migration + $migrator = new Engine(); + $migrator->migrate($src, $dst, $options); + + // Assert the migrated events + $dstObjects = $this->davList($dst, 'Calendar', Engine::TYPE_EVENT); + $events = \collect($dstObjects)->keyBy('uid')->all(); + $this->assertCount(2, $events); + $this->assertSame('test to ms', $events[self::EVENT1]->summary); + $this->assertSame('test ms3', $events[self::EVENT2]->summary); + + // Assert the migrated tasks + // Note: Tasks do not have UID in Exchange so it's generated + $tasks = $this->davList($dst, 'Tasks', Engine::TYPE_TASK); + $this->assertCount(1, $tasks); + $this->assertSame('Nowe zadanie', $tasks[0]->summary); + + // Assert the migrated contacts and contact folders + // Note: Contacts do not have UID in Exchange so it's generated + $dstObjects = $this->davList($dst, 'Kontakty', Engine::TYPE_CONTACT); + $contacts = \collect($dstObjects)->keyBy('fn')->all(); + + $this->assertCount(3, $contacts); + $this->assertSame(null, $contacts['Nowy Kontakt']->kind); + $this->assertSame(null, $contacts['Test Surname']->kind); + $this->assertSame('group', $contacts['nowa lista']->kind); + } + + /** + * Test EWS to DAV incremental migration run + * + * @group ews dav + * @depends testInitialMigration + */ + public function testIncrementalMigration(): void + { + $uri = \config('services.dav.uri'); + + // TODO: Test OAuth2 authentication + + $src = new Account('ews://user%40outlook.com:pass@office.outlook.com'); + $dst = new Account(preg_replace('|^[a-z]+://|', 'dav://jack%40kolab.org:simple123@', $uri)); + + $options = [ + 'force' => true, + 'sync' => true, + 'type' => 'event,contact', + // Mocking, use HTTP responses from the playback file + 'httpPlayback' => [ + 'mode' => 'playback', + 'recordLocation' => $this->buildPlaybackFile('incremental'), + ], + ]; + + // We added a new contact and modified another one + // Run the migration + $migrator = new Engine(); + $migrator->migrate($src, $dst, $options); + + $dstObjects = $this->davList($dst, 'Calendar', Engine::TYPE_EVENT); + $events = \collect($dstObjects)->keyBy('uid')->all(); + $this->assertCount(2, $events); + $this->assertSame('test to ms', $events[self::EVENT1]->summary); + $this->assertSame('test ms3', $events[self::EVENT2]->summary); + + // Assert the migrated contacts + // Note: Contacts do not have UID in Exchange so it's generated + $dstObjects = $this->davList($dst, 'Kontakty', Engine::TYPE_CONTACT); + $contacts = \collect($dstObjects)->keyBy('fn')->all(); + + $this->assertCount(4, $contacts); + $this->assertSame(null, $contacts['Nowy Kontakt']->kind); + $this->assertSame(null, $contacts['Test Surname 1']->kind); + $this->assertSame(null, $contacts['Test New']->kind); + $this->assertSame('group', $contacts['nowa lista']->kind); + + // TODO: Assert that unmodified objects aren't migrated again, + // although our httpPlayback makes that it would fail otherwise. + + // TODO: Test migrating a task/event with attachments + } + + /** + * Test OAuth2 use + * + * @group ews dav + * @depends testIncrementalMigration + */ + public function testOAuth2(): void + { + // TODO: Test OAuth2 authentication with HTTP client fake + + $this->markTestIncomplete(); + } + + /** + * Build a playback file for EWS client to mock communication with Exchange. + */ + private function buildPlaybackFile($type) + { + /* The .json and .xml files were produced with the follwoing script + having saveState.json produced by httpPlayback in 'record' mode. + Separated and formatted for better understanding and modification ability. + + $file = file_get_contents('saveState.json'); + + $data = json_decode($file, true); + + foreach ($data as $idx => $record) { + file_put_contents("a{$idx}.xml", $record['body']); + unset($record['body']); + file_put_contents("$idx.json", json_encode($record, JSON_PRETTY_PRINT)); + exec("xmllint --format a{$idx}.xml >> $idx.xml"); + unlink("a{$idx}.xml"); + } + */ + + $dir = __DIR__ . '/../../data/ews/' . $type; + $playback = []; + + foreach (glob("{$dir}/[0-9].json") as $file) { + $id = basename($file, '.json'); + + $response = json_decode(file_get_contents($file), true); + $response['body'] = file_get_contents(str_replace('.json', '.xml', $file)); + + $playback[(int) $id] = $response; + } + + ksort($playback); + + file_put_contents($dir . '/' . 'saveState.json', json_encode($playback)); + + return $dir; + } +} diff --git a/src/tests/Feature/DataMigrator/EngineTest.php b/src/tests/Feature/DataMigrator/EngineTest.php new file mode 100644 index 00000000..1b8440a2 --- /dev/null +++ b/src/tests/Feature/DataMigrator/EngineTest.php @@ -0,0 +1,47 @@ +markTestIncomplete(); + } + + /** + * Test synchronous migration + */ + public function testSyncMigration(): void + { + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/DataMigrator/IMAPTest.php b/src/tests/Feature/DataMigrator/IMAPTest.php new file mode 100644 index 00000000..b0f67820 --- /dev/null +++ b/src/tests/Feature/DataMigrator/IMAPTest.php @@ -0,0 +1,143 @@ +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'); + $this->imapDeleteFolder($dst, 'ImapDataMigrator/Test'); + + // 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('imap.uri'); + + $src = new Account(str_replace('://', '://john%40kolab.org:simple123@', $uri)); + $dst = new Account(str_replace('://', '://jack%40kolab.org:simple123@', $uri)); + + // 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); + } +} diff --git a/src/tests/Unit/DataMigrator/AccountTest.php b/src/tests/Unit/DataMigrator/AccountTest.php new file mode 100644 index 00000000..827527da --- /dev/null +++ b/src/tests/Unit/DataMigrator/AccountTest.php @@ -0,0 +1,33 @@ +assertSame($uri, (string) $account); + $this->assertSame('user', $account->username); + $this->assertSame('pass@word', $account->password); + $this->assertSame('host.tld', $account->host); + $this->assertSame('imap', $account->scheme); + $this->assertSame(143, $account->port); + $this->assertSame('imap://host.tld:143', $account->uri); + $this->assertSame(['client_id' => '123', 'client_secret' => '456'], $account->params); + $this->assertNull($account->email); + $this->assertNull($account->loginas); + + // Invalid input + $this->expectException(\Exception::class); + $account = new Account(str_replace('imap://', '', $uri)); + } +} diff --git a/src/tests/Unit/DataMigrator/EWS/AppointmentTest.php b/src/tests/Unit/DataMigrator/EWS/AppointmentTest.php new file mode 100644 index 00000000..4ee95f26 --- /dev/null +++ b/src/tests/Unit/DataMigrator/EWS/AppointmentTest.php @@ -0,0 +1,112 @@ + 'test']); + $appointment = new EWS\Appointment($ews, $folder); + + $ical = file_get_contents(__DIR__ . '/../../../data/ews/event/1.ics'); + + // 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([ + 'MimeContent' => base64_encode($ical), + 'ItemId' => new Type\ItemIdType( + 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' + . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', + 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', + ), + 'UID' => '1F3C13D7E99642A75ABE23D50487B454-8FE68B2E68E1B348', + 'Subject' => 'test subject', + 'HasAttachments' => false, + 'IsAssociated' => false, + 'Start' => '2023-11-21T11:00:00Z', + 'End' => '2023-11-21T11:30:00Z', + 'LegacyFreeBusyStatus' => 'Tentative', + 'CalendarItemType' => 'Single', + 'Organizer' => [ + 'Mailbox' => [ + 'Name' => 'Aleksander Machniak', + 'EmailAddress' => 'test@kolab.org', + 'RoutingType' => 'SMTP', + 'MailboxType' => 'Contact', + ], + ], + 'RequiredAttendees' => (object) [ + 'Attendee' => [ + Type\AttendeeType::buildFromArray([ + 'Mailbox' => [ + 'Name' => 'Aleksander Machniak', + 'EmailAddress' => 'test@kolab.org', + 'RoutingType' => 'SMTP', + 'MailboxType' => 'Contact', + ], + 'ResponseType' => 'Unknown', + ]), + Type\AttendeeType::buildFromArray([ + 'Mailbox' => [ + 'Name' => 'Alec Machniak', + 'EmailAddress' => 'test@outlook.com', + 'RoutingType' => 'SMTP', + 'MailboxType' => 'Mailbox', + ], + 'ResponseType' => 'Unknown', + ]), + ], + ], + ]); + + // Convert the Exchange item into iCalendar + $ical = $this->invokeMethod($appointment, 'processItem', [$item]); + + // Parse the iCalendar output + $event = new Vevent(); + $this->invokeMethod($event, 'fromIcal', [$ical]); + + $msId = implode('!', $item->getItemId()->toArray()); + $this->assertSame($msId, $event->custom['X-MS-ID']); + $this->assertSame($item->getUID(), $event->uid); + $this->assertSame('test description', $event->description); + $this->assertSame('test subject', $event->summary); + $this->assertSame('CONFIRMED', $event->status); + $this->assertSame('PUBLIC', $event->class); + $this->assertSame('Microsoft Exchange Server 2010', $event->prodid); + $this->assertSame('2023-11-20T14:50:05+00:00', $event->dtstamp->getDateTime()->format('c')); + $this->assertSame('2023-11-21T12:00:00+01:00', $event->dtstart->getDateTime()->format('c')); + $this->assertSame('2023-11-21T12:30:00+01:00', $event->dtend->getDateTime()->format('c')); + + // Organizer/attendees + $this->assertSame('test@kolab.org', $event->organizer['email']); + $this->assertSame('Aleksander Machniak', $event->organizer['cn']); + $this->assertSame('ORGANIZER', $event->organizer['role']); + $this->assertSame('ACCEPTED', $event->organizer['partstat']); + $this->assertSame(false, $event->organizer['rsvp']); + + $this->assertCount(1, $event->attendees); + $this->assertSame('alec@outlook.com', $event->attendees[0]['email']); + $this->assertSame('Alec Machniak', $event->attendees[0]['cn']); + $this->assertSame('REQ-PARTICIPANT', $event->attendees[0]['role']); + $this->assertSame('NEEDS-ACTION', $event->attendees[0]['partstat']); + $this->assertSame(true, $event->attendees[0]['rsvp']); + } +} diff --git a/src/tests/Unit/DataMigrator/EWS/ContactTest.php b/src/tests/Unit/DataMigrator/EWS/ContactTest.php new file mode 100644 index 00000000..2aaa9a01 --- /dev/null +++ b/src/tests/Unit/DataMigrator/EWS/ContactTest.php @@ -0,0 +1,140 @@ + 'test']); + $contact = new EWS\Contact($ews, $folder); + + $vcard = file_get_contents(__DIR__ . '/../../../data/ews/contact/1.vcf'); + + // 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([ + 'MimeContent' => base64_encode($vcard), + 'ItemId' => new Type\ItemIdType( + 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' + . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', + 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', + ), + 'HasAttachments' => false, + 'LastModifiedTime' => '2024-07-15T11:17:39,701Z', + 'DisplayName' => 'Nowy Nazwisko', + 'GivenName' => 'Nowy', + 'Surname' => 'Nazwisko', + 'EmailAddresses' => (object) [ + 'Entry' => [ + Type\EmailAddressDictionaryEntryType::buildFromArray([ + 'Key' => 'EmailAddress1', + 'Name' => 'test1@outlook.com', + 'RoutingType' => 'SMTP', + 'MailboxType' => 'Contact', + '_value' => 'christian1@outlook.com', + ]), + Type\EmailAddressDictionaryEntryType::buildFromArray([ + 'Key' => 'EmailAddress2', + 'Name' => 'test2@outlook.com', + 'RoutingType' => 'SMTP', + 'MailboxType' => 'Contact', + '_value' => 'test2@outlook.com', + ]), + ], + ], + /* + + + + ContactPicture.jpg + image/jpeg + 2081 + 2024-07-15T11:17:38 + false + true + + + + category + + en-US + + + Mr + Nowy + Bartosz + Nazwisko + Jr. + Nowy Nazwisko + alec + + Company + + + Testowa + Warsaw + mazowickie + Poland + 00-001 + + + + + home123456 + 1234556679200 + + + + 2014-10-11T11:59:00Z + IT + Developer + Office Location + 2020-11-12T11:59:00Z + true + */ + ]); + + // Convert the Exchange item into vCard + $vcard = $this->invokeMethod($contact, 'processItem', [$item]); + + // Parse the vCard + $contact = new Vcard(); + $this->invokeMethod($contact, 'fromVcard', [$vcard]); + + $this->assertMatchesRegularExpression('/^[a-f0-9]{40}$/', $contact->uid); + $this->assertSame('PUBLIC', $contact->class); + $this->assertSame('Nowy Nazwisko', $contact->fn); + $this->assertSame(null, $contact->kind); + $this->assertSame('Microsoft Exchange', $contact->prodid); + $this->assertSame('2024-07-15T11:17:39,701Z', $contact->rev); + $this->assertSame('Notatki do kontaktu', $contact->note); + + // EWS Properties with special handling + $msId = implode('!', $item->getItemId()->toArray()); + $this->assertSame($msId, $contact->custom['X-MS-ID']); + $this->assertSame('Partner Name', $contact->custom['X-SPOUSE']); + $this->assertSame('2020-11-12', $contact->custom['X-ANNIVERSARY']); + $this->assertCount(2, $contact->email); + $this->assertSame('internet', $contact->email[0]['type']); + $this->assertSame('christian1@outlook.com', $contact->email[0]['email']); + $this->assertSame('internet', $contact->email[1]['type']); + $this->assertSame('test2@outlook.com', $contact->email[1]['email']); + } +} diff --git a/src/tests/Unit/DataMigrator/EWS/DistListTest.php b/src/tests/Unit/DataMigrator/EWS/DistListTest.php new file mode 100644 index 00000000..77366f11 --- /dev/null +++ b/src/tests/Unit/DataMigrator/EWS/DistListTest.php @@ -0,0 +1,89 @@ + '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' => [ + 'Name' => 'Alec', + 'EmailAddress' => 'alec@kolab.org', + 'RoutingType' => 'SMTP', + 'MailboxType' => 'OneOff', + ], + 'Status' => 'Normal', + ]), + Type\MemberType::buildFromArray([ + 'Key' => 'AAAAAIErH6S+oxAZnW4A3QEPVAIAAAGAYQBsAGUAYwBAAGEAbABlAGMALgBw' + . 'AGwAAABTAE0AVABQAAAAYQBsAGUAYwBAAGEAbABlAGMALgBwAGwAAAB=', + 'Mailbox' => [ + 'Name' => 'Christian', + 'EmailAddress' => 'christian@kolab.org', + 'RoutingType' => 'SMTP', + 'MailboxType' => 'OneOff', + ], + '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', + ]; + $this->assertSame($members, $distlist->members); + } +} diff --git a/src/tests/Unit/DataMigrator/EWS/TaskTest.php b/src/tests/Unit/DataMigrator/EWS/TaskTest.php new file mode 100644 index 00000000..e7f118a7 --- /dev/null +++ b/src/tests/Unit/DataMigrator/EWS/TaskTest.php @@ -0,0 +1,158 @@ +source = $source; + $engine->destination = $destination; + $ews = new EWS($source, $engine); + $folder = Folder::fromArray(['id' => 'test']); + $task = new EWS\Task($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 it in XML. + + $html = '' + . '
task notes
'; + + $item = Type\TaskType::buildFromArray([ + 'ItemId' => new Type\ItemIdType( + 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' + . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', + 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', + ), + 'ItemClass' => 'IPM.Task', + 'Subject' => 'Nowe zadanie', + 'LastModifiedTime' => '2024-06-27T13:44:32Z', + 'Sensitivity' => 'Private', + // TODO: Looks like EWS has Body->IsTruncated property, but is it relevant? + 'Body' => new Type\BodyType($html, 'HTML'), + 'Importance' => 'High', + 'DateTimeCreated' => '2024-06-27T08:58:05Z', + 'ReminderDueBy' => '2024-07-17T07:00:00Z', + 'ReminderIsSet' => true, + 'ReminderNextTime' => '2024-07-17T07:00:00Z', + 'ReminderMinutesBeforeStart' => '0', + 'DueDate' => '2024-06-26T22:00:00Z', + 'IsComplete' => false, + 'IsRecurring' => true, + 'Owner' => 'Alec Machniak', + 'PercentComplete' => '10', + 'Recurrence' => Type\TaskRecurrenceType::buildFromArray([ + 'WeeklyRecurrence' => [ + 'Interval' => '1', + 'DaysOfWeek' => 'Thursday', + 'FirstDayOfWeek' => 'Sunday', + ], + 'NoEndRecurrence' => [ + 'StartDate' => '2024-06-27Z', + ], + ]), + 'Status' => 'NotStarted', + 'ChangeCount' => '2', + /* + + + + testdisk.log + application/octet-stream + 299368b3-06e4-42df-959e-d428046f55e6 + 249 + 2024-07-16T12:13:58 + false + false + + + 2024-06-27T08:58:05Z + 3041 + + Kategoria Niebieski + + false + false + false + false + false + 2024-06-27T08:58:05Z + + true + en-US + + false + false + false + true + true + true + true + + Alec Machniak + 2024-07-16T12:14:38Z + false + + + NotFlagged + + AQEAAAAAAAESAQAAAmjiC08AAAAA + + 1 + Not Started + */ + ]); + + // Convert the Exchange item into iCalendar + $ical = $this->invokeMethod($task, 'processItem', [$item]); + + // Parse the iCalendar output + $task = new Vtodo(); + $this->invokeMethod($task, 'fromIcal', [$ical]); + + $msId = implode('!', $item->getItemId()->toArray()); + $this->assertSame($msId, $task->custom['X-MS-ID']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{40}$/', $task->uid); + $this->assertSame('Nowe zadanie', $task->summary); + $this->assertSame($html, $task->description); + $this->assertSame('Kolab EWS Data Migrator', $task->prodid); + $this->assertSame('2', $task->sequence); + $this->assertSame('9', $task->priority); + $this->assertSame('PRIVATE', $task->class); + $this->assertSame(10, $task->percentComplete); + $this->assertSame('X-NOTSTARTED', $task->status); + $this->assertSame('2024-06-27T13:44:32+00:00', $task->dtstamp->getDateTime()->format('c')); + $this->assertSame('2024-06-27T08:58:05+00:00', $task->created->getDateTime()->format('c')); + $this->assertSame('2024-06-26T22:00:00+00:00', $task->due->getDateTime()->format('c')); + $this->assertSame('test@kolab.org', $task->organizer['email']); + + $this->assertSame('WEEKLY', $task->rrule['freq']); + $this->assertSame('1', $task->rrule['interval']); + $this->assertSame('TH', $task->rrule['byday']); + $this->assertSame('SU', $task->rrule['wkst']); + + // TODO: Reminder + } + + /** + * Test processing Recurrence property + */ + public function testProcessItemRecurrence(): void + { + $this->markTestIncomplete(); + } +} diff --git a/src/tests/data/contact/1.vcf b/src/tests/data/contact/1.vcf new file mode 100644 index 00000000..e0469c11 --- /dev/null +++ b/src/tests/data/contact/1.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +UID:uid1 +FN:Jane Doe +N:Doe;Jane;J.;; +END:VCARD diff --git a/src/tests/data/contact/2.vcf b/src/tests/data/contact/2.vcf new file mode 100644 index 00000000..c67a2f80 --- /dev/null +++ b/src/tests/data/contact/2.vcf @@ -0,0 +1,6 @@ +BEGIN:VCARD +VERSION:3.0 +UID:uid2 +FN:Jack Strong +N:Strong;Jack;;; +END:VCARD diff --git a/src/tests/data/event/1.1.ics b/src/tests/data/event/1.1.ics new file mode 100644 index 00000000..0f6fc72b --- /dev/null +++ b/src/tests/data/event/1.1.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test/test//NONSGML v1.0//EN +BEGIN:VEVENT +UID:abcdef +DTSTAMP:19970714T180000Z +ORGANIZER;CN=John Doe:MAILTO:john@kolab.org +DTSTART:20240714T170000Z +DTEND:20240714T180000Z +SUMMARY:Party Update +END:VEVENT +END:VCALENDAR diff --git a/src/tests/data/event/1.ics b/src/tests/data/event/1.ics new file mode 100644 index 00000000..5b814375 --- /dev/null +++ b/src/tests/data/event/1.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test/test//NONSGML v1.0//EN +BEGIN:VEVENT +UID:abcdef +DTSTAMP:19970714T170000Z +ORGANIZER;CN=John Doe:MAILTO:john@kolab.org +DTSTART:20240714T170000Z +DTEND:20240714T180000Z +SUMMARY:Party +END:VEVENT +END:VCALENDAR diff --git a/src/tests/data/event/2.ics b/src/tests/data/event/2.ics new file mode 100644 index 00000000..1914dbb9 --- /dev/null +++ b/src/tests/data/event/2.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test/test//NONSGML v1.0//EN +BEGIN:VEVENT +UID:123456 +DTSTAMP:19970714T170000Z +ORGANIZER;CN=John Doe:MAILTO:john@kolab.org +DTSTART:20240715T170000Z +DTEND:20240715T180000Z +SUMMARY:Meeting +END:VEVENT +END:VCALENDAR diff --git a/src/tests/data/event/3.ics b/src/tests/data/event/3.ics new file mode 100644 index 00000000..24959b2c --- /dev/null +++ b/src/tests/data/event/3.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//test/test//NONSGML v1.0//EN +BEGIN:VEVENT +UID:aaa-aaa +DTSTAMP:19970714T170000Z +ORGANIZER;CN=John Doe:MAILTO:john@kolab.org +DTSTART:20240715T170000Z +DTEND:20240715T180000Z +SUMMARY:Test Summary +END:VEVENT +END:VCALENDAR diff --git a/src/tests/data/ews/contact/1.vcf b/src/tests/data/ews/contact/1.vcf new file mode 100644 index 00000000..c749a006 --- /dev/null +++ b/src/tests/data/ews/contact/1.vcf @@ -0,0 +1,59 @@ +BEGIN:VCARD +PROFILE:VCARD +VERSION:3.0 +MAILER:Microsoft Exchange +PRODID:Microsoft Exchange +FN:Nowy Nazwisko +N:Nazwisko;Nowy;Bartosz;Mr;Jr. +PHOTO;TYPE=JPEG;ENCODING=B:/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgID + AwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2w + BDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ + EBAQEBAQEBAQEBD/wAARCAA6ADoDAREAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAw + QFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0Kx + wRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3 + R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW + 19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQ + oL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAV + YnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eH + l6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna + 4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD87K+4PlwoAKACgAoAKACgAoAKACgD2D + 9mj9l/4iftQ+M5PC/gpIbSx09Fm1bV7oH7PYRMSFzjl5GwwSMctgnhQzDmxOKhhY80/kjahQlX + laJ992v/AARx+F66WIr34w+KZdR2YM8Vlbxw7vXyjubHtv8AxryHnFS+kVY9H+zoW+I+Kf2s/w + BjD4hfsqata3Gq3sWveFdVlaHTtctoTGplAJ8meMk+VLtBYDJVgCVJwwX1MJjYYpaaNdDhxGGl + QeuqPnquw5goAKACgAoA/ar/AIJa+ENF0D9kzRfEGnwRi98T6lqN9fyAfOzxXL2yKT6COBSB/t + E9zXzGaTcsQ0+lj3MDFKimup9eV5x2Hkn7Vnwhk+OXwD8X/Dqxsbe51O/sTLpQmcRhb6JhJAd5 + +5ll2k/3WYdCa6MLW9hWjN7GNen7Wm4n4g/HX9nr4h/s665p/hr4lJpNvqepWzXkVrZ6jHdOkO + 7aHcJ9wMQwXPXY3pX1NDEQxCcobHhVaMqLtI8yrcyCgAoAKAP0U/4Jh/tg+E/AFhc/AL4n63Bp + Njd3rXvh7UruQJbxyyY821kc8RhmAdGOFLM4JBK58bM8HKo/bU1fuelgcQoL2c/kfqfFLHPGs0 + MiujgMrKwIYHoQR1rwD1jE8deM/D/w78H6x448VXy2mkaHZS315MeqxxqSQB3Y9FHUkgDrVwhK + pJQjuyZSUIuT2R/Pn8dPi74g+OvxV8Q/FDxGStxrV0ZIbfduW1tlG2GBfZI1Vc9yCTyTX2FCjG + hTVOPQ+dq1HVm5s4OtTMKACgAoA6/4R/DPxD8Y/iT4e+GfhaLdqOv3qWqOVysKdZJnA/gjjDu3 + sprOrVjRg6ktkXTg6klBdT+hP4c+BNA+GHgTQvh/4YgaLSvD9hFYWoY5ZkRcbmPdmOWJ7ljXx1 + So6s3OW7Po4QUIqK6H54f8FaP2kMDTv2bfC9/y3lat4maNu33rW1b9JmB/6Yn1r2cpw29eXojz + cwrf8ul8z8zK9w8sKACgAoAKAP0F/wCCYl3+z/8ACqHXPjB8Vvir4R0bxHf50nSLC/1KKOe1tA + QZpmQnKmRgqrnB2xt2evHzNVqtqdOLa3Z6OBdOnec2rn298Qf26f2Z/B/gnWvE+lfFvwv4gvtN + spJ7XStP1KOW4vZgP3cKKuT8zFQT0AJJ4FeXTwNeclFxaO+eKpRi2nc/Dnx1408Q/EbxjrPjvx + Xem71fXb2W+vJT0MjsSQo7KOAo6BQAOlfUwhGnFQjsjwZyc5OT3ZhVZIUAFABQAUAFABQAUAFA + BQAUAfRn/CK+GP8AoXNL/wDAOP8Awr7/AOpYb/n3H7kfkn9pY3/n9L/wJ/5h/wAIr4Y/6FzS/w + DwDj/wo+pYb/n3H7kH9pY3/n9L/wACf+Yf8Ir4Y/6FzS//AADj/wAKPqWG/wCfcfuQf2ljf+f0 + v/An/mH/AAivhj/oXNL/APAOP/Cj6lhv+fcfuQf2ljf+f0v/AAJ/5h/wivhj/oXNL/8AAOP/AA + o+pYb/AJ9x+5B/aWN/5/S/8Cf+Yf8ACK+GP+hc0v8A8A4/8KPqWG/59x+5B/aWN/5/S/8AAn/m + H/CK+GP+hc0v/wAA4/8ACj6lhv8An3H7kH9pY3/n9L/wJ/5h/wAIr4Y/6FzS/wDwDj/wo+pYb/ + n3H7kH9pY3/n9L/wACf+Z//9k= +NOTE:Notatki do kontaktu +ORG:Company;IT +CLASS:PUBLIC +ADR;TYPE=WORK:;Office Location;;;;; +ADR;TYPE=HOME:;;Testowa;Warsaw;mazowickie;00-001;Poland +LABEL;TYPE=HOME:Testowa\n00-001 Warsaw mazowickie\nPoland +TEL;TYPE=HOME:home123456 +TEL;TYPE=CELL:1234556679200 +X-MS-SPOUSE;TYPE=N:Partner Name +NICKNAME:alec +TITLE:Developer +URL;TYPE=HOME:www.alec.pl +CATEGORIES:category +BDAY;VALUE=DATE:2014-10-11 +REV;VALUE=DATE-TIME:2024-07-15T11:17:39,701Z +X-MS-ANNIVERSARY;VALUE=DATE:2020-11-12 +END:VCARD diff --git a/src/tests/data/ews/event/1.ics b/src/tests/data/ews/event/1.ics new file mode 100644 index 00000000..35472b22 --- /dev/null +++ b/src/tests/data/ews/event/1.ics @@ -0,0 +1,49 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +PRODID:Microsoft Exchange Server 2010 +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Central European Standard Time +BEGIN:STANDARD +DTSTART:16010101T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010101T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +ORGANIZER;CN=Aleksander Machniak:mailto:test@kolab.org +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=Aleksander + Machniak:mailto:test@kolab.org +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE;CN=Alec Machn + iak:mailto:alec@outlook.com +DESCRIPTION:test description +UID:1F3C13D7E99642A75ABE23D50487B454-8FE68B2E68E1B348 +SUMMARY:test subject +DTSTART;TZID=Central European Standard Time:20231121T120000 +DTEND;TZID=Central European Standard Time:20231121T123000 +CLASS:PUBLIC +PRIORITY:5 +DTSTAMP:20231120T145005Z +TRANSP:OPAQUE +STATUS:CONFIRMED +SEQUENCE:0 +X-MICROSOFT-CDO-APPT-SEQUENCE:0 +X-MICROSOFT-CDO-OWNERAPPTID:0 +X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE +X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY +X-MICROSOFT-CDO-ALLDAYEVENT:FALSE +X-MICROSOFT-CDO-IMPORTANCE:1 +X-MICROSOFT-CDO-INSTTYPE:0 +X-MICROSOFT-DONOTFORWARDMEETING:FALSE +X-MICROSOFT-DISALLOW-COUNTER:FALSE +X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT +X-MICROSOFT-ISRESPONSEREQUESTED:TRUE +END:VEVENT +END:VCALENDAR diff --git a/src/tests/data/ews/incremental/0.json b/src/tests/data/ews/incremental/0.json new file mode 100644 index 00000000..0da82daa --- /dev/null +++ b/src/tests/data/ews/incremental/0.json @@ -0,0 +1,96 @@ +{ + "error": false, + "statusCode": 200, + "headers": { + "Cache-Control": [ + "private" + ], + "Transfer-Encoding": [ + "chunked" + ], + "Content-Type": [ + "text\/xml; charset=utf-8" + ], + "Server": [ + "Microsoft-HTTPAPI\/2.0" + ], + "X-NanoProxy": [ + "1,1" + ], + "X-AspNet-Version": [ + "4.0.30319" + ], + "Request-Id": [ + "a4ce6838-81ac-65a7-4759-3d9294738779" + ], + "X-CalculatedFETarget": [ + "GV0P278CU004.internal.outlook.com" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000,h3-29=\":443\";ma=2592000" + ], + "MS-CV": [ + "OGjOpKyBp2VHWT2SlHOHeQ.1.1" + ], + "Restrict-Access-Confirm": [ + "1" + ], + "X-BackEndHttpStatus": [ + "200,200" + ], + "X-BEServer": [ + "GVAP278MB0615" + ], + "X-BeSku": [ + "WCS6" + ], + "X-CalculatedBETarget": [ + "GVAP278MB0615.CHEP278.PROD.OUTLOOK.COM" + ], + "X-DiagInfo": [ + "GVAP278MB0615" + ], + "x-EwsHandler": [ + "FindFolder" + ], + "X-FEEFZInfo": [ + "GVA" + ], + "X-FEProxyInfo": [ + "GV0P278CA0060" + ], + "X-FEServer": [ + "WA0P291CA0004" + ], + "x-ms-appId": [ + "6ce1e52b-cd1d-4663-abc3-368fba0bfae7" + ], + "X-Proxy-BackendServerStatus": [ + "200" + ], + "X-Proxy-RoutingCorrectness": [ + "1" + ], + "X-RUM-NotUpdateQueriedPath": [ + "1" + ], + "X-RUM-NotUpdateQueriedDbCopy": [ + "1" + ], + "X-RUM-Validated": [ + "1" + ], + "X-FirstHopCafeEFZ": [ + "WAW" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubDomains" + ], + "Set-Cookie": [ + "exchangecookie=faf9d40d8bc14d53a013ecd1336e35b5; expires=Fri, 11-Jul-2025 12:51:36 GMT; path=\/; secure; HttpOnly" + ], + "Date": [ + "Thu, 11 Jul 2024 12:51:36 GMT" + ] + } +} \ No newline at end of file diff --git a/src/tests/data/ews/incremental/0.xml b/src/tests/data/ews/incremental/0.xml new file mode 100644 index 00000000..35296240 --- /dev/null +++ b/src/tests/data/ews/incremental/0.xml @@ -0,0 +1,7139 @@ + + + + + + + + + + NoError + + + + + + IPF.Note + AllCategorizedItems + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + AllContacts + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + AllContactsExtended + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + AllItems + 230 + 0 + + true + true + true + true + true + true + true + + 110 + + + + + IPF.Note + AllPersonMetadata + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Task + AllTodoTasks + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ApplicationDataRoot + 0 + 56 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 00000002-0000-0ff1-ce00-000000000000 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HxNotificationSettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 00000006-0000-0ff1-ce00-000000000000 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GlobalSearchHistory + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 13937bba-652e-4c46-b222-3003f4d1ff97 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContextDataFast + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContextMetadata + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserInteractionsFast + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 1caee58f-eb14-4a6b-9339-1fe2ddf6692b + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Recent + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Settings + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 1e70cd27-4707-4589-8ec5-9bd20c472a46 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 224fffa9-a547-4c78-ab95-3aacd66c3e99 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FeaturesManagementCollection_1 + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 32d4b5e5-7d33-4e7f-b073-f8cffbbb47a1 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + outlookfavorites + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 33b68b23-a6c2-4684-99a0-fa3832792226 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AggregationV2BackfillStateCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + RelevanceDailyAggregation + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 34421fbe-f100-4e5b-9c46-2fea25aa7b88 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Contacts + 6 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 35d54a08-36c9-4847-9018-93934c62740c + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PeoplePredictions.FeedbackEntries + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PeoplePredictions.RequestEntries + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PeoplePredictions.UserProfile + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3873c4f1-2162-4ddb-96e6-232e4eddeedc + 0 + 4 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SEED_PeopleIntegrityCheckerStateCollection_v2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SEED_ScdIntegrityCheckerStateCollection_v2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 394866fc-eedb-4f01-8536-3ff84b16be2a + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AssociationBackfillStatus + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Favorites + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InsightInstancesActions + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3b2e5a14-128d-48aa-b581-482aac616d32 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3c896ded-22c5-450f-91f6-3d1ef0848f6e + 0 + 42 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActionExecutions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActivitiesDaily + 58 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActivitiesWeekly + 8 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AfterHoursEmailImpact + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AutomaticRepliesHistory + 105 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ChatsInterruptionStatistics + 49 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ComputeLogs + 38 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CumulativeNetworkSnapshot + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CumulativeOutOfOfficeClustering + 106 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyActivityTimeBins + 56 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyAppointments + 85 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyInteractions + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyNetworkSnapshot + 51 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DelayedEmail + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DeliverCardEvents + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DetailedMeetings + 51 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmailActionStatistics + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmailGrazerData + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmfResponses + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FoodForThoughtRanking + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HeterogeneousItems + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ImportantContact + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InsightsEngagement + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + MailActivityCompactSignalBatch + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ManagementOperationExecutionRecords + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + MeetingActionStatistics + 9 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + MeetingResponseActivity + 6 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutcomeStates + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutOfOffice + 44 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ProgramMetrics + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SegmentsStateRecord + 18 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SentMailLogs + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SigsChatsAndCallsCompactSignalBatch + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UnifiedCardEngagements + 30 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserConfiguration + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserProgramInstances + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyEmbeddingBasedUserTimePrediction + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyInteractions + 27 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyOutOfOfficeAndWorkingDay + 28 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyOutOfRoutineMeetingScorePDF + 30 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyOutOfRoutineMeetingTimes + 61 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyWorkplaceActivityEmbedding + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3e3c8645-20a6-4c6e-8355-c34cbbe04585 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PintSigsMsGraphState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 42c2c67f-2760-4f42-b0c4-ef5c3854a0c2 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CollabObjects + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 43375d74-c6a5-4d4e-a0a3-de139860ea75 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ElcArchiveProcessorStatsSdsCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OverallArchivingStatsSdsCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 441509e5-a165-4363-8ee7-bcf0b7d26739 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GenericWorkflowProcessor.SessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Idf + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfMeeting + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 4787c7ff-7cea-43db-8d0d-919f15c6354b + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + VivaSales + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 48af08dc-f6d2-435f-b2a7-069abd99c086 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InsightsProvidersSettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 49499048-0129-47f5-b95e-f9d315b861a6 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookAccountCloudSettings + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookCloudSettings + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 497effe9-df71-4043-a8bb-14cf78c4b63b + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EACUserPreferences + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 4e445925-163e-42ca-b801-9073bfa46d17 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NewsSubscriptionSourcesv2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 59cc317d-5453-4fcd-ab98-60552b158926 + 0 + 6 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AlgorithmRunStatus + 9 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CDDS_SuggestionCheckpoint + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContactFeedback + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContactMergeSuggestions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HiddenDuplicates + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HideSettings + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 644c1b11-f63f-45fa-826b-a9d2801db711 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + _PolicyContainer + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + YWxlY0BhcGhlbGVpYWl0Lm9ubWljcm9zb2Z0LmNvbQ==_LabelFile + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + YWxlY0BhcGhlbGVpYWl0Lm9ubWljcm9zb2Z0LmNvbQ==_PolicyContainer + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 66a88757-258c-4c72-893c-3e8bed4d6899 + 0 + 31 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ScopeEntities + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Apps + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.CalendarEvents + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.CollaborationNetworkEntities + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.ContentDomain + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EmailEntities + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EmailTokens + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Bookmarks + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Links + 4 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Locations + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Qnas + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.FreshHistory + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.FreshSharePointFiles + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.FreshSharePointSites + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.GroupsRoomsMiscIndex + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Notes + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.People + 11 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.PeopleCache + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.PeopleIndex + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.RecentEmailItems + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.RecommendationsService + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.RecommendationsServiceProcessor + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Scope + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SearchHistory.Main + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SearchHistoryState + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SharePointDocuments + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SkillEntities + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.TeamsAndChannels + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.TeamsEntities + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Topics + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 6cd5135b-7a07-43ec-acc2-b5e27dc628cb + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + currentuser + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + neighbors + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OfficeFeedSdsCacheCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 766ef332-38e5-4cb4-920c-baa478e39fd9 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CrawlerExecutionInfoCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7a5fbd1c-3e6d-461a-9075-83049393b3a7 + 0 + 7 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AcronymAggregationState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AcronymService.SessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ConceptAggregationSessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NicknamesAllInbox + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SimpleAcronymsIndex + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SimpleAcronymsIndexElysium + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SimpleUserContextIndex + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7aadb841-2368-4d24-a373-94d0ba1f077a + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + outlookspaces + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7ae974c5-1af7-4923-af3a-fb1fd14dcb7e + 0 + 9 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AppBarTiles + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FloodgateSurvey + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GetStartedStore + 20 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + lightning + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LightningSharedStore + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LightningStore + 67 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookEndpointDataCollection + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookOptionsV3 + 7 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WhatsNewStore + 97 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7e02887d-df1d-45a4-af52-dcee2e585eee + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserEnablementStatus + 8 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 80723a00-368e-4d64-8281-210e49e593a8 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActivityFeed_201905 + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 82d8ab62-be52-a567-14ea-1616c4ee06c4 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActorAnalyticsProcessorState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 8a18aa92-0a1e-4e06-abd0-e118fa4787b1 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GraphMetaColl + 26 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 8c22b648-ee54-4ece-a4ca-3015b6d24f8e + 0 + 20 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DIF + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Images + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_contactsync + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_meetinginsightssetting + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_officegraphvisibilitysetting + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceaad + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceconnector + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceemailaddressdisplaynames + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcemanagerchain + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcemsgraph + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcenamepronunciation + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcenone + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceorganizationheadcount + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcephotoetag + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceupa + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceuserdefined + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_userdefinedsettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SQI_FI + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + view_profilev2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + view_settingsv2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 94c63fef-13a3-47bc-8074-75af8c65887a + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DelveCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 97cb1f73-50df-47d1-8fb0-0271f2728514 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CleanupProcessorState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + a3883eba-fbe9-48bd-9ed3-dca3e0e84250 + 0 + 4 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AggregatedLinks + 9 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmailsIndexV1 + 65 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LinkFiltersMetadataIndex + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LinksIndex + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + a6061a71-f529-4ca2-bc9d-c8085ecbf3ca + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EdgeSettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + adcd8967-0799-4ec0-bfc8-dcf85fd84767 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CssProcessingState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ae8e128e-080f-4086-b0e3-4c19301ada69 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Scheduling + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + b669c6ea-1adf-453f-b8bc-6d526592b419 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FocusedInboxMailboxData + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NudgeCandidates + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ScenarioTracking + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + b6e65498-8dce-4d57-8de9-1269fcfcf6ce + 0 + 6 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + KeyphraseRelationshipsSessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserDocKpeStats + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserDocWithKpes + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserKpes + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserKpeState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserStatistics + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + c9224372-5534-42cb-a48b-8db4f4a3892e + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TeamsCalendar.SSMeetingPropsSync + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + c94eb245-f5ab-4fea-a534-841091469193 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ic3usersettings + 13 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d297b9ff-5d5b-4bce-8ed4-eedb1431bf61 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NucleusProperties + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d396de1f-10d4-4023-aae2-5bb3d724ba9a + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FolderGroups-DefaultCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + settings + 6 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TasksDataMarker_v9 + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d71dfe16-1070-48f3-bd3a-c3ec919d34e7 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TxpAutoblocking + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TxpUserSettings + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d8cbf452-c2df-4756-95a5-8aa23fedc3ce + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserMigrationDetails + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + e69932cd-f814-4087-8ab1-5ab3f1ad18eb + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + e7762c80-392b-4278-80fa-a8fda80b129c + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CMUState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + e7f049f2-9dc5-4301-88a2-121b268ad42f + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Common + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SharingSuggestion + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + f08262d6-bf02-4e1e-bdef-e95e2f0a940f + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GlowCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + f164ee43-46e5-4f7c-b5ff-2d1abcd4ce7e + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PersonalProfileVector_AnalyzerStates + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + f3f64fec-ecee-4e9a-9eed-a36a5e421300 + 0 + 10 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfEmailPoi + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfInboxPoi + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfMeetingPoi + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InferredLanguage + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SlpJobs + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechNGrams + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechTrimmedLms + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechUserDocumentNGrams + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechUserDocumentSentences + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechUserDocumentsHistory + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + BrokerSubscriptions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + BulkActions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + CalendarSharingCacheCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Common Views + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ComplianceMetadata + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Connectors + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + ConnectorConfigurations + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.CrawlerData + CrawlerData + 35 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Deferred Action + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.DlpPolicyEvaluation + DlpPolicyEvaluation + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Document Centric Conversations + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ExchangeODataSyncData + 199 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ExchangeSyncData + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + AirSync-iPhone-J97G80P7O916504POMJIHAJE70 + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + AirSync-TestActiveSyncConnectivity-430847304 + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Hx-Outlook-B57C063B9A6C46ECAD4C159C1E161B14 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + OutlookServiceActionQueue + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Policy + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Finder + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + OwaFV15.1AllFocusedAQMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NABkNDgALgAAA722UNhKUbZNrFYGsDU7Na0BACzZqOGSdeVFg3+lsLwmpuUAAAIBDAAAAA== + 101 + 0 + + true + true + true + true + true + true + true + + 95 + + + + + IPF.Note + OwaFV15.1AllOtherAQMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NABkNDgALgAAA722UNhKUbZNrFYGsDU7Na0BACzZqOGSdeVFg3+lsLwmpuUAAAIBDAAAAA== + 15 + 0 + + true + true + true + true + true + true + true + + 15 + + + + + IPF.Task + Folder Memberships + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Task + Flagged Emails + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Folder nadrzędny magazynu informacji + 0 + 19 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Archiwum + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Configuration + Conversation Action Settings + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Journal + Dziennik + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Elementy usunięte + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Elementy wysłane + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.EventCheckPoints + EventCheckPoints + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Contact + ExternalContacts + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF + Folder główny usługi Yammer + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + Feeds + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + Inbound + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + Outbound + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Historia konwersacji + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.SkypeTeams.Message + Czat zespołu + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Appointment + Calendar + 2 + 2 + + true + true + true + true + true + true + true + + + + + + IPF.Appointment + United States holidays + 97 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Appointment.Birthday + Urodziny + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact + Kontakty + 4 + 7 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.MOC.QuickContacts + {06967759-274D-40B2-A3EB-D7F9E73727D7} + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.MOC.ImContactList + {A9E2BC46-B3A0-4243-B315-60D991004455} + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.Company + Firmy + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.GalContacts + GAL Contacts + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.OrganizationalContacts + Organizational Contacts + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.PeopleCentricConversationBuddies + PeopleCentricConversation Buddies + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.RecipientCache + Recipient Cache + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.Contact.MeContact + MeContact + 0 + 0 + + true + true + true + true + true + true + true + + + + + + IPF.StickyNote + Notatki + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Files + Pliki + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Skrzynka nadawcza + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Skrzynka odbiorcza + 116 + 0 + + true + true + true + true + true + true + true + + 110 + + + + + IPF.Note + Wersje robocze + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Wiadomości-śmieci + 0 + 0 + + true + true + true + true + true + true + true + + 3dbb5f39-dc76-4916-9db3-fa9191760a55 + 0 + + + + + IPF.Task + Tasks + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Freebusy Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.FreeBusyCache + FreeBusyLocalCache + 2 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + FreeBusyLocalCacheSubscriptions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + GraphFilesAndWorkingSetSearchFolder + 64 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + GraphStore + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + GraphRelations + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Inference + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + MailboxAssociations + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + MailboxMoveHistory + 4 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + MailboxReplicationService Index + 4 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + MergedViewFolderCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + MessageIngestion + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + Yammer + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Moje kontakty + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + O365 Suite Notifications + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + OneDriveRoot + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + OneNotePagePreviews + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Task + Orion Notes + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + OutlookExtensions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + PACE + 31 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + People I Know + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + PeopleConnect + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + PeoplePublicData + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Outlook.Reminder + Przypomnienia + 21 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + QuarantinedEmail + 0 + 1 + + true + true + true + true + true + true + true + + cce0d6e6-b69b-410a-907d-06dc2071ab58 + 0 + + + + + QuarantinedEmailDefaultCategory + 0 + 4 + + true + true + true + true + true + true + true + + cce0d6e6-b69b-410a-907d-06dc2071ab58 + 0 + + + + + QedcDefaultRetention + 0 + 0 + + true + true + true + true + true + true + true + + cce0d6e6-b69b-410a-907d-06dc2071ab58 + 0 + + + + + QedcLongRetention + 0 + 0 + + true + true + true + true + true + true + true + + cce0d6e6-b69b-410a-907d-06dc2071ab58 + 0 + + + + + QedcMediumRetention + 0 + 0 + + true + true + true + true + true + true + true + + cce0d6e6-b69b-410a-907d-06dc2071ab58 + 0 + + + + + QedcShortRetention + 0 + 0 + + true + true + true + true + true + true + true + + cce0d6e6-b69b-410a-907d-06dc2071ab58 + 0 + + + + + Recoverable Items + 0 + 6 + + true + true + false + false + false + + + true + true + true + true + true + true + true + + 0 + + + + + Audits + 12 + 0 + + true + true + false + false + false + + + true + true + true + true + true + true + true + + 0 + + + + + Calendar Logging + 0 + 0 + + true + true + false + false + false + + + true + true + true + true + true + true + true + + 0 + + + + + Deletions + 0 + 0 + + true + true + false + false + false + + + true + true + true + true + true + true + true + + 0 + + + + + Purges + 2 + 0 + + true + true + false + false + false + + + true + true + true + true + true + true + true + + 0 + + + + + SubstrateHolds + 0 + 0 + + true + true + false + false + false + + + true + true + true + true + true + true + true + + 0 + + + + + Versions + 0 + 0 + + true + true + false + false + false + + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.RecoveryPoints + RecoveryPoints + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + RelevantContacts + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Schedule + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + SearchFoldersView + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.ShadowItems + ShadowItems + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + SharedFilesSearchFolder + 63 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + SharePointNotifications + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Sharing + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Shortcuts + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ShortNotes + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + SkypeSpacesData + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + SkypeMessages + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + TeamsMeetings + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + TeamsMessages + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Spooler Queue + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + SpoolsPresentSharedItemsSearchFolder + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + SpoolsSearchFolder + 8 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + SubstrateFiles + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + ClassicAttachments + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.Signal + GraphWorkingSet + 64 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + SPOOLS + 8 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + SuggestedUserGroupAssociations + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.SwssItems + SwssItems + 0 + 10 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + TeamChatHistory + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + TeamsMessagesData + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + TemporarySaves + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Task + To-Do Search + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Ulubione + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + UserCuratedContacts + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + UserSocialActivityNotifications + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Views + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + XrmActivityStream + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + XrmActivityStreamSearch + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + XrmCompanySearch + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + XrmDealSearch + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + XrmInsights + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + XrmProjects + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + XrmSearch + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + YammerData + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + + + + diff --git a/src/tests/data/ews/incremental/1.json b/src/tests/data/ews/incremental/1.json new file mode 100644 index 00000000..46b779e1 --- /dev/null +++ b/src/tests/data/ews/incremental/1.json @@ -0,0 +1,96 @@ +{ + "error": false, + "statusCode": 200, + "headers": { + "Cache-Control": [ + "private" + ], + "Transfer-Encoding": [ + "chunked" + ], + "Content-Type": [ + "text\/xml; charset=utf-8" + ], + "Server": [ + "Microsoft-HTTPAPI\/2.0" + ], + "X-NanoProxy": [ + "1,1" + ], + "X-AspNet-Version": [ + "4.0.30319" + ], + "Request-Id": [ + "0de708d9-ea99-409a-9a67-9c8506607510" + ], + "X-CalculatedFETarget": [ + "GV0P278CU003.internal.outlook.com" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000,h3-29=\":443\";ma=2592000" + ], + "MS-CV": [ + "2QjnDZnqmkCaZ5yFBmB1EA.1.1" + ], + "Restrict-Access-Confirm": [ + "1" + ], + "X-BackEndHttpStatus": [ + "200,200" + ], + "X-BEServer": [ + "GV0P278MB1046" + ], + "X-BeSku": [ + "WCS7" + ], + "X-CalculatedBETarget": [ + "GV0P278MB1046.CHEP278.PROD.OUTLOOK.COM" + ], + "X-DiagInfo": [ + "GV0P278MB1046" + ], + "x-EwsHandler": [ + "FindItem" + ], + "X-FEEFZInfo": [ + "GVA" + ], + "X-FEProxyInfo": [ + "GV0P278CA0041" + ], + "X-FEServer": [ + "WA0P291CA0004" + ], + "x-ms-appId": [ + "6ce1e52b-cd1d-4663-abc3-368fba0bfae7" + ], + "X-Proxy-BackendServerStatus": [ + "200" + ], + "X-Proxy-RoutingCorrectness": [ + "1" + ], + "X-RUM-NotUpdateQueriedPath": [ + "1" + ], + "X-RUM-NotUpdateQueriedDbCopy": [ + "1" + ], + "X-RUM-Validated": [ + "1" + ], + "X-FirstHopCafeEFZ": [ + "WAW" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubDomains" + ], + "Set-Cookie": [ + "exchangecookie=69bae057f3b74f0fb7163f66ddd11493; expires=Fri, 11-Jul-2025 12:51:40 GMT; path=\/; secure; HttpOnly" + ], + "Date": [ + "Thu, 11 Jul 2024 12:51:39 GMT" + ] + } +} \ No newline at end of file diff --git a/src/tests/data/ews/incremental/1.xml b/src/tests/data/ews/incremental/1.xml new file mode 100644 index 00000000..ad5c7e0c --- /dev/null +++ b/src/tests/data/ews/incremental/1.xml @@ -0,0 +1,27 @@ + + + + + + + + + + NoError + + + + + IPM.Appointment + + + + IPM.Appointment + + + + + + + + diff --git a/src/tests/data/ews/incremental/4.json b/src/tests/data/ews/incremental/4.json new file mode 100644 index 00000000..905f098f --- /dev/null +++ b/src/tests/data/ews/incremental/4.json @@ -0,0 +1,96 @@ +{ + "error": false, + "statusCode": 200, + "headers": { + "Cache-Control": [ + "private" + ], + "Transfer-Encoding": [ + "chunked" + ], + "Content-Type": [ + "text\/xml; charset=utf-8" + ], + "Server": [ + "Microsoft-HTTPAPI\/2.0" + ], + "X-NanoProxy": [ + "1,1" + ], + "X-AspNet-Version": [ + "4.0.30319" + ], + "Request-Id": [ + "65d9caeb-2f07-ca8d-bd20-851f8dc232ed" + ], + "X-CalculatedFETarget": [ + "GV0P278CU002.internal.outlook.com" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000,h3-29=\":443\";ma=2592000" + ], + "MS-CV": [ + "68rZZQcvjcq9IIUfjcIy7Q.1.1" + ], + "Restrict-Access-Confirm": [ + "1" + ], + "X-BackEndHttpStatus": [ + "200,200" + ], + "X-BEServer": [ + "GVAP278MB0389" + ], + "X-BeSku": [ + "WCS6" + ], + "X-CalculatedBETarget": [ + "GVAP278MB0389.CHEP278.PROD.OUTLOOK.COM" + ], + "X-DiagInfo": [ + "GVAP278MB0389" + ], + "x-EwsHandler": [ + "FindItem" + ], + "X-FEEFZInfo": [ + "GVA" + ], + "X-FEProxyInfo": [ + "GV0P278CA0020" + ], + "X-FEServer": [ + "WA0P291CA0004" + ], + "x-ms-appId": [ + "6ce1e52b-cd1d-4663-abc3-368fba0bfae7" + ], + "X-Proxy-BackendServerStatus": [ + "200" + ], + "X-Proxy-RoutingCorrectness": [ + "1" + ], + "X-RUM-NotUpdateQueriedPath": [ + "1" + ], + "X-RUM-NotUpdateQueriedDbCopy": [ + "1" + ], + "X-RUM-Validated": [ + "1" + ], + "X-FirstHopCafeEFZ": [ + "WAW" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubDomains" + ], + "Set-Cookie": [ + "exchangecookie=5ad29dedadde423c99430af280c2ae09; expires=Fri, 11-Jul-2025 12:51:49 GMT; path=\/; secure; HttpOnly" + ], + "Date": [ + "Thu, 11 Jul 2024 12:51:49 GMT" + ] + } +} \ No newline at end of file diff --git a/src/tests/data/ews/incremental/4.xml b/src/tests/data/ews/incremental/4.xml new file mode 100644 index 00000000..ea487095 --- /dev/null +++ b/src/tests/data/ews/incremental/4.xml @@ -0,0 +1,35 @@ + + + + + + + + + + NoError + + + + + IPM.Contact + + + + IPM.DistList + + + + IPM.Contact + + + + IPM.Contact + + + + + + + + diff --git a/src/tests/data/ews/incremental/5.json b/src/tests/data/ews/incremental/5.json new file mode 100644 index 00000000..80355d07 --- /dev/null +++ b/src/tests/data/ews/incremental/5.json @@ -0,0 +1,96 @@ +{ + "error": false, + "statusCode": 200, + "headers": { + "Cache-Control": [ + "private" + ], + "Transfer-Encoding": [ + "chunked" + ], + "Content-Type": [ + "text\/xml; charset=utf-8" + ], + "Server": [ + "Microsoft-HTTPAPI\/2.0" + ], + "X-NanoProxy": [ + "1,1" + ], + "X-AspNet-Version": [ + "4.0.30319" + ], + "Request-Id": [ + "75aec142-54d8-bfc0-c0e7-adc4e3f2ba14" + ], + "X-CalculatedFETarget": [ + "GV0P278CU005.internal.outlook.com" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000,h3-29=\":443\";ma=2592000" + ], + "MS-CV": [ + "QsGuddhUwL\/A563E4\/K6FA.1.1" + ], + "Restrict-Access-Confirm": [ + "1" + ], + "X-BackEndHttpStatus": [ + "200,200" + ], + "X-BEServer": [ + "GV0P278MB0353" + ], + "X-BeSku": [ + "WCS6" + ], + "X-CalculatedBETarget": [ + "GV0P278MB0353.CHEP278.PROD.OUTLOOK.COM" + ], + "X-DiagInfo": [ + "GV0P278MB0353" + ], + "x-EwsHandler": [ + "GetItem" + ], + "X-FEEFZInfo": [ + "GVA" + ], + "X-FEProxyInfo": [ + "GV0P278CA0084" + ], + "X-FEServer": [ + "WA0P291CA0004" + ], + "x-ms-appId": [ + "6ce1e52b-cd1d-4663-abc3-368fba0bfae7" + ], + "X-Proxy-BackendServerStatus": [ + "200" + ], + "X-Proxy-RoutingCorrectness": [ + "1" + ], + "X-RUM-NotUpdateQueriedPath": [ + "1" + ], + "X-RUM-NotUpdateQueriedDbCopy": [ + "1" + ], + "X-RUM-Validated": [ + "1" + ], + "X-FirstHopCafeEFZ": [ + "WAW" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubDomains" + ], + "Set-Cookie": [ + "exchangecookie=08d3bd1184da4e9f8016984e26367d56; expires=Fri, 11-Jul-2025 12:51:50 GMT; path=\/; secure; HttpOnly" + ], + "Date": [ + "Thu, 11 Jul 2024 12:51:50 GMT" + ] + } +} \ No newline at end of file diff --git a/src/tests/data/ews/incremental/5.xml b/src/tests/data/ews/incremental/5.xml new file mode 100644 index 00000000..d89bc525 --- /dev/null +++ b/src/tests/data/ews/incremental/5.xml @@ -0,0 +1,49 @@ + + + + + + + + + + NoError + + + QkVHSU46VkNBUkQNClBST0ZJTEU6VkNBUkQNClZFUlNJT046My4wDQpNQUlMRVI6TWljcm9zb2Z0IEV4Y2hhbmdlDQpQUk9ESUQ6TWljcm9zb2Z0IEV4Y2hhbmdlDQpGTjpUZXN0IFN1cm5hbWUgMQ0KTjpTdXJuYW1lIDE7VGVzdDs7Ow0KTk9URToNCk9SRzo7DQpDTEFTUzpQVUJMSUMNCkFEUjtUWVBFPVdPUks6Ozs7Ozs7DQpBRFI7VFlQRT1IT01FOjs7Ozs7Ow0KQURSO1RZUEU9UE9TVEFMOjs7Ozs7Ow0KVEVMO1RZUEU9Q0VMTDoNClRFTDtUWVBFPVBBR0VSOg0KVEVMO1RZUEU9Q0FSOg0KVEVMO1RZUEU9Vk9JQ0U6DQpYLU1TLVNQT1VTRTtUWVBFPU46DQpYLU1TLU1BTkFHRVI7VFlQRT1OOg0KWC1NUy1BU1NJU1RBTlQ7VFlQRT1OOg0KVElUTEU6DQpVUkw7VFlQRT1IT01FOg0KVVJMO1RZUEU9V09SSzoNClJFVjtWQUxVRT1EQVRFLVRJTUU6MjAyNC0wNi0yNVQxMDo1NDo1NCwyMTRaDQpFTkQ6VkNBUkQNCg== + + false + en-US + 2024-06-25T09:54:54Z + + Test Surname 1 + Test + + Test + Surname 1 + Test Surname 1 + + + christian@outlook.com + + + + + + + + + + + Surname 1 + Q1ne1H54kk35i44UboHGPFRRPI8= + 2790877950c24ec1a3a9b41ce75aec8a + EXCH + + + + + + + + diff --git a/src/tests/data/ews/incremental/6.json b/src/tests/data/ews/incremental/6.json new file mode 100644 index 00000000..80355d07 --- /dev/null +++ b/src/tests/data/ews/incremental/6.json @@ -0,0 +1,96 @@ +{ + "error": false, + "statusCode": 200, + "headers": { + "Cache-Control": [ + "private" + ], + "Transfer-Encoding": [ + "chunked" + ], + "Content-Type": [ + "text\/xml; charset=utf-8" + ], + "Server": [ + "Microsoft-HTTPAPI\/2.0" + ], + "X-NanoProxy": [ + "1,1" + ], + "X-AspNet-Version": [ + "4.0.30319" + ], + "Request-Id": [ + "75aec142-54d8-bfc0-c0e7-adc4e3f2ba14" + ], + "X-CalculatedFETarget": [ + "GV0P278CU005.internal.outlook.com" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000,h3-29=\":443\";ma=2592000" + ], + "MS-CV": [ + "QsGuddhUwL\/A563E4\/K6FA.1.1" + ], + "Restrict-Access-Confirm": [ + "1" + ], + "X-BackEndHttpStatus": [ + "200,200" + ], + "X-BEServer": [ + "GV0P278MB0353" + ], + "X-BeSku": [ + "WCS6" + ], + "X-CalculatedBETarget": [ + "GV0P278MB0353.CHEP278.PROD.OUTLOOK.COM" + ], + "X-DiagInfo": [ + "GV0P278MB0353" + ], + "x-EwsHandler": [ + "GetItem" + ], + "X-FEEFZInfo": [ + "GVA" + ], + "X-FEProxyInfo": [ + "GV0P278CA0084" + ], + "X-FEServer": [ + "WA0P291CA0004" + ], + "x-ms-appId": [ + "6ce1e52b-cd1d-4663-abc3-368fba0bfae7" + ], + "X-Proxy-BackendServerStatus": [ + "200" + ], + "X-Proxy-RoutingCorrectness": [ + "1" + ], + "X-RUM-NotUpdateQueriedPath": [ + "1" + ], + "X-RUM-NotUpdateQueriedDbCopy": [ + "1" + ], + "X-RUM-Validated": [ + "1" + ], + "X-FirstHopCafeEFZ": [ + "WAW" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubDomains" + ], + "Set-Cookie": [ + "exchangecookie=08d3bd1184da4e9f8016984e26367d56; expires=Fri, 11-Jul-2025 12:51:50 GMT; path=\/; secure; HttpOnly" + ], + "Date": [ + "Thu, 11 Jul 2024 12:51:50 GMT" + ] + } +} \ No newline at end of file diff --git a/src/tests/data/ews/incremental/6.xml b/src/tests/data/ews/incremental/6.xml new file mode 100644 index 00000000..b1c6deb6 --- /dev/null +++ b/src/tests/data/ews/incremental/6.xml @@ -0,0 +1,49 @@ + + + + + + + + + + NoError + + + QkVHSU46VkNBUkQNClBST0ZJTEU6VkNBUkQNClZFUlNJT046My4wDQpNQUlMRVI6TWljcm9zb2Z0IEV4Y2hhbmdlDQpQUk9ESUQ6TWljcm9zb2Z0IEV4Y2hhbmdlDQpGTjpUZXN0IE5ldw0KTjpOZXc7VGVzdDs7Ow0KTk9URToNCk9SRzo7DQpDTEFTUzpQVUJMSUMNCkFEUjtUWVBFPVdPUks6Ozs7Ozs7DQpBRFI7VFlQRT1IT01FOjs7Ozs7Ow0KQURSO1RZUEU9UE9TVEFMOjs7Ozs7Ow0KVEVMO1RZUEU9Q0VMTDoNClRFTDtUWVBFPVBBR0VSOg0KVEVMO1RZUEU9Q0FSOg0KVEVMO1RZUEU9Vk9JQ0U6DQpYLU1TLVNQT1VTRTtUWVBFPU46DQpYLU1TLU1BTkFHRVI7VFlQRT1OOg0KWC1NUy1BU1NJU1RBTlQ7VFlQRT1OOg0KVElUTEU6DQpVUkw7VFlQRT1IT01FOg0KVVJMO1RZUEU9V09SSzoNClJFVjtWQUxVRT1EQVRFLVRJTUU6MjAyNC0wNi0yNVQxMDo1NDo1NCwyMTRaDQpFTkQ6VkNBUkQNCg== + + false + en-US + 2024-06-25T09:54:54Z + + Test New + Test + + Test + New + Test New + + + new@outlook.com + + + + + + + + + + + New + Q1ne1H54kk35i44UboHGPFRRPI8= + 2790877950c24ec1a3a9b41ce75aec8a + EXCH + + + + + + + + diff --git a/src/tests/data/ews/initial/0.json b/src/tests/data/ews/initial/0.json new file mode 100644 index 00000000..0da82daa --- /dev/null +++ b/src/tests/data/ews/initial/0.json @@ -0,0 +1,96 @@ +{ + "error": false, + "statusCode": 200, + "headers": { + "Cache-Control": [ + "private" + ], + "Transfer-Encoding": [ + "chunked" + ], + "Content-Type": [ + "text\/xml; charset=utf-8" + ], + "Server": [ + "Microsoft-HTTPAPI\/2.0" + ], + "X-NanoProxy": [ + "1,1" + ], + "X-AspNet-Version": [ + "4.0.30319" + ], + "Request-Id": [ + "a4ce6838-81ac-65a7-4759-3d9294738779" + ], + "X-CalculatedFETarget": [ + "GV0P278CU004.internal.outlook.com" + ], + "Alt-Svc": [ + "h3=\":443\";ma=2592000,h3-29=\":443\";ma=2592000" + ], + "MS-CV": [ + "OGjOpKyBp2VHWT2SlHOHeQ.1.1" + ], + "Restrict-Access-Confirm": [ + "1" + ], + "X-BackEndHttpStatus": [ + "200,200" + ], + "X-BEServer": [ + "GVAP278MB0615" + ], + "X-BeSku": [ + "WCS6" + ], + "X-CalculatedBETarget": [ + "GVAP278MB0615.CHEP278.PROD.OUTLOOK.COM" + ], + "X-DiagInfo": [ + "GVAP278MB0615" + ], + "x-EwsHandler": [ + "FindFolder" + ], + "X-FEEFZInfo": [ + "GVA" + ], + "X-FEProxyInfo": [ + "GV0P278CA0060" + ], + "X-FEServer": [ + "WA0P291CA0004" + ], + "x-ms-appId": [ + "6ce1e52b-cd1d-4663-abc3-368fba0bfae7" + ], + "X-Proxy-BackendServerStatus": [ + "200" + ], + "X-Proxy-RoutingCorrectness": [ + "1" + ], + "X-RUM-NotUpdateQueriedPath": [ + "1" + ], + "X-RUM-NotUpdateQueriedDbCopy": [ + "1" + ], + "X-RUM-Validated": [ + "1" + ], + "X-FirstHopCafeEFZ": [ + "WAW" + ], + "Strict-Transport-Security": [ + "max-age=31536000; includeSubDomains" + ], + "Set-Cookie": [ + "exchangecookie=faf9d40d8bc14d53a013ecd1336e35b5; expires=Fri, 11-Jul-2025 12:51:36 GMT; path=\/; secure; HttpOnly" + ], + "Date": [ + "Thu, 11 Jul 2024 12:51:36 GMT" + ] + } +} \ No newline at end of file diff --git a/src/tests/data/ews/initial/0.xml b/src/tests/data/ews/initial/0.xml new file mode 100644 index 00000000..34fda8ad --- /dev/null +++ b/src/tests/data/ews/initial/0.xml @@ -0,0 +1,7139 @@ + + + + + + + + + + NoError + + + + + + IPF.Note + AllCategorizedItems + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + AllContacts + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + AllContactsExtended + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF + AllItems + 230 + 0 + + true + true + true + true + true + true + true + + 110 + + + + + IPF.Note + AllPersonMetadata + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Task + AllTodoTasks + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ApplicationDataRoot + 0 + 56 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 00000002-0000-0ff1-ce00-000000000000 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HxNotificationSettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 00000006-0000-0ff1-ce00-000000000000 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GlobalSearchHistory + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 13937bba-652e-4c46-b222-3003f4d1ff97 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContextDataFast + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContextMetadata + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserInteractionsFast + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 1caee58f-eb14-4a6b-9339-1fe2ddf6692b + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Recent + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Settings + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 1e70cd27-4707-4589-8ec5-9bd20c472a46 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 224fffa9-a547-4c78-ab95-3aacd66c3e99 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FeaturesManagementCollection_1 + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 32d4b5e5-7d33-4e7f-b073-f8cffbbb47a1 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + outlookfavorites + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 33b68b23-a6c2-4684-99a0-fa3832792226 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AggregationV2BackfillStateCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + RelevanceDailyAggregation + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 34421fbe-f100-4e5b-9c46-2fea25aa7b88 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Contacts + 6 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 35d54a08-36c9-4847-9018-93934c62740c + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PeoplePredictions.FeedbackEntries + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PeoplePredictions.RequestEntries + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PeoplePredictions.UserProfile + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3873c4f1-2162-4ddb-96e6-232e4eddeedc + 0 + 4 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SEED_PeopleIntegrityCheckerStateCollection_v2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SEED_ScdIntegrityCheckerStateCollection_v2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 394866fc-eedb-4f01-8536-3ff84b16be2a + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AssociationBackfillStatus + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Favorites + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InsightInstancesActions + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3b2e5a14-128d-48aa-b581-482aac616d32 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3c896ded-22c5-450f-91f6-3d1ef0848f6e + 0 + 42 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActionExecutions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActivitiesDaily + 58 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActivitiesWeekly + 8 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AfterHoursEmailImpact + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AutomaticRepliesHistory + 105 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ChatsInterruptionStatistics + 49 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ComputeLogs + 38 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CumulativeNetworkSnapshot + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CumulativeOutOfOfficeClustering + 106 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyActivityTimeBins + 56 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyAppointments + 85 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyInteractions + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DailyNetworkSnapshot + 51 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DelayedEmail + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DeliverCardEvents + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DetailedMeetings + 51 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmailActionStatistics + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmailGrazerData + 50 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmfResponses + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FoodForThoughtRanking + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HeterogeneousItems + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ImportantContact + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InsightsEngagement + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + MailActivityCompactSignalBatch + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ManagementOperationExecutionRecords + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + MeetingActionStatistics + 9 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + MeetingResponseActivity + 6 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutcomeStates + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutOfOffice + 44 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ProgramMetrics + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SegmentsStateRecord + 18 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SentMailLogs + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SigsChatsAndCallsCompactSignalBatch + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UnifiedCardEngagements + 30 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserConfiguration + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserProgramInstances + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyEmbeddingBasedUserTimePrediction + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyInteractions + 27 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyOutOfOfficeAndWorkingDay + 28 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyOutOfRoutineMeetingScorePDF + 30 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyOutOfRoutineMeetingTimes + 61 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WeeklyWorkplaceActivityEmbedding + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 3e3c8645-20a6-4c6e-8355-c34cbbe04585 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PintSigsMsGraphState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 42c2c67f-2760-4f42-b0c4-ef5c3854a0c2 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CollabObjects + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 43375d74-c6a5-4d4e-a0a3-de139860ea75 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ElcArchiveProcessorStatsSdsCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OverallArchivingStatsSdsCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 441509e5-a165-4363-8ee7-bcf0b7d26739 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GenericWorkflowProcessor.SessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Idf + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfMeeting + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 4787c7ff-7cea-43db-8d0d-919f15c6354b + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + VivaSales + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 48af08dc-f6d2-435f-b2a7-069abd99c086 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InsightsProvidersSettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 49499048-0129-47f5-b95e-f9d315b861a6 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookAccountCloudSettings + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookCloudSettings + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 497effe9-df71-4043-a8bb-14cf78c4b63b + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EACUserPreferences + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 4e445925-163e-42ca-b801-9073bfa46d17 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NewsSubscriptionSourcesv2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 59cc317d-5453-4fcd-ab98-60552b158926 + 0 + 6 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AlgorithmRunStatus + 9 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CDDS_SuggestionCheckpoint + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContactFeedback + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ContactMergeSuggestions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HiddenDuplicates + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + HideSettings + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 644c1b11-f63f-45fa-826b-a9d2801db711 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + _PolicyContainer + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + YWxlY0BhcGhlbGVpYWl0Lm9ubWljcm9zb2Z0LmNvbQ==_LabelFile + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + YWxlY0BhcGhlbGVpYWl0Lm9ubWljcm9zb2Z0LmNvbQ==_PolicyContainer + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 66a88757-258c-4c72-893c-3e8bed4d6899 + 0 + 31 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ScopeEntities + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Apps + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.CalendarEvents + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.CollaborationNetworkEntities + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.ContentDomain + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EmailEntities + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EmailTokens + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Bookmarks + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Links + 4 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Locations + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.EntityServe.Qnas + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.FreshHistory + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.FreshSharePointFiles + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.FreshSharePointSites + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.GroupsRoomsMiscIndex + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Notes + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.People + 11 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.PeopleCache + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.PeopleIndex + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.RecentEmailItems + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.RecommendationsService + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.RecommendationsServiceProcessor + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Scope + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SearchHistory.Main + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SearchHistoryState + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SharePointDocuments + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.SkillEntities + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.TeamsAndChannels + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.TeamsEntities + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SubstrateSearch.Topics + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 6cd5135b-7a07-43ec-acc2-b5e27dc628cb + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + currentuser + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + neighbors + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OfficeFeedSdsCacheCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 766ef332-38e5-4cb4-920c-baa478e39fd9 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CrawlerExecutionInfoCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7a5fbd1c-3e6d-461a-9075-83049393b3a7 + 0 + 7 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AcronymAggregationState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AcronymService.SessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ConceptAggregationSessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NicknamesAllInbox + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SimpleAcronymsIndex + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SimpleAcronymsIndexElysium + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SimpleUserContextIndex + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7aadb841-2368-4d24-a373-94d0ba1f077a + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + outlookspaces + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7ae974c5-1af7-4923-af3a-fb1fd14dcb7e + 0 + 9 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AppBarTiles + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FloodgateSurvey + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GetStartedStore + 20 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + lightning + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LightningSharedStore + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LightningStore + 67 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookEndpointDataCollection + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + OutlookOptionsV3 + 7 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + WhatsNewStore + 97 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 7e02887d-df1d-45a4-af52-dcee2e585eee + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserEnablementStatus + 8 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 80723a00-368e-4d64-8281-210e49e593a8 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActivityFeed_201905 + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 82d8ab62-be52-a567-14ea-1616c4ee06c4 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ActorAnalyticsProcessorState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 8a18aa92-0a1e-4e06-abd0-e118fa4787b1 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GraphMetaColl + 26 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 8c22b648-ee54-4ece-a4ca-3015b6d24f8e + 0 + 20 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DIF + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Images + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_contactsync + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_meetinginsightssetting + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_officegraphvisibilitysetting + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceaad + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceconnector + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceemailaddressdisplaynames + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcemanagerchain + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcemsgraph + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcenamepronunciation + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcenone + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceorganizationheadcount + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourcephotoetag + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceupa + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_sourceuserdefined + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + source_userdefinedsettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SQI_FI + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + view_profilev2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + view_settingsv2 + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 94c63fef-13a3-47bc-8074-75af8c65887a + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + DelveCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + 97cb1f73-50df-47d1-8fb0-0271f2728514 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CleanupProcessorState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + a3883eba-fbe9-48bd-9ed3-dca3e0e84250 + 0 + 4 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + AggregatedLinks + 9 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EmailsIndexV1 + 65 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LinkFiltersMetadataIndex + 3 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + LinksIndex + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + a6061a71-f529-4ca2-bc9d-c8085ecbf3ca + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + EdgeSettings + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + adcd8967-0799-4ec0-bfc8-dcf85fd84767 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CssProcessingState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ae8e128e-080f-4086-b0e3-4c19301ada69 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Scheduling + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + b669c6ea-1adf-453f-b8bc-6d526592b419 + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FocusedInboxMailboxData + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NudgeCandidates + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ScenarioTracking + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + b6e65498-8dce-4d57-8de9-1269fcfcf6ce + 0 + 6 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + KeyphraseRelationshipsSessionManager.Data + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserDocKpeStats + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserDocWithKpes + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserKpes + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserKpeState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserStatistics + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + c9224372-5534-42cb-a48b-8db4f4a3892e + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TeamsCalendar.SSMeetingPropsSync + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + c94eb245-f5ab-4fea-a534-841091469193 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + ic3usersettings + 13 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d297b9ff-5d5b-4bce-8ed4-eedb1431bf61 + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + NucleusProperties + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d396de1f-10d4-4023-aae2-5bb3d724ba9a + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + FolderGroups-DefaultCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + settings + 6 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TasksDataMarker_v9 + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d71dfe16-1070-48f3-bd3a-c3ec919d34e7 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TxpAutoblocking + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + TxpUserSettings + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + d8cbf452-c2df-4756-95a5-8aa23fedc3ce + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + UserMigrationDetails + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + e69932cd-f814-4087-8ab1-5ab3f1ad18eb + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + e7762c80-392b-4278-80fa-a8fda80b129c + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + CMUState + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + e7f049f2-9dc5-4301-88a2-121b268ad42f + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + Common + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SharingSuggestion + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + f08262d6-bf02-4e1e-bdef-e95e2f0a940f + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + GlowCollection + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + f164ee43-46e5-4f7c-b5ff-2d1abcd4ce7e + 0 + 1 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + PersonalProfileVector_AnalyzerStates + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + f3f64fec-ecee-4e9a-9eed-a36a5e421300 + 0 + 10 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfEmailPoi + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfInboxPoi + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + IdfMeetingPoi + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + InferredLanguage + 2 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SlpJobs + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechNGrams + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechTrimmedLms + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechUserDocumentNGrams + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechUserDocumentSentences + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPM.ApplicationData + SpeechUserDocumentsHistory + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + BrokerSubscriptions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + BulkActions + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + CalendarSharingCacheCollection + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Common Views + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ComplianceMetadata + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Connectors + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + ConnectorConfigurations + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.CrawlerData + CrawlerData + 35 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Deferred Action + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.StoreItem.DlpPolicyEvaluation + DlpPolicyEvaluation + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + Document Centric Conversations + 0 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ExchangeODataSyncData + 199 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + ExchangeSyncData + 0 + 3 + + true + true + true + true + true + true + true + + 0 + + + + + AirSync-iPhone-J97G80P7O916504POMJIHAJE70 + 10 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + AirSync-TestActiveSyncConnectivity-430847304 + 5 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Hx-Outlook-B57C063B9A6C46ECAD4C159C1E161B14 + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + OutlookServiceActionQueue + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Policy + 1 + 0 + + true + true + true + true + true + true + true + + 0 + + + + + Finder + 0 + 2 + + true + true + true + true + true + true + true + + 0 + + + + + IPF.Note + OwaFV15.1AllFocusedAQMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NABkNDgALgAAA722UNhKUbZNrFYGsDU7Na0BACzZqOGSdeVFg3+lsLwmpuUAAAIBDAAAAA== + 101 + 0 + + true + true + true + true + true + true + true + + 95 + + + + + IPF.Note + OwaFV15.1AllOtherAQMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NABkNDgALgAAA722UNhKUbZNrFYGsDU7Na0BACzZqOGSdeVFg3+lsLwmpuUAAAIBDAAAAA== + 15 + 0 +