From 2647a55964d05fed9e146cf996b3bcd2ac24890e Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 28 May 2026 23:24:23 -0400 Subject: [PATCH] feat: mail entity download Signed-off-by: Sebastian Krupinski --- lib/Providers/Service.php | 166 ++++++++++++----------- lib/Service/Remote/RemoteMailService.php | 38 +++++- 2 files changed, 119 insertions(+), 85 deletions(-) diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index 2b4c560..e7cd46d 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -19,7 +19,7 @@ use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceMutableInterface; -use KTXF\Mail\Service\DownloadResult; +use KTXF\Resource\BinaryResource; use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Delta\Delta; @@ -42,6 +42,7 @@ use KTXF\Mail\Collection\CollectionRoles; use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXM\ProviderImap\Providers\EntityResource; +use KTXM\ProviderImap\Client\Mailbox; /** * IMAP Mail Service @@ -62,23 +63,23 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC private array $auxiliary = []; private array $serviceAbilities = [ - self::CAPABILITY_COLLECTION_LIST => true, + 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_LIST_SORT => [ self::CAPABILITY_COLLECTION_SORT_LABEL, self::CAPABILITY_COLLECTION_SORT_RANK, ], - self::CAPABILITY_COLLECTION_EXTANT => true, 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_COLLECTION_MOVE => true, + self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256', @@ -89,7 +90,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC 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_LIST_SORT => [ self::CAPABILITY_ENTITY_SORT_FROM, self::CAPABILITY_ENTITY_SORT_TO, self::CAPABILITY_ENTITY_SORT_SUBJECT, @@ -100,25 +101,25 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC 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, + 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 RemoteMailService $mailService; + private RemoteMailService $remoteService; public function __construct() {} private function initialize(): void { - if (!isset($this->mailService)) { + if (!isset($this->remoteService)) { $wrapper = RemoteService::freshClient($this); - $this->mailService = RemoteService::mailService($this, $wrapper); + $this->remoteService = RemoteService::mailService($this, $wrapper); } } @@ -362,7 +363,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $list = []; - foreach ($this->mailService->collectionList($location, $filter, $sort) as $mailbox) { + foreach ($this->remoteService->collectionList($location, $filter, $sort) as $mailbox) { $resource = $this->collectionFresh(); $resource->fromImap($mailbox); $list[$mailbox->name()] = $resource; @@ -389,7 +390,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($identifiers as $identifier) { $key = (string) $identifier; - $result = $this->mailService->collectionFetch($key); + $result = $this->remoteService->collectionFetch($key); $list[$key] = $result !== false; } @@ -401,7 +402,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - $mailbox = $this->mailService->collectionFetch((string) $identifier); + $mailbox = $this->remoteService->collectionFetch((string) $identifier); if ($mailbox === null) { return null; } @@ -430,13 +431,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC if ($target !== null) { $path = $target->collection(); // Determine the hierarchy delimiter from an existing mailbox, default to '/' - $mailboxes = iterator_to_array($this->mailService->collectionList(null, null, null, '')); + $mailboxes = iterator_to_array($this->remoteService->collectionList(null, null, null, '')); $rootMailbox = $mailboxes === [] ? null : reset($mailboxes); $delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/'); $label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter); } - $mailbox = $this->mailService->collectionCreate($label); + $mailbox = $this->remoteService->collectionCreate($label); $collection = $this->collectionFresh(); $collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]); @@ -456,7 +457,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC // In IMAP, "update" = rename to the new label $oldPath = (string) $target->collection(); $newName = $properties->getLabel(); - $mailbox = $this->mailService->collectionRename($oldPath, $newName); + $mailbox = $this->remoteService->collectionRename($oldPath, $newName); $collection = $this->collectionFresh(); $collection->fromImap($mailbox); @@ -476,14 +477,14 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC // Move to target collection (e.g. Trash) instead of deleting if ($deleteMode === 'soft' && $deleteTarget !== null) { - return $this->collectionMove($target, new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget)); + 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)); + $mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null)); if (empty($mailboxes)) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } @@ -499,7 +500,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $result = match ($deleteMode) { 'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target), - 'hard' => $this->mailService->collectionDestroy((string) $target->collection()), + 'hard' => $this->remoteService->collectionDestroy((string) $target->collection()), }; return $result; } @@ -508,8 +509,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - $sourceMailbox = $this->mailService->collectionFetch((string) $source->collection()); - $targetMailbox = $this->mailService->collectionFetch((string) $target->collection()); + $sourceMailbox = $this->remoteService->collectionFetch((string) $source->collection()); + $targetMailbox = $this->remoteService->collectionFetch((string) $target->collection()); if ($sourceMailbox === null) { throw new \RuntimeException('Source collection not found for move operation'); } @@ -521,8 +522,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $targetDelimiter = $targetMailbox->delimiter() ?? '/'; $extantPath = $sourceMailbox->name(); - $freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end(explode($sourceDelimiter, $extantPath)); - $mutatedMailbox = $this->mailService->collectionRename($extantPath, $freshPath); + $extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter)); + + $freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs); + + $mutatedMailbox = $this->remoteService->collectionRename($extantPath, $freshPath); $collection = $this->collectionFresh(); $collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]); @@ -538,7 +542,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) { + foreach ($this->remoteService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) { $resource = $this->entityFresh(); $resource->fromImap($message, $collection); yield $resource->urn() => $resource; @@ -576,7 +580,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($identifiers as $collection => $entities) { $uids = array_keys($entities); - foreach ($this->mailService->entityFetch((string) $collection, ...$uids) as $uid => $message) { + foreach ($this->remoteService->entityFetch((string) $collection, ...$uids) as $uid => $message) { $resource = $this->entityFresh(); $resource->fromImap($message, $collection); yield $resource->urn() => $resource; @@ -584,6 +588,16 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } } + public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource { + $this->initialize(); + + $collection = $target->collection(); + $uid = (int) $target->entity(); + $partId = isset($part['partId']) ? (string) $part['partId'] : null; + + return $this->remoteService->entityDownload($collection, $uid, $partId); + } + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta { return new Delta(signature: $signature); @@ -593,7 +607,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - $allUids = $this->mailService->entityList((string) $collection); + $allUids = $this->remoteService->entityList((string) $collection); $uidSet = array_flip($allUids); // int[] → [uid => index] $extant = []; foreach ($identifiers as $id) { @@ -640,7 +654,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } } - $mutations = $this->mailService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids); + $mutations = $this->remoteService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids); foreach ($uids as $uid) { $list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched']; @@ -671,17 +685,18 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $filter = $this->collectionListFilter(); $filter->condition('role', CollectionRoles::Trash->value); - $mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null)); + /** @var Mailbox[] $mailboxes */ + $mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null)); if (empty($mailboxes)) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } - $rootMailbox = reset($mailboxes); - if ($rootMailbox === false) { + $targetMailbox = reset($mailboxes); + if ($targetMailbox === false) { throw new \RuntimeException('No Trash collection configured or found for deletion'); } - $deleteTargetNative = $rootMailbox->name(); + $deleteTargetNative = $targetMailbox->name(); $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); } else { $deleteTargetNative = $deleteTarget; @@ -703,8 +718,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $uids = array_keys($sourceEntities); $mutations = match ($deleteMode) { - 'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids), - 'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids), + 'soft' => $this->remoteService->entityMove($deleteTargetNative, $sourceCollection, ...$uids), + 'hard' => $this->remoteService->entityDestroy($sourceCollection, ...$uids), }; foreach ($uids as $uid) { @@ -720,39 +735,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $list; } - public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array - { - // validate target belongs to this service - if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) { - throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target); - } - - // validate identifiers and group by collection - $sources = $this->groupEntitiesByCollection(...$sources); - - // copy entities on remote store and construct result map - $this->initialize(); - - $list = []; - - foreach ($sources as $sourceCollection => $sourceEntities) { - $uids = array_keys($sourceEntities); - - $mutations = $this->mailService->entityCopy($target->collection(), $sourceCollection, ...$uids); - - foreach ($uids as $uid) { - $mutatedUid = $mutations[$uid] ?? null; - $list[(string)$sourceEntities[$uid]] = [ - 'disposition' => $mutatedUid !== null ? 'copied' : 'error', - 'destination' => $target, - 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null, - ]; - } - } - - return $list; - } - public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array { // validate target belongs to this service @@ -771,7 +753,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($sources as $sourceCollection => $sourceEntities) { $uids = array_keys($sourceEntities); - $mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids); + $mutations = $this->remoteService->entityMove($target->collection(), $sourceCollection, ...$uids); foreach ($uids as $uid) { $mutatedUid = $mutations[$uid] ?? null; @@ -780,20 +762,44 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC 'destination' => $target, 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null, ]; + unset($sourceEntities[$uid]); } } return $list; } - public function entityDownload(EntityIdentifier $target, array|null $part): DownloadResult { + public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array + { + // validate target belongs to this service + if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) { + throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target); + } + + // validate identifiers and group by collection + $sources = $this->groupEntitiesByCollection(...$sources); + + // copy entities on remote store and construct result map $this->initialize(); - $collection = $target->collection(); - $uid = (int) $target->entity(); - $partId = isset($part['partId']) ? (string) $part['partId'] : null; + $list = []; + + foreach ($sources as $sourceCollection => $sourceEntities) { + $uids = array_keys($sourceEntities); - return $this->mailService->entityDownload($collection, $uid, $partId); + $mutations = $this->remoteService->entityCopy($target->collection(), $sourceCollection, ...$uids); + + foreach ($uids as $uid) { + $mutatedUid = $mutations[$uid] ?? null; + $list[(string)$sourceEntities[$uid]] = [ + 'disposition' => $mutatedUid !== null ? 'copied' : 'error', + 'destination' => $target, + 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null, + ]; + } + } + + return $list; } private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index ac041e2..d5c6c5c 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -46,9 +46,7 @@ use KTXF\Resource\Range\IRangeTally; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Sort\ISort; -use KTXF\Mail\Service\DownloadResult; -use KTXM\ProviderImap\Providers\CollectionResource; -use KTXM\ProviderImap\Providers\EntityResource; +use KTXF\Resource\BinaryResource; /** * IMAP Remote Mail Service @@ -307,7 +305,7 @@ class RemoteMailService * @param int $uid Message UID * @param string|null $partId MIME section (e.g. '1', '1.2'); null = full RFC 822 */ - public function entityDownload(string $collection, int $uid, ?string $partId = null): DownloadResult + public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource { $this->client->perform(new SelectCommand($collection, true)); @@ -338,7 +336,7 @@ class RemoteMailService $encoding ); - return new DownloadResult($filename, $mimeType, $stream); + return new BinaryResource($filename, $mimeType, $stream); } private function findBodyPart(MessagePart $root, string $partId): ?MessagePart @@ -537,6 +535,36 @@ class RemoteMailService } + public function entityCopy(string $targetCollection, string $sourceCollection, int ...$uids): array + { + if (empty($uids)) { + return []; + } + + $this->client->perform(new SelectCommand($sourceCollection, false)); + $response = $this->client->perform(new CopyCommand( + FetchTarget::uid(SequenceSet::items(...array_values($uids))), + $targetCollection, + )); + + if (!$response->isOk()) { + throw new ImapException('Failed to copy messages: ' . implode(', ', $response->responseCodes())); + } + + // construct operation result as a map of source UID to boolean or destination UID, depending on server support + $map = $response->copyUidMap(); + if ($map === []) { + $result = array_fill_keys(array_map('strval', $uids), true); + } else { + $result = array_fill_keys(array_map('strval', $uids), false); + foreach ($uids as $uid) { + $result[$uid] = $map[$uid] ?? false; + } + } + + return $result; + } + private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder { if ($filter === null || $filter->conditions() === []) {