From 4e12d41ee926c90afa55884b58eb79b4ba3df825 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 7 Oct 2026 21:49:31 -0400 Subject: [PATCH] refactor: split service into ServiceBase and LiveService Signed-off-by: Sebastian Krupinski --- lib/Console/ConnectCommand.php | 4 +- lib/Console/DisconnectCommand.php | 4 +- lib/Console/DiscoverCommand.php | 4 +- lib/Console/HarmonizeCommand.php | 4 +- lib/Console/TestCommand.php | 4 +- .../{Service.php => LiveService.php} | 476 ++---------------- lib/Providers/Provider.php | 20 +- lib/Providers/ServiceBase.php | 404 +++++++++++++++ lib/Service/Cache/HarmonizationService.php | 16 +- lib/Service/Live/LiveMailService.php | 120 ++++- lib/Stores/ServiceStore.php | 6 +- tests/php/Unit/CollectionFetchTest.php | 48 +- tests/php/Unit/CommandCompletionTest.php | 6 +- tests/php/Unit/HarmonizationServiceTest.php | 10 +- tests/php/Unit/LiveMailServiceTest.php | 6 +- .../{ServiceTest.php => LiveServiceTest.php} | 13 +- tests/php/Unit/MailboxSelectionTest.php | 6 +- tests/php/Unit/MessageHarmonizationTest.php | 14 +- 18 files changed, 649 insertions(+), 516 deletions(-) rename lib/Providers/{Service.php => LiveService.php} (57%) create mode 100644 lib/Providers/ServiceBase.php rename tests/php/Unit/{ServiceTest.php => LiveServiceTest.php} (67%) diff --git a/lib/Console/ConnectCommand.php b/lib/Console/ConnectCommand.php index 10ed846..c1aad62 100644 --- a/lib/Console/ConnectCommand.php +++ b/lib/Console/ConnectCommand.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Console; use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\LiveService; use KTXM\ProviderImap\Providers\ServiceIdentityBasic; use KTXM\ProviderImap\Providers\ServiceLocation; use KTXC\Context\TenantContext; @@ -162,7 +162,7 @@ class ConnectCommand extends Command 'secret' => $password, ]); - $service = new Service(); + $service = new LiveService(); $service->setLocation($location); $service->setIdentity($identity); diff --git a/lib/Console/DisconnectCommand.php b/lib/Console/DisconnectCommand.php index c7d2e23..c2c273c 100644 --- a/lib/Console/DisconnectCommand.php +++ b/lib/Console/DisconnectCommand.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Console; use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXC\Context\TenantContext; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -96,7 +96,7 @@ class DisconnectCommand extends Command $choices = []; foreach ($services as $id => $service) { - $label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id; + $label = $service instanceof ServiceBase ? ($service->getLabel() ?? $id) : $id; $choices[$id] = "{$label} [{$id}]"; } diff --git a/lib/Console/DiscoverCommand.php b/lib/Console/DiscoverCommand.php index 9d80bfc..d22204c 100644 --- a/lib/Console/DiscoverCommand.php +++ b/lib/Console/DiscoverCommand.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Console; use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\LiveService; use KTXM\ProviderImap\Providers\ServiceIdentityBasic; use KTXM\ProviderImap\Providers\ServiceLocation; use KTXM\ProviderImap\Service\Discovery; @@ -191,7 +191,7 @@ class DiscoverCommand extends Command // ── Test before saving ─────────────────────────────────────────────── - $service = new Service(); + $service = new LiveService(); $service->setLocation($location); $service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([ 'identity' => $username, diff --git a/lib/Console/HarmonizeCommand.php b/lib/Console/HarmonizeCommand.php index 6dad3d8..ca7d8db 100644 --- a/lib/Console/HarmonizeCommand.php +++ b/lib/Console/HarmonizeCommand.php @@ -11,7 +11,7 @@ namespace KTXM\ProviderImap\Console; use KTXC\Context\TenantContext; use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXM\ProviderImap\Service\Cache\HarmonizationService; use KTXM\ProviderImap\Service\Cache\MessageDeltaService; use KTXM\ProviderImap\Stores\MailboxStore; @@ -104,7 +104,7 @@ class HarmonizeCommand extends Command $this->tenantContext->resolveIdentifier($tenantId); $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); - if (!$service instanceof Service) { + if (!$service instanceof ServiceBase) { $io->error(sprintf("Service '%s' not found.", $serviceId)); return Command::FAILURE; } diff --git a/lib/Console/TestCommand.php b/lib/Console/TestCommand.php index 465fb6c..4fd58e8 100644 --- a/lib/Console/TestCommand.php +++ b/lib/Console/TestCommand.php @@ -16,7 +16,7 @@ use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget; use KTXM\ProviderImap\Client\Message; use KTXM\ProviderImap\Client\Protocol\SequenceSet; use KTXM\ProviderImap\Providers\Provider; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXM\ProviderImap\Service\Live\LiveMailService; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -299,7 +299,7 @@ class TestCommand extends Command return $limit >= 0 ? $limit : $default; } - private function formatTarget(Service $service): string + private function formatTarget(ServiceBase $service): string { $location = $service->getLocation(); if ($location === null) { diff --git a/lib/Providers/Service.php b/lib/Providers/LiveService.php similarity index 57% rename from lib/Providers/Service.php rename to lib/Providers/LiveService.php index ef76702..aea60be 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/LiveService.php @@ -44,352 +44,22 @@ 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; -class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface +/** + * IMAP mail service that talks to the server directly. + * + * Holds the mail API rules (validation, delete modes, submission, move / copy + * result mapping); CachedService delegates writes here. + */ +class LiveService extends ServiceBase { - private const PROVIDER_IDENTIFIER = 'imap'; - - private ?string $serviceTenantId = null; - private ?string $serviceUserId = null; - private ?string $serviceIdentifier = null; - private ?string $serviceLabel = null; - private bool $serviceEnabled = false; - private array $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_FROM => 's:100:256:256', - self::CAPABILITY_ENTITY_FILTER_TO => '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 => 's:32:1:1', - self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1', - 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 => true, - self::CAPABILITY_ENTITY_MODIFY => true, - self::CAPABILITY_ENTITY_PATCH => true, - self::CAPABILITY_ENTITY_DELETE => true, - self::CAPABILITY_ENTITY_MOVE => true, - self::CAPABILITY_ENTITY_COPY => false, - 'EntityTransmit' => true, - ]; - - private LiveMailService $liveService; - - public function __construct() {} - - private function initialize(): void - { - if (!isset($this->liveService)) { - $this->liveService = new LiveMailService($this); - } - } - - 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'] ?? null; - $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::PROPERTY_TYPE => self::JSON_TYPE, - self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, - self::PROPERTY_IDENTIFIER => $this->serviceIdentifier, - self::PROPERTY_LABEL => $this->serviceLabel, - self::PROPERTY_ENABLED => $this->serviceEnabled, - self::PROPERTY_CAPABILITIES => $this->serviceAbilities, - self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, - self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, - self::PROPERTY_LOCATION => $this->location?->jsonSerialize(), - self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), - self::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::PROPERTY_ENABLED])) { - $this->setEnabled($data[self::PROPERTY_ENABLED]); - } - if (isset($data[self::PROPERTY_LABEL])) { - $this->setLabel($data[self::PROPERTY_LABEL]); - } - if (isset($data[self::PROPERTY_LOCATION])) { - $this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION])); - } - if (isset($data[self::PROPERTY_IDENTITY])) { - $this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY])); - } - if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) { - $value = $data[self::PROPERTY_PRIMARY_ADDRESS]; - $this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value)); - } - if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) { - $this->setSecondaryAddresses(array_map( - fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr), - $data[self::PROPERTY_SECONDARY_ADDRESSES] - )); - } - if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) { - $this->setAuxiliary($data[self::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 tenantIdentifier(): ?string - { - return $this->serviceTenantId; - } - - public function getLabel(): ?string - { - 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 Address::fromArray($this->primaryAddress); - } - - public function setPrimaryAddress(AddressInterface $value): static - { - $this->primaryAddress = $value->toArray(); - return $this; - } - - public function getSecondaryAddresses(): array - { - return array_map( - fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]) - , $this->secondaryAddresses); - } - - public function setSecondaryAddresses(array $addresses): static - { - $this->secondaryAddresses = array_map( - fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]), - $addresses - ); - return $this; - } - - public function hasAddress(string $address): bool - { - $address = strtolower(trim($address)); - - if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) { - return true; - } - foreach ($this->secondaryAddresses as $secondary) { - $secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary; - if (strtolower($secondaryAddr) === $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 $type = null, array $data = []): ServiceLocation - { - $loc = new ServiceLocation(); - $loc->jsonDeserialize($data); - return $loc; - } - - public function getIdentity(): ServiceIdentityBasic - { - return $this->identity; - } - - public function setIdentity(ResourceServiceIdentityInterface $identity): static - { - $this->identity = $identity; - return $this; - } - - public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic - { - $id = new ServiceIdentityBasic(); - $id->jsonDeserialize($data); - return $id; - } - - 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; - } + private LiveMailService $mailService; public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $this->initialize(); - $list = []; - - foreach ($this->liveService->collectionList($location, $filter, $sort) as $mailbox) { - $resource = $this->collectionFresh(); - $resource->fromImap($mailbox); - $list[$mailbox->name()] = $resource; - } - - return $list; - } - - 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] ?? []); + return $this->mailService->collectionList($location === null ? null : (string) $location, $filter, $sort); } public function collectionExtant(string|int ...$identifiers): array @@ -400,9 +70,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($identifiers as $identifier) { $key = (string) $identifier; - $result = $this->liveService->collectionFetch($key); - - $list[$key] = $result !== false; + $list[$key] = $this->mailService->collectionFetch($key) !== null; } return $list; @@ -412,20 +80,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - $mailbox = $this->liveService->collectionFetch((string) $identifier); - if ($mailbox === null) { - return null; - } - - $collection = $this->collectionFresh(); - $collection->fromImap($mailbox); - - return $collection; - } - - public function collectionFresh(): CollectionResource - { - return new CollectionResource($this->provider(), $this->identifier()); + return $this->mailService->collectionFetch((string) $identifier); } public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface @@ -441,18 +96,11 @@ 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->liveService->collectionList(null, null, null, '')); - $rootMailbox = $mailboxes === [] ? null : reset($mailboxes); - $delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/'); + $delimiter = $this->mailService->collectionDelimiter(); $label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter); } - $mailbox = $this->liveService->collectionCreate($label); - - $collection = $this->collectionFresh(); - $collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]); - - return $collection; + return $this->mailService->collectionCreate($label, $delimiter ?? null); } public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface @@ -467,11 +115,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC // In IMAP, "update" = rename to the new label $oldPath = (string) $target->collection(); $newName = $properties->getLabel(); - $mailbox = $this->liveService->collectionRename($oldPath, $newName); - - $collection = $this->collectionFresh(); - $collection->fromImap($mailbox); - return $collection; + return $this->mailService->collectionRename($oldPath, $newName); } public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true @@ -494,7 +138,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $result = match ($deleteMode) { 'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target), - 'hard' => $this->liveService->collectionDestroy((string) $target->collection()), + 'hard' => $this->mailService->collectionDestroy((string) $target->collection()), }; return $result; } @@ -503,8 +147,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - $sourceMailbox = $this->liveService->collectionFetch((string) $source->collection()); - $targetMailbox = $this->liveService->collectionFetch((string) $target->collection()); + $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'); } @@ -512,19 +156,15 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC throw new \RuntimeException('Target collection not found for move operation'); } - $sourceDelimiter = $sourceMailbox->delimiter() ?? '/'; - $targetDelimiter = $targetMailbox->delimiter() ?? '/'; + $sourceDelimiter = $sourceMailbox->getProperties()->getDelimiter() ?: '/'; + $targetDelimiter = $targetMailbox->getProperties()->getDelimiter() ?: '/'; - $extantPath = $sourceMailbox->name(); + $extantPath = (string) $sourceMailbox->identifier(); $extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter)); - $freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs); + $freshPath = rtrim((string) $targetMailbox->identifier(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs); - $mutatedMailbox = $this->liveService->collectionRename($extantPath, $freshPath); - - $collection = $this->collectionFresh(); - $collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]); - return $collection; + return $this->mailService->collectionRename($extantPath, $freshPath, $targetDelimiter); } public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array @@ -536,31 +176,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - foreach ($this->liveService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) { - $resource = $this->entityFresh(); - $resource->fromImap($message, $collection); + foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $resource) { yield $resource->urn() => $resource; } } - 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 match ($type) { - RangeType::TALLY => new RangeTally(), - default => new Range(), - }; - } - public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array { return iterator_to_array($this->entityFetchStream(...$identifiers), true); @@ -574,9 +194,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($identifiers as $collection => $entities) { $uids = array_keys($entities); - foreach ($this->liveService->entityFetch((string) $collection, null, ...$uids) as $uid => $message) { - $resource = $this->entityFresh(); - $resource->fromImap($message, $collection); + foreach ($this->mailService->entityFetch((string) $collection, ...$uids) as $resource) { yield $resource->urn() => $resource; } } @@ -589,7 +207,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $uid = (int) $target->entity(); $partId = isset($part['partId']) ? (string) $part['partId'] : null; - return $this->liveService->entityDownload($collection, $uid, $partId); + return $this->mailService->entityDownload($collection, $uid, $partId); } public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta @@ -607,7 +225,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC static fn (int $uid): bool => $uid > 0, )); - $existing = array_flip($this->liveService->entityExtant((string) $collection, ...$uids)); + $existing = array_flip($this->mailService->entityExtant((string) $collection, ...$uids)); $extant = []; foreach ($identifiers as $id) { @@ -616,11 +234,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $extant; } - public function entityFresh(): EntityResource - { - return new EntityResource($this->provider(), $this->identifier()); - } - public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult { if ($message === null) { @@ -640,7 +253,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } $this->initialize(); - $smtp = $this->liveService->smtpClient(); + $smtp = $this->mailService->smtpClient(); try { $queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw); } finally { @@ -661,7 +274,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $this->initialize(); $sentCollection = $this->resolveSentCollection(); if ($sentCollection !== null) { - $uid = $this->liveService->entityCreate($sentCollection, $raw, ['\\Seen']); + $uid = $this->mailService->entityCreate($sentCollection, $raw, ['\\Seen']); if ($uid !== null && $uid > 0) { $sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid); } @@ -680,7 +293,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC && $source->service() === $this->identifier()) { try { $this->initialize(); - $this->liveService->entityDestroy($source->collection(), (int) $source->entity()); + $this->mailService->entityDestroy($source->collection(), (int) $source->entity()); } catch (\Throwable) { // ignore — the message was already delivered } @@ -703,7 +316,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $this->initialize(); [$nativeMessage, $nativeFlags] = $this->messagePayload($properties, $options); - $created = $this->liveService->entityCreate( + $created = $this->mailService->entityCreate( (string)$target->collection(), $nativeMessage, $nativeFlags, @@ -724,7 +337,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $this->initialize(); [$nativeMessage, $nativeFlags] = $this->messagePayload($properties); - $modified = $this->liveService->entityReplace( + $modified = $this->mailService->entityReplace( (string)$target->collection(), (int)$target->entity(), $nativeMessage, @@ -760,7 +373,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } } - $mutations = $this->liveService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids); + $mutations = $this->mailService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids); foreach ($uids as $uid) { $list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched']; @@ -807,8 +420,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $uids = array_keys($sourceEntities); $mutations = match ($deleteMode) { - 'soft' => $this->liveService->entityMove($deleteTargetNative, $sourceCollection, ...$uids), - 'hard' => $this->liveService->entityDestroy($sourceCollection, ...$uids), + 'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids), + 'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids), }; foreach ($uids as $uid) { @@ -842,7 +455,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($sources as $sourceCollection => $sourceEntities) { $uids = array_keys($sourceEntities); - $mutations = $this->liveService->entityMove($target->collection(), $sourceCollection, ...$uids); + $mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids); foreach ($uids as $uid) { $mutatedUid = $mutations[$uid] ?? null; @@ -876,7 +489,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($sources as $sourceCollection => $sourceEntities) { $uids = array_keys($sourceEntities); - $mutations = $this->liveService->entityCopy($target->collection(), $sourceCollection, ...$uids); + $mutations = $this->mailService->entityCopy($target->collection(), $sourceCollection, ...$uids); foreach ($uids as $uid) { $mutatedUid = $mutations[$uid] ?? null; @@ -899,13 +512,9 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC $filter = $this->collectionListFilter(); $filter->condition('role', $role->value); - /** @var Mailbox[] $mailboxes */ - $mailboxes = iterator_to_array($this->liveService->collectionList(null, $filter, null)); - if ($mailboxes === []) { - return null; - } - $mailbox = reset($mailboxes); - return $mailbox === false ? null : $mailbox->name(); + $collections = $this->mailService->collectionList(null, $filter, null); + + return $collections === [] ? null : (string) array_key_first($collections); } /** @@ -988,4 +597,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return [(new MessageBuilder())->build($properties), $flags]; } + + protected function initialize(): void + { + if (!isset($this->mailService)) { + $this->mailService = new LiveMailService($this); + } + } } diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php index 28079bb..d9ec9ec 100644 --- a/lib/Providers/Provider.php +++ b/lib/Providers/Provider.php @@ -106,7 +106,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, return $list; } - public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?ServiceBase { $serviceData = $this->serviceStore->fetch($tenantId, $userId, $identifier); if ($serviceData === null) { @@ -116,9 +116,9 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, return $serviceInstance; } - public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service + public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?ServiceBase { - /** @var Service[] $services */ + /** @var ServiceBase[] $services */ $services = $this->serviceList($tenantId, $userId); foreach ($services as $service) { if ($service->hasAddress($address)) { @@ -133,14 +133,14 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, return $this->serviceStore->extant($tenantId, $userId, $identifiers); } - public function serviceFresh(): Service + public function serviceFresh(): ServiceBase { - return new Service(); + return new LiveService(); } public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { - if (!($service instanceof Service)) { + if (!($service instanceof ServiceBase)) { throw new \InvalidArgumentException('Service must be instance of IMAP Service'); } @@ -150,7 +150,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { - if (!($service instanceof Service)) { + if (!($service instanceof ServiceBase)) { throw new \InvalidArgumentException('Service must be instance of IMAP Service'); } @@ -160,7 +160,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool { - if (!($service instanceof Service)) { + if (!($service instanceof ServiceBase)) { return false; } @@ -185,7 +185,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, $startTime = microtime(true); try { - if (!($service instanceof Service)) { + if (!($service instanceof ServiceBase)) { throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); } // augment the service with any provided test options (e.g. override location or credentials) @@ -193,7 +193,7 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, // Attempt to authenticate and list mailboxes as a connectivity check $live = new LiveMailService($service); - $mailboxes = iterator_to_array($live->collectionList()); + $mailboxes = $live->collectionList(); $latency = (int) round((microtime(true) - $startTime) * 1000); diff --git a/lib/Providers/ServiceBase.php b/lib/Providers/ServiceBase.php new file mode 100644 index 0000000..55210a2 --- /dev/null +++ b/lib/Providers/ServiceBase.php @@ -0,0 +1,404 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Providers; + +use Generator; +use KTXF\Mail\Collection\CollectionBaseInterface; +use KTXF\Mail\Collection\CollectionPropertiesBaseInterface; +use KTXF\Mail\Object\Address; +use KTXF\Mail\Object\AddressInterface; +use KTXF\Mail\Service\ServiceBaseInterface; +use KTXF\Mail\Service\ServiceCollectionMutableInterface; +use KTXF\Mail\Service\ServiceEntityMutableInterface; +use KTXF\Mail\Service\ServiceEntitySubmitInterface; +use KTXF\Mail\Service\ServiceConfigurableInterface; +use KTXF\Mail\Service\ServiceMutableInterface; +use KTXF\Mail\Submission\EntitySubmitResult; +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\Range\IRange; +use KTXF\Resource\Range\Range; +use KTXF\Resource\Range\RangeTally; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; +use KTXF\Resource\Sort\Sort; +use KTXM\ProviderImap\Providers\ServiceIdentityBasic; +use KTXM\ProviderImap\Providers\ServiceLocation; +use KTXM\ProviderImap\Mime\MessageBuilder; +use KTXM\ProviderImap\Providers\CollectionResource; +use KTXF\Mail\Collection\CollectionRoles; +use KTXF\Mail\Object\MessagePropertiesMutableInterface; +use KTXF\Resource\Identifier\EntityIdentifierInterface; +use KTXM\ProviderImap\Providers\EntityResource; + +/** + * IMAP mail service: account configuration and serialization. + * + * The mail API (collection / entity methods, declared by the implemented + * interfaces) is left to the concrete classes: LiveService talks to the IMAP + * server, CachedService serves reads from the cache. The Provider picks the + * class by mode when loading a service. + */ +abstract class ServiceBase implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface +{ + protected const PROVIDER_IDENTIFIER = 'imap'; + + protected ?string $serviceTenantId = null; + protected ?string $serviceUserId = null; + protected ?string $serviceIdentifier = null; + protected ?string $serviceLabel = null; + protected bool $serviceEnabled = false; + protected array $primaryAddress = []; + protected array $secondaryAddresses = []; + protected ?ServiceLocation $location = null; + protected ?ServiceIdentityBasic $identity = null; + protected array $auxiliary = []; + + protected 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_FROM => 's:100:256:256', + self::CAPABILITY_ENTITY_FILTER_TO => '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 => 's:32:1:1', + self::CAPABILITY_ENTITY_FILTER_DATE_AFTER => 's:32:1:1', + 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 => true, + self::CAPABILITY_ENTITY_MODIFY => true, + self::CAPABILITY_ENTITY_PATCH => true, + self::CAPABILITY_ENTITY_DELETE => true, + self::CAPABILITY_ENTITY_MOVE => true, + self::CAPABILITY_ENTITY_COPY => false, + 'EntityTransmit' => true, + ]; + + public function __construct() {} + + 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'] ?? null; + $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::PROPERTY_TYPE => self::JSON_TYPE, + self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, + self::PROPERTY_IDENTIFIER => $this->serviceIdentifier, + self::PROPERTY_LABEL => $this->serviceLabel, + self::PROPERTY_ENABLED => $this->serviceEnabled, + self::PROPERTY_CAPABILITIES => $this->serviceAbilities, + self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, + self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, + self::PROPERTY_LOCATION => $this->location?->jsonSerialize(), + self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), + self::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::PROPERTY_ENABLED])) { + $this->setEnabled($data[self::PROPERTY_ENABLED]); + } + if (isset($data[self::PROPERTY_LABEL])) { + $this->setLabel($data[self::PROPERTY_LABEL]); + } + if (isset($data[self::PROPERTY_LOCATION])) { + $this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION])); + } + if (isset($data[self::PROPERTY_IDENTITY])) { + $this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY])); + } + if (isset($data[self::PROPERTY_PRIMARY_ADDRESS])) { + $value = $data[self::PROPERTY_PRIMARY_ADDRESS]; + $this->setPrimaryAddress(is_array($value) ? Address::fromArray($value) : new Address((string)$value)); + } + if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) { + $this->setSecondaryAddresses(array_map( + fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr), + $data[self::PROPERTY_SECONDARY_ADDRESSES] + )); + } + if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) { + $this->setAuxiliary($data[self::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 tenantIdentifier(): ?string + { + return $this->serviceTenantId; + } + + public function getLabel(): ?string + { + 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 Address::fromArray($this->primaryAddress); + } + + public function setPrimaryAddress(AddressInterface $value): static + { + $this->primaryAddress = $value->toArray(); + return $this; + } + + public function getSecondaryAddresses(): array + { + return array_map( + fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]) + , $this->secondaryAddresses); + } + + public function setSecondaryAddresses(array $addresses): static + { + $this->secondaryAddresses = array_map( + fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]), + $addresses + ); + return $this; + } + + public function hasAddress(string $address): bool + { + $address = strtolower(trim($address)); + + if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) { + return true; + } + foreach ($this->secondaryAddresses as $secondary) { + $secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary; + if (strtolower($secondaryAddr) === $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 $type = null, array $data = []): ServiceLocation + { + $loc = new ServiceLocation(); + $loc->jsonDeserialize($data); + return $loc; + } + + public function getIdentity(): ServiceIdentityBasic + { + return $this->identity; + } + + public function setIdentity(ResourceServiceIdentityInterface $identity): static + { + $this->identity = $identity; + return $this; + } + + public function freshIdentity(?string $type = null, array $data = []): ServiceIdentityBasic + { + $id = new ServiceIdentityBasic(); + $id->jsonDeserialize($data); + return $id; + } + + 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 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 collectionFresh(): CollectionResource + { + return new CollectionResource($this->provider(), $this->identifier()); + } + + 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 match ($type) { + RangeType::TALLY => new RangeTally(), + default => new Range(), + }; + } + + public function entityFresh(): EntityResource + { + return new EntityResource($this->provider(), $this->identifier()); + } +} diff --git a/lib/Service/Cache/HarmonizationService.php b/lib/Service/Cache/HarmonizationService.php index 44017a0..cf9518c 100644 --- a/lib/Service/Cache/HarmonizationService.php +++ b/lib/Service/Cache/HarmonizationService.php @@ -10,10 +10,9 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Service\Cache; use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions; -use KTXM\ProviderImap\Providers\CollectionResource; use KTXM\ProviderImap\Providers\EntityResource; use KTXM\ProviderImap\Providers\MessageProperties; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXM\ProviderImap\Service\Live\LiveMailService; use KTXM\ProviderImap\Stores\MailboxStore; use KTXM\ProviderImap\Stores\MessageFileStore; @@ -35,7 +34,7 @@ class HarmonizationService /** Lifetime of a mailbox harmonization lock in seconds; long enough for a large first run */ public const LOCK_TTL = 900; - private ?Service $service = null; + private ?ServiceBase $service = null; private ?LiveMailService $live = null; public function __construct( @@ -48,7 +47,7 @@ class HarmonizationService /** * Bind a service; returns a new instance so the shared one stays unbound. */ - public function for(Service $service, ?LiveMailService $live = null): static + public function for(ServiceBase $service, ?LiveMailService $live = null): static { $bound = clone $this; $bound->service = $service; @@ -72,10 +71,7 @@ class HarmonizationService $tenantId = (string) $service->tenantIdentifier(); $serviceId = (string) $service->identifier(); - $remote = []; - foreach ($live->collectionList() as $name => $mailbox) { - $remote[(string) $name] = (new CollectionResource($service->provider(), $serviceId))->fromImap($mailbox); - } + $remote = $live->collectionList(); $cached = $this->mailboxStore->list($serviceId); $result = ['added' => [], 'updated' => [], 'removed' => []]; @@ -158,7 +154,7 @@ class HarmonizationService try { $state = $this->mailboxStore->state($serviceId, $mailbox); - $selected = $live->collectionFetch($mailbox) + $selected = $live->mailboxFetch($mailbox) ?? throw new RuntimeException("Mailbox not found on server: {$mailbox}"); $uidValidity = $selected->uidValidity() ?? throw new RuntimeException("Server did not report UIDVALIDITY for mailbox: {$mailbox}"); @@ -239,7 +235,7 @@ class HarmonizationService $options = FetchOptions::message()->withBodyText(MessageIngestor::BODY_TEXT_LIMIT); $ingested = []; - foreach ($live->entityFetch($mailbox, $options, ...$uids) as $message) { + foreach ($live->messageFetch($mailbox, $options, ...$uids) as $message) { $entity = (new EntityResource($service->provider(), $serviceId))->fromImap($message, $mailbox); array_push($ingested, ...$this->ingestor->ingest($tenantId, $serviceId, $uidValidity, $entity)); } diff --git a/lib/Service/Live/LiveMailService.php b/lib/Service/Live/LiveMailService.php index 9702c9e..9736767 100644 --- a/lib/Service/Live/LiveMailService.php +++ b/lib/Service/Live/LiveMailService.php @@ -33,6 +33,8 @@ use KTXM\ProviderImap\Client\Protocol\IdentifierMode; use KTXM\ProviderImap\Client\ImapException; use KTXM\ProviderImap\Client\Protocol\Command\Argument\ListReturnOptions; use KTXM\ProviderImap\Client\Mailbox; +use KTXM\ProviderImap\Providers\CollectionResource; +use KTXM\ProviderImap\Providers\EntityResource; use KTXM\ProviderImap\Client\Message; use KTXM\ProviderImap\Client\MessageAddress; use KTXM\ProviderImap\Client\MessagePart; @@ -49,7 +51,7 @@ use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Sort\ISort; use KTXF\Resource\BinaryResource; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXM\ProviderImap\Smtp\Client as SmtpClient; use RuntimeException; @@ -69,7 +71,7 @@ class LiveMailService private ?Mailbox $selected = null; public function __construct( - private readonly Service $service, + private readonly ServiceBase $service, ) {} /** @@ -140,11 +142,48 @@ class LiveMailService } /** - * list of collections in remote storage - * - * @since Release 1.0.0 - */ - public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator + * List the collections of the account, keyed by mailbox name. + * + * @return array + */ + public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array + { + $list = []; + foreach ($this->mailboxList($location, $filter, $sort) as $name => $mailbox) { + $list[(string) $name] = $this->collectionResource($mailbox); + } + + return $list; + } + + /** + * Fetch a single collection by its mailbox name; null when it does not exist. + */ + public function collectionFetch(string $identifier): ?CollectionResource + { + $mailbox = $this->mailboxFetch($identifier); + + return $mailbox === null ? null : $this->collectionResource($mailbox); + } + + /** + * Hierarchy delimiter of the account, from `LIST "" ""` (no mailboxes are listed); "/" when unknown. + */ + public function collectionDelimiter(): string + { + foreach ($this->imapClient()->perform(new ListCommand('', '')) as $mailbox) { + return $mailbox->delimiter() ?: '/'; + } + + return '/'; + } + + /** + * List the IMAP mailboxes of the account (with status), keyed by name. + * + * @return Generator + */ + public function mailboxList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator { // Prepare location filter if (!empty($location)) { @@ -196,11 +235,11 @@ class LiveMailService } /** - * Fetch a single mailbox by its full name. + * Fetch a single IMAP mailbox (with status) by its full name. * * Returns null when no mailbox matching $identifier is found. */ - public function collectionFetch(string $identifier): ?Mailbox + public function mailboxFetch(string $identifier): ?Mailbox { // LIST-STATUS (RFC 5819) returns the status with the LIST response in one round trip $listStatus = $this->imapClient()->hasCapability('LIST-STATUS'); @@ -225,38 +264,38 @@ class LiveMailService } /** - * Create a new IMAP mailbox and return it. + * Create a new IMAP mailbox and return it as a collection resource. * * If the server-side LIST cannot confirm the new mailbox (e.g., immediate * consistency), a lightweight stub resource is returned instead. */ - public function collectionCreate(string $name): Mailbox + public function collectionCreate(string $name, ?string $delimiter = null): CollectionResource { $this->imapClient()->perform(new CreateCommand($name)); // Attempt to refetch the new mailbox from the server - $mailbox = $this->collectionFetch($name); + $mailbox = $this->mailboxFetch($name); if ($mailbox === null) { throw new ImapException('Failed to create mailbox: ' . $name); } - return $mailbox; + return $this->collectionResource($mailbox, $delimiter); } /** * Rename a mailbox and return the updated resource. */ - public function collectionRename(string $oldName, string $newName): Mailbox + public function collectionRename(string $oldName, string $newName, ?string $delimiter = null): CollectionResource { $this->imapClient()->perform(new RenameCommand($oldName, $newName)); - $mailbox = $this->collectionFetch($newName); + $mailbox = $this->mailboxFetch($newName); if ($mailbox === null) { throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName); } - return $mailbox; + return $this->collectionResource($mailbox, $delimiter); } /** @@ -354,11 +393,36 @@ class LiveMailService } /** - * Retrieve a list of messages in a mailbox matching the given filter, sorted and paginated as requested. + * List the entities of a mailbox matching the given filter, sorted and paginated as requested. * - * @return Message[] list of messages matching the filter, sorted and paginated as requested + * @return Generator keyed by UID */ public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator + { + foreach ($this->messageList($collection, $filter, $sort, $range) as $message) { + $entity = $this->entityResource($message, $collection); + yield (int) $entity->identifier() => $entity; + } + } + + /** + * Fetch entities by UID (full TEXT). + * + * @return Generator keyed by UID + */ + public function entityFetch(string $collection, int ...$uids): Generator + { + foreach ($this->messageFetch($collection, null, ...$uids) as $uid => $message) { + yield $uid => $this->entityResource($message, $collection); + } + } + + /** + * List IMAP messages of a mailbox matching the given filter, sorted and paginated as requested. + * + * @return Generator + */ + public function messageList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator { // text parts normally precede attachments, so capping TEXT avoids transferring attachment bytes $options = FetchOptions::message()->withBodyText(self::LIST_BODY_TEXT_LIMIT); @@ -387,16 +451,16 @@ class LiveMailService return []; } - yield from $this->entityFetch($collection, $options, ...$uids); + yield from $this->messageFetch($collection, $options, ...$uids); } /** - * Fetch one or more messages by UID and return EntityResource objects. + * Fetch one or more messages by UID as IMAP messages. * * @param int ...$uids - * @return Message[] keyed by UID + * @return Generator keyed by UID */ - public function entityFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator + public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator { if (empty($uids)) { return []; @@ -1181,6 +1245,18 @@ class LiveMailService return $normalized; } + private function collectionResource(Mailbox $mailbox, ?string $delimiter = null): CollectionResource + { + return (new CollectionResource($this->service->provider(), $this->service->identifier())) + ->fromImap($mailbox, ['delimiter' => $delimiter]); + } + + private function entityResource(Message $message, string $collection): EntityResource + { + return (new EntityResource($this->service->provider(), $this->service->identifier())) + ->fromImap($message, $collection); + } + /** * Select a mailbox, reusing the current selection when possible. * diff --git a/lib/Stores/ServiceStore.php b/lib/Stores/ServiceStore.php index 6350cef..0489d8d 100644 --- a/lib/Stores/ServiceStore.php +++ b/lib/Stores/ServiceStore.php @@ -12,7 +12,7 @@ namespace KTXM\ProviderImap\Stores; use KTXC\Db\DataStore; use KTXF\Security\Crypto; use KTXF\Utile\UUID; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; /** * IMAP Service Store @@ -114,7 +114,7 @@ class ServiceStore /** * Create a new service */ - public function create(string $tenantId, string $userId, Service $service): array + public function create(string $tenantId, string $userId, ServiceBase $service): array { $document = $service->toStore(); @@ -136,7 +136,7 @@ class ServiceStore /** * Modify an existing service */ - public function modify(string $tenantId, string $userId, Service $service): array + public function modify(string $tenantId, string $userId, ServiceBase $service): array { $serviceId = $service->identifier(); if (empty($serviceId)) { diff --git a/tests/php/Unit/CollectionFetchTest.php b/tests/php/Unit/CollectionFetchTest.php index b2c0f76..335bcbf 100644 --- a/tests/php/Unit/CollectionFetchTest.php +++ b/tests/php/Unit/CollectionFetchTest.php @@ -6,7 +6,9 @@ namespace KTXT\ProviderImap\Tests\Unit; use KTXM\ProviderImap\Client\{Client, ConnectionConfig}; use KTXM\ProviderImap\Client\Transport\{ConnectionInterface, ConnectionFactoryInterface}; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\CollectionResource; +use KTXM\ProviderImap\Providers\ServiceBase; +use KTXM\ProviderImap\Providers\LiveService; use KTXM\ProviderImap\Service\Live\LiveMailService; use PHPUnit\Framework\TestCase; @@ -16,7 +18,7 @@ final class CollectionFetchTest extends TestCase public function testWithListStatusUsesOneRoundTrip(): void { - $mailbox = $this->service(listStatus: true)->collectionFetch('INBOX'); + $mailbox = $this->service(listStatus: true)->mailboxFetch('INBOX'); $this->assertCount(1, $this->commands); $this->assertStringContainsString('RETURN (STATUS', $this->commands[0]); @@ -27,7 +29,7 @@ final class CollectionFetchTest extends TestCase public function testWithoutListStatusFallsBackToStatus(): void { - $mailbox = $this->service(listStatus: false)->collectionFetch('INBOX'); + $mailbox = $this->service(listStatus: false)->mailboxFetch('INBOX'); $this->assertCount(2, $this->commands); $this->assertStringNotContainsString('RETURN', $this->commands[0]); @@ -39,7 +41,36 @@ final class CollectionFetchTest extends TestCase public function testUnknownMailboxIsNull(): void { - $this->assertNull($this->service(listStatus: false, exists: false)->collectionFetch('Missing')); + $this->assertNull($this->service(listStatus: false, exists: false)->mailboxFetch('Missing')); + } + + public function testCollectionsAreResourcesKeyedByName(): void + { + $service = $this->service(listStatus: true); + + $list = $service->collectionList(); + $collection = $service->collectionFetch('INBOX'); + + $this->assertSame(['INBOX'], array_keys($list)); + $this->assertInstanceOf(CollectionResource::class, $list['INBOX']); + $this->assertInstanceOf(CollectionResource::class, $collection); + $this->assertSame('INBOX', $collection?->identifier()); + $this->assertNull($this->service(listStatus: true, exists: false)->collectionFetch('Missing')); + } + + public function testDelimiterIsReadWithoutListingMailboxes(): void + { + $this->assertSame('/', $this->service(listStatus: false)->collectionDelimiter()); + $this->assertSame(['LIST "" ""'], $this->commands); + } + + public function testCollectionExtantReportsMissingMailboxes(): void + { + $service = new LiveService(); + $live = $this->service(listStatus: true, exists: false); + (new \ReflectionProperty(LiveService::class, 'mailService'))->setValue($service, $live); + + $this->assertSame(['Missing' => false], $service->collectionExtant('Missing')); } private function service(bool $listStatus, bool $exists = true): LiveMailService @@ -57,6 +88,11 @@ final class CollectionFetchTest extends TestCase } else { $this->commands[] = $command; } + if ($operation === 'LIST' && $command === 'LIST "" ""') { + $lines[] = "* LIST (\\Noselect) \"/\" \"\"\r\n"; + $lines[] = "$tag OK Completed\r\n"; + return; + } if ($operation === 'LIST') { if (!$listStatus && str_contains($command, 'RETURN')) { $lines[] = "$tag BAD Unknown RETURN option\r\n"; @@ -80,8 +116,8 @@ final class CollectionFetchTest extends TestCase $client = new Client($factory); $client->connect(new ConnectionConfig('localhost')); - return new class($this->createStub(Service::class), $client) extends LiveMailService { - public function __construct(Service $service, private readonly Client $client) + return new class($this->createStub(ServiceBase::class), $client) extends LiveMailService { + public function __construct(ServiceBase $service, private readonly Client $client) { parent::__construct($service); } diff --git a/tests/php/Unit/CommandCompletionTest.php b/tests/php/Unit/CommandCompletionTest.php index a1991c1..6845fcb 100644 --- a/tests/php/Unit/CommandCompletionTest.php +++ b/tests/php/Unit/CommandCompletionTest.php @@ -8,7 +8,7 @@ use KTXM\ProviderImap\Client\{Client, ConnectionConfig, ImapException}; use KTXM\ProviderImap\Client\Protocol\Command\CreateCommand; use KTXM\ProviderImap\Client\Result\CommandCompletion; use KTXM\ProviderImap\Client\Transport\{ConnectionInterface, ConnectionFactoryInterface}; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXM\ProviderImap\Service\Live\LiveMailService; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -48,8 +48,8 @@ final class CommandCompletionTest extends TestCase private function service(Client $client): LiveMailService { - return new class($this->createStub(Service::class), $client) extends LiveMailService { - public function __construct(Service $service, private readonly Client $client) + return new class($this->createStub(ServiceBase::class), $client) extends LiveMailService { + public function __construct(ServiceBase $service, private readonly Client $client) { parent::__construct($service); } diff --git a/tests/php/Unit/HarmonizationServiceTest.php b/tests/php/Unit/HarmonizationServiceTest.php index ffd0fa0..6d407f4 100644 --- a/tests/php/Unit/HarmonizationServiceTest.php +++ b/tests/php/Unit/HarmonizationServiceTest.php @@ -11,7 +11,7 @@ use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Sort\ISort; use KTXM\ProviderImap\Client\Mailbox; use KTXM\ProviderImap\Providers\CollectionResource; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\LiveService; use KTXM\ProviderImap\Service\Cache\HarmonizationService; use KTXM\ProviderImap\Service\Cache\MessageIngestor; use KTXM\ProviderImap\Service\Live\LiveMailService; @@ -146,7 +146,7 @@ final class HarmonizationServiceTest extends TestCase $this->calls[] = ['files.deleteByMailbox', $tid, $sid, $name]; }); - $service = (new Service())->fromStore(['tid' => 'tenant', 'sid' => 'svc']); + $service = (new LiveService())->fromStore(['tid' => 'tenant', 'sid' => 'svc']); $live = new HarmonizationServiceTestLiveStub($service); $live->mailboxes = array_map(static fn (string $name): Mailbox => new Mailbox($name, '/', []), $remote); @@ -166,10 +166,12 @@ final class HarmonizationServiceTestLiveStub extends LiveMailService /** @var Mailbox[] */ public array $mailboxes = []; - public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator + public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array { + $list = []; foreach ($this->mailboxes as $mailbox) { - yield $mailbox->name() => $mailbox; + $list[$mailbox->name()] = (new CollectionResource('imap', 'svc'))->fromImap($mailbox); } + return $list; } } diff --git a/tests/php/Unit/LiveMailServiceTest.php b/tests/php/Unit/LiveMailServiceTest.php index a78ecfb..614677c 100644 --- a/tests/php/Unit/LiveMailServiceTest.php +++ b/tests/php/Unit/LiveMailServiceTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace KTXT\ProviderImap\Tests\Unit; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXM\ProviderImap\Service\Live\LiveMailService; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -12,7 +12,7 @@ use RuntimeException; final class LiveMailServiceTest extends TestCase { public function testEntityReplaceReturnsAuthoritativeReplacementUid(): void { - $service = new LiveMailServiceStub($this->createStub(Service::class)); + $service = new LiveMailServiceStub($this->createStub(ServiceBase::class)); $service->appendUid = 202; $service->destroyResults = [[101 => true]]; @@ -28,7 +28,7 @@ final class LiveMailServiceTest extends TestCase { } public function testEntityReplaceRemovesReplacementWhenOldUidCannotBeDeleted(): void { - $service = new LiveMailServiceStub($this->createStub(Service::class)); + $service = new LiveMailServiceStub($this->createStub(ServiceBase::class)); $service->appendUid = 202; $service->destroyResults = [ [101 => false], diff --git a/tests/php/Unit/ServiceTest.php b/tests/php/Unit/LiveServiceTest.php similarity index 67% rename from tests/php/Unit/ServiceTest.php rename to tests/php/Unit/LiveServiceTest.php index f040ac5..bf0c91b 100644 --- a/tests/php/Unit/ServiceTest.php +++ b/tests/php/Unit/LiveServiceTest.php @@ -4,19 +4,20 @@ declare(strict_types=1); namespace KTXT\ProviderImap\Tests\Unit; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; +use KTXM\ProviderImap\Providers\LiveService; use KTXM\ProviderImap\Service\Live\LiveMailService; use PHPUnit\Framework\TestCase; use ReflectionProperty; -final class ServiceTest extends TestCase +final class LiveServiceTest extends TestCase { public function testEntityExtantReportsExistenceOfEachIdentifier(): void { - $service = new Service(); - $live = new ServiceTestLiveStub($this->createStub(Service::class)); + $service = new LiveService(); + $live = new LiveServiceTestLiveStub($this->createStub(ServiceBase::class)); $live->existing = [5]; - (new ReflectionProperty(Service::class, 'liveService'))->setValue($service, $live); + (new ReflectionProperty(LiveService::class, 'mailService'))->setValue($service, $live); $result = $service->entityExtant('INBOX', 5, '6', 'abc', 0); @@ -25,7 +26,7 @@ final class ServiceTest extends TestCase } } -final class ServiceTestLiveStub extends LiveMailService +final class LiveServiceTestLiveStub extends LiveMailService { /** @var int[] */ public array $existing = []; diff --git a/tests/php/Unit/MailboxSelectionTest.php b/tests/php/Unit/MailboxSelectionTest.php index aeb297e..fdcadbb 100644 --- a/tests/php/Unit/MailboxSelectionTest.php +++ b/tests/php/Unit/MailboxSelectionTest.php @@ -6,7 +6,7 @@ namespace KTXT\ProviderImap\Tests\Unit; use KTXM\ProviderImap\Client\{Client, ConnectionConfig, ImapException}; use KTXM\ProviderImap\Client\Transport\{ConnectionInterface, ConnectionFactoryInterface}; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceBase; use KTXM\ProviderImap\Service\Live\LiveMailService; use PHPUnit\Framework\TestCase; @@ -101,8 +101,8 @@ final class MailboxSelectionTest extends TestCase $client = new Client($factory); $client->connect(new ConnectionConfig('localhost')); - return new class($this->createStub(Service::class), $client) extends LiveMailService { - public function __construct(Service $service, private readonly Client $client) + return new class($this->createStub(ServiceBase::class), $client) extends LiveMailService { + public function __construct(ServiceBase $service, private readonly Client $client) { parent::__construct($service); } diff --git a/tests/php/Unit/MessageHarmonizationTest.php b/tests/php/Unit/MessageHarmonizationTest.php index e10b80e..82a56ea 100644 --- a/tests/php/Unit/MessageHarmonizationTest.php +++ b/tests/php/Unit/MessageHarmonizationTest.php @@ -14,7 +14,7 @@ use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions; use KTXM\ProviderImap\Client\Protocol\Parser\FetchMessageParser; use KTXM\ProviderImap\Providers\CollectionResource; use KTXM\ProviderImap\Providers\EntityResource; -use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\LiveService; use KTXM\ProviderImap\Service\Cache\HarmonizationService; use KTXM\ProviderImap\Service\Cache\MessageDeltaService; use KTXM\ProviderImap\Service\Cache\MessageIngestor; @@ -40,7 +40,7 @@ final class MessageHarmonizationTest extends TestCase $this->messages = new FakeMessageStore($dataStore); $this->files = new FakeMessageFileStore('/nonexistent'); - $service = (new Service())->fromStore(['tid' => 'tenant', 'sid' => 'svc']); + $service = (new LiveService())->fromStore(['tid' => 'tenant', 'sid' => 'svc']); $this->live = new MessageHarmonizationLiveStub($service); $this->live->uidValidity = 7; $this->live->uidNext = 100; @@ -286,14 +286,16 @@ final class MessageHarmonizationLiveStub extends LiveMailService /** @var string[] */ public array $failing = []; - public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator + public function collectionList(?string $location = null, ?IFilter $filter = null, ?ISort $sort = null): array { + $list = []; foreach ($this->mailboxList as $mailbox) { - yield $mailbox->name() => $mailbox; + $list[$mailbox->name()] = (new CollectionResource('imap', 'svc'))->fromImap($mailbox); } + return $list; } - public function collectionFetch(string $identifier): ?Mailbox + public function mailboxFetch(string $identifier): ?Mailbox { if (in_array($identifier, $this->failing, true)) { return null; @@ -307,7 +309,7 @@ final class MessageHarmonizationLiveStub extends LiveMailService yield from $this->flags; } - public function entityFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator + public function messageFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator { $this->fetchBatches[] = $uids; if (preg_match('/BODY\.PEEK\[TEXT\]<0\.(\d+)>/', (string) $options?->toCommand(), $matches) === 1) {