feat: mail entity download

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-05-28 23:24:23 -04:00
parent 186263c62a
commit 2647a55964
2 changed files with 119 additions and 85 deletions

View File

@@ -19,7 +19,7 @@ use KTXF\Mail\Service\ServiceCollectionMutableInterface;
use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXF\Mail\Service\ServiceEntityMutableInterface;
use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface;
use KTXF\Mail\Service\ServiceMutableInterface; use KTXF\Mail\Service\ServiceMutableInterface;
use KTXF\Mail\Service\DownloadResult; use KTXF\Resource\BinaryResource;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface;
use KTXF\Resource\Delta\Delta; use KTXF\Resource\Delta\Delta;
@@ -42,6 +42,7 @@ use KTXF\Mail\Collection\CollectionRoles;
use KTXF\Mail\Object\MessagePropertiesMutableInterface; use KTXF\Mail\Object\MessagePropertiesMutableInterface;
use KTXF\Resource\Identifier\EntityIdentifierInterface; use KTXF\Resource\Identifier\EntityIdentifierInterface;
use KTXM\ProviderImap\Providers\EntityResource; use KTXM\ProviderImap\Providers\EntityResource;
use KTXM\ProviderImap\Client\Mailbox;
/** /**
* IMAP Mail Service * IMAP Mail Service
@@ -72,8 +73,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
self::CAPABILITY_COLLECTION_SORT_LABEL, self::CAPABILITY_COLLECTION_SORT_LABEL,
self::CAPABILITY_COLLECTION_SORT_RANK, self::CAPABILITY_COLLECTION_SORT_RANK,
], ],
self::CAPABILITY_COLLECTION_EXTANT => true,
self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_FETCH => true,
self::CAPABILITY_COLLECTION_EXTANT => true,
self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_CREATE => true,
self::CAPABILITY_COLLECTION_UPDATE => true, self::CAPABILITY_COLLECTION_UPDATE => true,
self::CAPABILITY_COLLECTION_DELETE => true, self::CAPABILITY_COLLECTION_DELETE => true,
@@ -110,15 +111,15 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
self::CAPABILITY_ENTITY_COPY => false, self::CAPABILITY_ENTITY_COPY => false,
]; ];
private RemoteMailService $mailService; private RemoteMailService $remoteService;
public function __construct() {} public function __construct() {}
private function initialize(): void private function initialize(): void
{ {
if (!isset($this->mailService)) { if (!isset($this->remoteService)) {
$wrapper = RemoteService::freshClient($this); $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 = []; $list = [];
foreach ($this->mailService->collectionList($location, $filter, $sort) as $mailbox) { foreach ($this->remoteService->collectionList($location, $filter, $sort) as $mailbox) {
$resource = $this->collectionFresh(); $resource = $this->collectionFresh();
$resource->fromImap($mailbox); $resource->fromImap($mailbox);
$list[$mailbox->name()] = $resource; $list[$mailbox->name()] = $resource;
@@ -389,7 +390,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
foreach ($identifiers as $identifier) { foreach ($identifiers as $identifier) {
$key = (string) $identifier; $key = (string) $identifier;
$result = $this->mailService->collectionFetch($key); $result = $this->remoteService->collectionFetch($key);
$list[$key] = $result !== false; $list[$key] = $result !== false;
} }
@@ -401,7 +402,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{ {
$this->initialize(); $this->initialize();
$mailbox = $this->mailService->collectionFetch((string) $identifier); $mailbox = $this->remoteService->collectionFetch((string) $identifier);
if ($mailbox === null) { if ($mailbox === null) {
return null; return null;
} }
@@ -430,13 +431,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
if ($target !== null) { if ($target !== null) {
$path = $target->collection(); $path = $target->collection();
// Determine the hierarchy delimiter from an existing mailbox, default to '/' // 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); $rootMailbox = $mailboxes === [] ? null : reset($mailboxes);
$delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/'); $delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/');
$label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter); $label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter);
} }
$mailbox = $this->mailService->collectionCreate($label); $mailbox = $this->remoteService->collectionCreate($label);
$collection = $this->collectionFresh(); $collection = $this->collectionFresh();
$collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]); $collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]);
@@ -456,7 +457,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// In IMAP, "update" = rename to the new label // In IMAP, "update" = rename to the new label
$oldPath = (string) $target->collection(); $oldPath = (string) $target->collection();
$newName = $properties->getLabel(); $newName = $properties->getLabel();
$mailbox = $this->mailService->collectionRename($oldPath, $newName); $mailbox = $this->remoteService->collectionRename($oldPath, $newName);
$collection = $this->collectionFresh(); $collection = $this->collectionFresh();
$collection->fromImap($mailbox); $collection->fromImap($mailbox);
@@ -476,14 +477,14 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// Move to target collection (e.g. Trash) instead of deleting // Move to target collection (e.g. Trash) instead of deleting
if ($deleteMode === 'soft' && $deleteTarget !== null) { 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) { if ($deleteMode === 'soft' && $deleteTarget === null) {
$filter = $this->collectionListFilter(); $filter = $this->collectionListFilter();
$filter->condition('role', CollectionRoles::Trash->value); $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)) { if (empty($mailboxes)) {
throw new \RuntimeException('No Trash collection configured or found for deletion'); throw new \RuntimeException('No Trash collection configured or found for deletion');
} }
@@ -499,7 +500,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$result = match ($deleteMode) { $result = match ($deleteMode) {
'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target), '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; return $result;
} }
@@ -508,8 +509,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{ {
$this->initialize(); $this->initialize();
$sourceMailbox = $this->mailService->collectionFetch((string) $source->collection()); $sourceMailbox = $this->remoteService->collectionFetch((string) $source->collection());
$targetMailbox = $this->mailService->collectionFetch((string) $target->collection()); $targetMailbox = $this->remoteService->collectionFetch((string) $target->collection());
if ($sourceMailbox === null) { if ($sourceMailbox === null) {
throw new \RuntimeException('Source collection not found for move operation'); throw new \RuntimeException('Source collection not found for move operation');
} }
@@ -521,8 +522,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$targetDelimiter = $targetMailbox->delimiter() ?? '/'; $targetDelimiter = $targetMailbox->delimiter() ?? '/';
$extantPath = $sourceMailbox->name(); $extantPath = $sourceMailbox->name();
$freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end(explode($sourceDelimiter, $extantPath)); $extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter));
$mutatedMailbox = $this->mailService->collectionRename($extantPath, $freshPath);
$freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs);
$mutatedMailbox = $this->remoteService->collectionRename($extantPath, $freshPath);
$collection = $this->collectionFresh(); $collection = $this->collectionFresh();
$collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]); $collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]);
@@ -538,7 +542,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{ {
$this->initialize(); $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 = $this->entityFresh();
$resource->fromImap($message, $collection); $resource->fromImap($message, $collection);
yield $resource->urn() => $resource; yield $resource->urn() => $resource;
@@ -576,7 +580,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
foreach ($identifiers as $collection => $entities) { foreach ($identifiers as $collection => $entities) {
$uids = array_keys($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 = $this->entityFresh();
$resource->fromImap($message, $collection); $resource->fromImap($message, $collection);
yield $resource->urn() => $resource; 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 public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
{ {
return new Delta(signature: $signature); return new Delta(signature: $signature);
@@ -593,7 +607,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
{ {
$this->initialize(); $this->initialize();
$allUids = $this->mailService->entityList((string) $collection); $allUids = $this->remoteService->entityList((string) $collection);
$uidSet = array_flip($allUids); // int[] → [uid => index] $uidSet = array_flip($allUids); // int[] → [uid => index]
$extant = []; $extant = [];
foreach ($identifiers as $id) { 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) { foreach ($uids as $uid) {
$list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched']; $list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched'];
@@ -671,17 +685,18 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$filter = $this->collectionListFilter(); $filter = $this->collectionListFilter();
$filter->condition('role', CollectionRoles::Trash->value); $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)) { if (empty($mailboxes)) {
throw new \RuntimeException('No Trash collection configured or found for deletion'); throw new \RuntimeException('No Trash collection configured or found for deletion');
} }
$rootMailbox = reset($mailboxes); $targetMailbox = reset($mailboxes);
if ($rootMailbox === false) { if ($targetMailbox === false) {
throw new \RuntimeException('No Trash collection configured or found for deletion'); 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); $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
} else { } else {
$deleteTargetNative = $deleteTarget; $deleteTargetNative = $deleteTarget;
@@ -703,8 +718,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$uids = array_keys($sourceEntities); $uids = array_keys($sourceEntities);
$mutations = match ($deleteMode) { $mutations = match ($deleteMode) {
'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids), 'soft' => $this->remoteService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids), 'hard' => $this->remoteService->entityDestroy($sourceCollection, ...$uids),
}; };
foreach ($uids as $uid) { foreach ($uids as $uid) {
@@ -720,39 +735,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
return $list; 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 public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array
{ {
// validate target belongs to this service // validate target belongs to this service
@@ -771,7 +753,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
foreach ($sources as $sourceCollection => $sourceEntities) { foreach ($sources as $sourceCollection => $sourceEntities) {
$uids = array_keys($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) { foreach ($uids as $uid) {
$mutatedUid = $mutations[$uid] ?? null; $mutatedUid = $mutations[$uid] ?? null;
@@ -780,20 +762,44 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
'destination' => $target, 'destination' => $target,
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null, 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
]; ];
unset($sourceEntities[$uid]);
} }
} }
return $list; 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(); $this->initialize();
$collection = $target->collection(); $list = [];
$uid = (int) $target->entity();
$partId = isset($part['partId']) ? (string) $part['partId'] : null;
return $this->mailService->entityDownload($collection, $uid, $partId); foreach ($sources as $sourceCollection => $sourceEntities) {
$uids = array_keys($sourceEntities);
$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 private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array

View File

@@ -46,9 +46,7 @@ use KTXF\Resource\Range\IRangeTally;
use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Range\RangeTally;
use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\ISort;
use KTXF\Mail\Service\DownloadResult; use KTXF\Resource\BinaryResource;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\EntityResource;
/** /**
* IMAP Remote Mail Service * IMAP Remote Mail Service
@@ -307,7 +305,7 @@ class RemoteMailService
* @param int $uid Message UID * @param int $uid Message UID
* @param string|null $partId MIME section (e.g. '1', '1.2'); null = full RFC 822 * @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)); $this->client->perform(new SelectCommand($collection, true));
@@ -338,7 +336,7 @@ class RemoteMailService
$encoding $encoding
); );
return new DownloadResult($filename, $mimeType, $stream); return new BinaryResource($filename, $mimeType, $stream);
} }
private function findBodyPart(MessagePart $root, string $partId): ?MessagePart 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 private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder
{ {
if ($filter === null || $filter->conditions() === []) { if ($filter === null || $filter->conditions() === []) {