* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderJmapc\Providers\Mail; use Generator; use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionRoles; use KTXF\Mail\Collection\CollectionMutableInterface; use KTXF\Mail\Collection\CollectionPropertiesBaseInterface; use KTXF\Mail\Object\Address; use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXF\Mail\Service\ServiceMutableInterface; use KTXF\Resource\BinaryResource; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\Range; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; use KTXM\ProviderJmapc\Providers\ServiceIdentityBasic; use KTXM\ProviderJmapc\Providers\ServiceLocation; use KTXM\ProviderJmapc\Service\Remote\RemoteMailService; use KTXM\ProviderJmapc\Service\Remote\RemoteService; class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface { private const PROVIDER_IDENTIFIER = 'jmap'; private ?string $serviceTenantId = null; private ?string $serviceUserId = null; private ?string $serviceIdentifier = null; private ?string $serviceLabel = null; private bool $serviceEnabled = false; private string $primaryAddress = ''; private array $secondaryAddresses = []; private ?ServiceLocation $location = null; private ?ServiceIdentityBasic $identity = null; private array $auxiliary = []; private array $serviceAbilities = [ self::CAPABILITY_COLLECTION_LIST => true, self::CAPABILITY_COLLECTION_LIST_FILTER => [ self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:128:256:256', self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:32:1:1', self::CAPABILITY_COLLECTION_FILTER_SUBSCRIBED => 'b:0:1:1', ], self::CAPABILITY_COLLECTION_LIST_SORT => [ self::CAPABILITY_COLLECTION_SORT_LABEL, self::CAPABILITY_COLLECTION_SORT_RANK, ], self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_EXTANT => true, self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_UPDATE => true, self::CAPABILITY_COLLECTION_DELETE => true, self::CAPABILITY_COLLECTION_MOVE => true, self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ self::CAPABILITY_ENTITY_FILTER_ALL => 's:200:256:256', self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_CC => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_BCC => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_SUBJECT => 's:200:256:256', self::CAPABILITY_ENTITY_FILTER_BODY => 's:200:256:256', self::CAPABILITY_ENTITY_FILTER_DATE_BEFORE => 'd:0:32:32', self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 'd:0:16:16', self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16', self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32', ], self::CAPABILITY_ENTITY_LIST_SORT => [ self::CAPABILITY_ENTITY_SORT_FROM, self::CAPABILITY_ENTITY_SORT_TO, self::CAPABILITY_ENTITY_SORT_SUBJECT, self::CAPABILITY_ENTITY_SORT_DATE_RECEIVED, self::CAPABILITY_ENTITY_SORT_DATE_SENT, self::CAPABILITY_ENTITY_SORT_SIZE, ], self::CAPABILITY_ENTITY_LIST_RANGE => [ 'tally' => ['absolute', 'relative'] ], self::CAPABILITY_ENTITY_FETCH => true, self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_CREATE => false, self::CAPABILITY_ENTITY_MODIFY => false, self::CAPABILITY_ENTITY_PATCH => true, self::CAPABILITY_ENTITY_DELETE => true, self::CAPABILITY_ENTITY_MOVE => true, self::CAPABILITY_ENTITY_COPY => false, ]; private readonly RemoteMailService $mailService; public function __construct( ) {} private function initialize(): void { if (!isset($this->mailService)) { $client = RemoteService::freshClient($this); $this->mailService = RemoteService::mailService($client); } } public function toStore(): array { return array_filter([ 'tid' => $this->serviceTenantId, 'uid' => $this->serviceUserId, 'sid' => $this->serviceIdentifier, 'enabled' => $this->serviceEnabled, 'label' => $this->serviceLabel, 'primaryAddress' => $this->primaryAddress, 'secondaryAddresses' => $this->secondaryAddresses, 'location' => $this->location?->toStore(), 'identity' => $this->identity?->toStore(), 'auxiliary' => $this->auxiliary, ], fn($v) => $v !== null); } public function fromStore(array $data): static { $this->serviceTenantId = $data['tid'] ?? null; $this->serviceUserId = $data['uid'] ?? null; $this->serviceIdentifier = $data['sid']; $this->serviceLabel = $data['label'] ?? ''; $this->serviceEnabled = $data['enabled'] ?? false; if (isset($data['primaryAddress'])) { $this->primaryAddress = $data['primaryAddress']; } if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) { $this->secondaryAddresses = $data['secondaryAddresses']; } if (isset($data['location'])) { $this->location = (new ServiceLocation())->fromStore($data['location']); } if (isset($data['identity'])) { $this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']); } if (isset($data['auxiliary']) && is_array($data['auxiliary'])) { $this->auxiliary = $data['auxiliary']; } return $this; } public function jsonSerialize(): array { return array_filter([ self::JSON_PROPERTY_TYPE => self::JSON_TYPE, self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier, self::JSON_PROPERTY_LABEL => $this->serviceLabel, self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(), self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), self::JSON_PROPERTY_AUXILIARY => $this->auxiliary, ], fn($v) => $v !== null); } public function jsonDeserialize(array|string $data, bool $delta = false): static { if (is_string($data)) { $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } if (isset($data[self::JSON_PROPERTY_ENABLED])) { $this->setEnabled($data[self::JSON_PROPERTY_ENABLED]); } if (isset($data[self::JSON_PROPERTY_LABEL])) { $this->setLabel($data[self::JSON_PROPERTY_LABEL]); } if (isset($data[self::JSON_PROPERTY_LOCATION])) { $this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION])); } if (isset($data[self::JSON_PROPERTY_IDENTITY])) { $this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY])); } if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) { $this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])); } if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) { $this->setSecondaryAddresses(array_map( fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr), $data[self::JSON_PROPERTY_SECONDARY_ADDRESSES] )); } if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) { $this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]); } return $this; } public function capable(string $value): bool { return isset($this->serviceAbilities[$value]); } public function capabilities(): array { return $this->serviceAbilities; } public function provider(): string { return self::PROVIDER_IDENTIFIER; } public function identifier(): string|int { return $this->serviceIdentifier; } public function getLabel(): string|null { return $this->serviceLabel; } public function setLabel(string $label): static { $this->serviceLabel = $label; return $this; } public function getEnabled(): bool { return $this->serviceEnabled; } public function setEnabled(bool $enabled): static { $this->serviceEnabled = $enabled; return $this; } public function getPrimaryAddress(): AddressInterface { return new Address($this->primaryAddress); } public function setPrimaryAddress(AddressInterface $value): static { $this->primaryAddress = $value->getAddress(); return $this; } public function getSecondaryAddresses(): array { return $this->secondaryAddresses; } public function setSecondaryAddresses(array $addresses): static { $this->secondaryAddresses = $addresses; return $this; } public function hasAddress(string $address): bool { $address = strtolower(trim($address)); if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) { return true; } foreach ($this->secondaryAddresses as $secondaryAddress) { if (strtolower($secondaryAddress->getAddress()) === $address) { return true; } } return false; } public function getLocation(): ServiceLocation { return $this->location; } public function setLocation(ResourceServiceLocationInterface $location): static { $this->location = $location; return $this; } public function freshLocation(string|null $type = null, array $data = []): ServiceLocation { $location = new ServiceLocation(); $location->jsonDeserialize($data); return $location; } public function getIdentity(): ServiceIdentityBasic { return $this->identity; } public function setIdentity(ResourceServiceIdentityInterface $identity): static { $this->identity = $identity; return $this; } public function freshIdentity(string|null $type, array $data = []): ServiceIdentityBasic { $identity = new ServiceIdentityBasic(); $identity->jsonDeserialize($data); return $identity; } public function getDebug(): bool { return ($this->auxiliary['debug'] ?? false) === true; } public function setDebug(bool $debug): static { $this->auxiliary['debug'] = $debug; return $this; } public function getAuxiliary(): array { return $this->auxiliary; } public function setAuxiliary(array $auxiliary): static { $this->auxiliary = $auxiliary; return $this; } public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $this->initialize(); $collections = $this->mailService->collectionList($location, $filter, $sort); foreach ($collections as &$collection) { if (is_array($collection) && isset($collection['id'])) { $object = new CollectionResource(provider: $this->provider(), service: $this->identifier()); $object->fromJmap($collection); $collection = $object; } } return $collections; } public function collectionListFilter(): Filter { return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); } public function collectionListSort(): Sort { return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); } public function collectionExtant(string|int ...$identifiers): array { $this->initialize(); return $this->mailService->collectionExtant(...$identifiers); } public function collectionFetch(string|int $identifier): ?CollectionResource { $this->initialize(); $mailbox = $this->mailService->collectionFetch($identifier); if (!is_array($mailbox) || !isset($mailbox['id'])) { return null; } $collection = $this->collectionFresh(); $collection->fromJmap($mailbox); return $collection; } public function collectionFresh(): CollectionResource { return new CollectionResource($this->provider(), $this->identifier()); } public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface { $this->initialize(); if ($properties instanceof CollectionProperties === false) { $native = new CollectionProperties([]); $native->jsonDeserialize($properties->jsonSerialize()); } else { $native = $properties; } $collection = $native->toJmap(); $collection = $this->mailService->collectionCreate($target?->collection(), $collection, $options); $object = $this->collectionFresh(); $object->fromJmap($collection); return $object; } public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface { $this->initialize(); if ($properties instanceof CollectionProperties === false) { $native = new CollectionProperties([]); $native->jsonDeserialize($properties->jsonSerialize()); } else { $native = $properties; } $collection = $native->toJmap(); $collection = $this->mailService->collectionModify($target->collection(), $collection); $object = $this->collectionFresh(); $object->fromJmap($collection); return $object; } public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true { $this->initialize(); $deleteMode = $this->auxiliary['deleteMode'] ?? 'soft'; $deleteTarget = $this->auxiliary['deleteTarget'] ?? null; if ($deleteMode !== 'soft' && $deleteMode !== 'hard') { throw new \InvalidArgumentException("Invalid delete mode: $deleteMode"); } // Move to target collection (e.g. Trash) instead of deleting if ($deleteMode === 'soft' && $deleteTarget !== null) { return $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target); } if ($deleteMode === 'soft' && $deleteTarget === null) { $filter = $this->collectionListFilter(); $filter->condition('role', CollectionRoles::Trash->value); $mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null)); if (empty($mailboxes)) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } $deleteTarget = key($mailboxes); } // we need to determine if the folder being deleted is already in the trash if (str_starts_with((string) $target->collection(), (string) $deleteTarget)) { // if so, we should hard delete instead of moving to avoid duplicates in the trash $deleteMode = 'hard'; } $result = match ($deleteMode) { 'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target), 'hard' => $this->mailService->collectionDestroy($target->collection(), $force), }; return $result; } public function collectionMove(CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface { $this->initialize(); $sourceMailbox = $this->mailService->collectionFetch((string) $source->collection()); $targetMailbox = $this->mailService->collectionFetch((string) $target->collection()); if ($sourceMailbox === null) { throw new \RuntimeException('Source collection not found for move operation'); } if ($targetMailbox === null) { throw new \RuntimeException('Target collection not found for move operation'); } $mutation['parentId'] = $targetMailbox['id']; $mutation = $this->mailService->collectionModify($sourceMailbox['id'], $mutation); $mutation = array_merge($sourceMailbox, $mutation); $collection = $this->collectionFresh(); $collection->fromJmap($mutation); return $collection; } public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { $this->initialize(); $result = $this->mailService->entityList($collection, $filter, $sort, $range, $properties); $list = []; foreach ($result['list'] as $index => $entry) { if (is_array($entry) && isset($entry['id'])) { $object = $this->entityFresh(); $object->fromJmap($entry); $list[$object->urn()] = $object; } unset($result['list'][$index]); } return $list; } public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator { foreach ($this->entityListBulk($collection, $filter, $sort, $range, $properties) as $urn => $entity) { yield $urn => $entity; } } public function entityListFilter(): Filter { return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); } public function entityListSort(): Sort { return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); } public function entityListRange(RangeType $type): IRange { return new Range(); } public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array { $this->initialize(); $ids = []; foreach ($identifiers as $identifier) { $ids[] = $identifier->entity(); } $entities = $this->mailService->entityFetch(...$ids); $list = []; foreach ($entities as $entity) { if (is_array($entity) && isset($entity['id'])) { $object = $this->entityFresh(); $object->fromJmap($entity); $list[$object->urn()] = $object; } } return $list; } public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator { foreach ($this->entityFetchBulk(...$identifiers) as $urn => $entity) { yield $urn => $entity; } } public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource { $this->initialize(); $blobId = isset($part['blobId']) ? (string) $part['blobId'] : null; return $this->mailService->entityDownload($target->entity(), $blobId); } public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta { $this->initialize(); return $this->mailService->entityDelta($collection, $signature, $detail); } public function entityExtant(string|int $collection, string|int ...$identifiers): array { $this->initialize(); return $this->mailService->entityExtant(...$identifiers); } public function entityFresh(): EntityResource { return new EntityResource($this->provider(), $this->identifier()); } public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource { // TODO: Implement entity create return $this->entityFresh(); } public function entityModify(EntityIdentifier $identifier, MessagePropertiesMutableInterface $properties): EntityResource { // TODO: Implement entity modify return $this->entityFresh(); } public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array { // validate identifiers and construct ID list $targets = $this->mapEntities(...$targets); // move entities on remote store and construct result map $this->initialize(); $patch = $this->mailService->entityFresh(); $flags = $properties->getFlags(); foreach ($flags as $flag => $value) { $patch->keyword($flag, $value); } $dispositions = $this->mailService->entityPatch($patch, ...array_keys($targets)); $list = []; foreach ($targets as $target) { $entityId = $target->entity(); // if the source entity ID is not in the dispositions, it means an unknown error occurred during the move operation for that entity if (!isset($dispositions[$entityId])) { $list[(string)$target] = [ 'disposition' => 'error', 'error' => 'Unknown error occurred during move operation', ]; continue; } // if the disposition for the entity ID is not true, it means the move operation failed for that entity with a known error if ($dispositions[$entityId] !== true) { $list[(string)$target] = [ 'disposition' => 'error', 'error' => $dispositions[$entityId] ?? 'Unknown error occurred during move operation', ]; continue; } $list[(string)$target] = [ 'disposition' => 'patched' ]; unset($targets[$entityId]); } return $list; } public function entityDelete(EntityIdentifier ...$targets): array { // validate identifiers and construct ID list $targets = $this->mapEntities(...$targets); // determine delete mode and target collection (e.g. Trash) if applicable $deleteMode = $this->auxiliary['deleteMode'] ?? 'soft'; $deleteTarget = $this->auxiliary['deleteTarget'] ?? null; if ($deleteMode !== 'soft' && $deleteMode !== 'hard') { throw new \InvalidArgumentException("Invalid delete mode: $deleteMode"); } // connect to remote store $this->initialize(); // attempt to find a target collection for soft deletion if none was specified if ($deleteMode === 'soft' && $deleteTarget === null) { $filter = $this->collectionListFilter(); $filter->condition('role', CollectionRoles::Trash->value); $mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null)); if (empty($mailboxes)) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } $targetMailbox = reset($mailboxes); if ($targetMailbox === false) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } $deleteTargetNative = $targetMailbox->id(); $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); } else { $deleteTargetNative = $deleteTarget; $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); } // if all targets are already in the delete target collection, we should hard delete instead of moving to avoid duplicates in the trash $targetCollections = []; foreach ($targets as $target) { $targetCollections[$target->collection()] = true; } if (array_keys($targetCollections) === [$deleteTargetNative]) { $deleteMode = 'hard'; } // move or delete target entities on remote store and construct result map $dispositions = match ($deleteMode) { 'soft' => $this->mailService->entityMove($deleteTargetNative, ...array_keys($targets)), 'hard' => $this->mailService->entityDelete(...array_keys($targets)), }; $list = []; foreach ($targets as $target) { $entityId = $target->entity(); // if the source entity ID is not in the dispositions, it means an unknown error occurred during the move operation for that entity if (!isset($dispositions[$entityId])) { $list[(string)$target] = [ 'disposition' => 'error', 'error' => 'Unknown error occurred during move operation', ]; continue; } // if the disposition for the entity ID is not true, it means the move operation failed for that entity with a known error if ($dispositions[$entityId] !== true) { $list[(string)$target] = [ 'disposition' => 'error', 'error' => $dispositions[$entityId] ?? 'Unknown error occurred during move operation', ]; continue; } if ($deleteMode === 'soft') { $list[(string)$target] = [ 'disposition' => 'moved', 'destination' => $deleteTargetIdentifier, 'mutation' => new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $entityId), ]; } else { $list[(string)$target] = [ 'disposition' => 'deleted', ]; } unset($targets[$entityId]); } return $list; } public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array { // validate target belongs to this service if ($target->provider() !== $this->provider() || $target->service() !== (string)$this->identifier()) { throw new \InvalidArgumentException('Target collection does not belong to this service'); } // validate identifiers and construct ID list $sources = $this->mapEntities(...$sources); // move entities on remote store and construct result map $this->initialize(); $dispositions = $this->mailService->entityMove($target->collection(), ...array_keys($sources)); $list = []; foreach ($sources as $source) { $entityId = $source->entity(); // if the source entity ID is not in the dispositions, it means an unknown error occurred during the move operation for that entity if (!isset($dispositions[$entityId])) { $list[(string)$source] = [ 'disposition' => 'error', 'error' => 'Unknown error occurred during move operation', ]; continue; } // if the disposition for the entity ID is not true, it means the move operation failed for that entity with a known error if ($dispositions[$entityId] !== true) { $list[(string)$source] = [ 'disposition' => 'error', 'error' => $dispositions[$entityId] ?? 'Unknown error occurred during move operation', ]; continue; } $list[(string)$source] = [ 'disposition' => 'moved', 'destination' => $target, 'mutation' => new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $entityId), ]; unset($sources[$entityId]); } return $list; } public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array { // TODO: Implement entity copy return []; } private function mapEntities(EntityIdentifier ...$identifiers): array { $list = []; foreach ($identifiers as $identifier) { if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) { throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier); } $list[$identifier->entity()] = $identifier; } return $list; } }