Merge pull request 'feat: mail entity download' (#32) from feat/mail-entity-download into main
Some checks failed
Renovate / renovate (push) Failing after 2m0s

Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-05-29 03:24:54 +00:00
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\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 = [];
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

View File

@@ -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() === []) {