diff --git a/lib/Client/FetchOptions.php b/lib/Client/FetchOptions.php index 5cc90b9..ed46cb4 100644 --- a/lib/Client/FetchOptions.php +++ b/lib/Client/FetchOptions.php @@ -15,42 +15,21 @@ final class FetchOptions public static function default(): self { - return self::message(); - } - - public static function summary(): self - { - return new self([ - 'UID', - 'FLAGS', - 'INTERNALDATE', - 'RFC822.SIZE', - ]); + return (new self([])) + ->withUid() + ->withFlags() + ->withInternalDate() + ->withSize(); } public static function message(): self { - return self::summary() + return self::default() + ->withHeaders() ->withEnvelope() ->withBodyStructure(); } - public static function fullMessage(): self - { - return self::message()->withBodyText(); - } - - public function withBodySection(string $section): self - { - $section = strtoupper(trim($section)); - - if ($section === '') { - return $this; - } - - return $this->with(sprintf('BODY[%s]', $section)); - } - public static function of(string ...$items): self { return new self(self::normalize($items)); @@ -76,6 +55,25 @@ final class FetchOptions return $this->with('RFC822.SIZE'); } + public function withHeaders(): self + { + return $this->with('BODY[HEADER]'); + } + + public function withHeader(string ...$fields): self + { + $fields = array_values(array_filter(array_map( + static fn (string $field): string => strtoupper(trim($field)), + $fields, + ), static fn (string $field): bool => $field !== '')); + + if ($fields === []) { + return $this; + } + + return $this->with(sprintf('BODY.PEEK[HEADER.FIELDS (%s)]', implode(' ', $fields))); + } + public function withEnvelope(): self { return $this->with('ENVELOPE'); @@ -88,21 +86,18 @@ final class FetchOptions public function withBodyText(): self { - return $this->withBodySection('TEXT'); + return $this->with('BODY[TEXT]'); } - public function withHeaderFields(string ...$fields): self + public function withBodySection(string $section): self { - $fields = array_values(array_filter(array_map( - static fn (string $field): string => strtoupper(trim($field)), - $fields, - ), static fn (string $field): bool => $field !== '')); + $section = strtoupper(trim($section)); - if ($fields === []) { + if ($section === '') { return $this; } - return $this->with(sprintf('BODY.PEEK[HEADER.FIELDS (%s)]', implode(' ', $fields))); + return $this->with(sprintf('BODY[%s]', $section)); } public function with(string $item): self diff --git a/lib/Client/Message.php b/lib/Client/Message.php index 7615791..7694c09 100644 --- a/lib/Client/Message.php +++ b/lib/Client/Message.php @@ -21,6 +21,7 @@ final class Message private readonly int $uid, private readonly int $size, private readonly ?string $internalDate, + private readonly ?string $receivedAt, private readonly array $flags, private readonly ?string $subject, private readonly ?string $sentAt, @@ -56,6 +57,11 @@ final class Message return $this->internalDate; } + public function receivedAt(): ?string + { + return $this->receivedAt; + } + /** * @return list */ @@ -139,7 +145,7 @@ final class Message public function bodyText(): ?string { - return $this->bodyText; + return $this->bodySections['TEXT'] ?? null; } /** @@ -160,6 +166,7 @@ final class Message $this->uid, $this->size, $this->internalDate, + $this->receivedAt, $this->flags, $this->subject, $this->sentAt, diff --git a/lib/Client/MessageParser.php b/lib/Client/MessageParser.php index c201954..c264020 100644 --- a/lib/Client/MessageParser.php +++ b/lib/Client/MessageParser.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Client; +use DateTimeInterface; + final class MessageParser { public static function isFetchMessage(string $payload): bool @@ -23,12 +25,14 @@ final class MessageParser $envelope = is_array($attributes['ENVELOPE'] ?? null) ? $attributes['ENVELOPE'] : null; $bodyStructure = isset($attributes['BODYSTRUCTURE']) ? self::parseBodyPart($attributes['BODYSTRUCTURE'], '') : null; $bodySections = self::parseBodySections($attributes, $bodyStructure); + $headers = self::parseFetchedHeaders($attributes); return new Message( $sequence, $uid, self::toOptionalInt($attributes['RFC822.SIZE'] ?? null) ?? 0, self::toNullableString($attributes['INTERNALDATE'] ?? null), + self::extractReceivedAt($headers), self::parseFlags($attributes['FLAGS'] ?? null), self::decodeMimeHeader(self::envelopeString($envelope, 1)), self::envelopeString($envelope, 0), @@ -258,6 +262,127 @@ final class MessageParser return is_string($value) && $value !== '' ? $value : null; } + /** + * @param array $attributes + * @return array> + */ + private static function parseFetchedHeaders(array $attributes): array + { + $headers = []; + + foreach ($attributes as $name => $value) { + if (!is_string($value)) { + continue; + } + + if (!preg_match('/^BODY(?:\.PEEK)?\[(.+)\]$/i', $name, $matches)) { + continue; + } + + $section = strtoupper(trim($matches[1])); + if (!str_starts_with($section, 'HEADER')) { + continue; + } + + foreach (self::parseHeaderBlock($value) as $headerName => $headerValues) { + $normalized = strtolower($headerName); + $headers[$normalized] ??= []; + array_push($headers[$normalized], ...$headerValues); + } + } + + return $headers; + } + + /** + * @return array> + */ + private static function parseHeaderBlock(string $headers): array + { + $parsed = []; + $currentName = null; + $currentValue = ''; + + foreach (preg_split("/\r\n|\n|\r/", $headers) ?: [] as $line) { + if ($line === '') { + break; + } + + if (($line[0] === ' ' || $line[0] === "\t") && $currentName !== null) { + $currentValue .= ' ' . trim($line); + continue; + } + + if ($currentName !== null) { + $parsed[$currentName] ??= []; + $parsed[$currentName][] = trim($currentValue); + } + + $separator = strpos($line, ':'); + if ($separator === false) { + $currentName = null; + $currentValue = ''; + continue; + } + + $currentName = substr($line, 0, $separator); + $currentValue = substr($line, $separator + 1); + } + + if ($currentName !== null) { + $parsed[$currentName] ??= []; + $parsed[$currentName][] = trim($currentValue); + } + + return $parsed; + } + + /** + * @param array> $headers + */ + private static function extractReceivedAt(array $headers): ?string + { + foreach ($headers['delivery-date'] ?? [] as $value) { + $date = self::parseHeaderDate($value); + if ($date !== null) { + return $date; + } + } + + foreach ($headers['received'] ?? [] as $value) { + $date = self::parseHeaderDateFromReceived($value); + if ($date !== null) { + return $date; + } + } + + return null; + } + + private static function parseHeaderDate(?string $value): ?string + { + $value = self::toNullableString($value); + if ($value === null) { + return null; + } + + try { + return (new \DateTimeImmutable($value))->format(DateTimeInterface::ATOM); + } catch (\Exception) { + return null; + } + } + + private static function parseHeaderDateFromReceived(string $value): ?string + { + $separator = strrpos($value, ';'); + if ($separator === false) { + return self::parseHeaderDate($value); + } + + return self::parseHeaderDate(substr($value, $separator + 1)); + } + private static function envelopeString(?array $envelope, int $index): ?string { if ($envelope === null) { diff --git a/lib/Providers/CollectionResource.php b/lib/Providers/CollectionResource.php index d2978a9..5bde98e 100644 --- a/lib/Providers/CollectionResource.php +++ b/lib/Providers/CollectionResource.php @@ -91,12 +91,4 @@ class CollectionResource extends CollectionMutableAbstract return $this->properties; } - // ── JSON ───────────────────────────────────────────────────────────────── - - public function jsonSerialize(): array - { - $data = $this->data; - $data['properties'] = $this->getProperties()->jsonSerialize(); - return $data; - } } diff --git a/lib/Providers/EntityResource.php b/lib/Providers/EntityResource.php index 5c35874..e18c597 100644 --- a/lib/Providers/EntityResource.php +++ b/lib/Providers/EntityResource.php @@ -9,7 +9,6 @@ declare(strict_types=1); namespace KTXM\ProviderImap\Providers; -use DateTimeInterface; use KTXM\ProviderImap\Client\Message; use KTXF\Mail\Entity\EntityMutableAbstract; @@ -43,27 +42,6 @@ class EntityResource extends EntityMutableAbstract { return $this; } - /** - * Convert mail entity object to store array - */ - public function toStore(): array { - return array_merge( - $this->data, - ['properties' => $this->getProperties()->toStore()] - ); - } - - /** - * Hydrate mail entity object from store array - */ - public function fromStore(array $data): static { - $properties = $data['properties'] ?? []; - unset($data['properties']); - $this->data = $data; - $this->getProperties()->fromStore($properties); - return $this; - } - /** * @inheritDoc */ diff --git a/lib/Providers/MessageProperties.php b/lib/Providers/MessageProperties.php index 35da2e1..b784da6 100644 --- a/lib/Providers/MessageProperties.php +++ b/lib/Providers/MessageProperties.php @@ -25,71 +25,74 @@ class MessageProperties extends MessagePropertiesMutableAbstract { */ public function fromImap(Message $message): static { - $this->data['size'] = $message->size(); - - $this->data['flags'] = []; - foreach ($message->flags() as $flag) { - $flag = ltrim($flag, '\\'); - $normalized = match (strtolower($flag)) { - 'seen' => 'read', - 'flagged' => 'flagged', - 'answered' => 'answered', - 'draft' => 'draft', - 'deleted' => 'deleted', - default => strtolower($flag), - }; - $this->data['flags'][$normalized] = true; - } + $this->data[static::PROPERTY_SIZE] = $message->size(); if ($message->messageId() !== null) { - $this->data['urid'] = $message->messageId(); + $this->data[static::PROPERTY_URID] = $message->messageId(); } - if ($message->subject() !== null) { - $this->data['subject'] = $message->subject(); + if ($message->inReplyTo() !== null) { + $this->data[static::PROPERTY_IN_REPLY_TO] = $message->inReplyTo(); + } + + //if ($message->references() !== []) { + // $this->data[static::PROPERTY_REFERENCES] = $message->references(); + //} + + $receivedAt = $message->receivedAt() ?? $message->internalDate(); + if ($receivedAt !== null) { + $date = new DateTimeImmutable($receivedAt); + $this->data[static::PROPERTY_RECEIVED] = $date->format(DateTimeInterface::ATOM); } if ($message->sentAt() !== null) { $date = new DateTimeImmutable($message->sentAt()); - $this->data['date'] = $date->format(DateTimeInterface::ATOM); - } - - if ($message->inReplyTo() !== null) { - $this->data['inReplyTo'] = $message->inReplyTo(); - } - - if ($message->from() !== []) { - $this->data['from'] = $message->from()[0]->toArray(); + $this->data[static::PROPERTY_SENT] = $date->format(DateTimeInterface::ATOM); } if ($message->sender() !== []) { - $this->data['sender'] = $message->sender()[0]->toArray(); + $this->data[static::PROPERTY_SENDER] = $message->sender()[0]->toArray(); } - foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) { + if ($message->from() !== []) { + $this->data[static::PROPERTY_FROM] = $message->from()[0]->toArray(); + } + + $addressProperties = [ + 'to' => static::PROPERTY_TO, + 'cc' => static::PROPERTY_CC, + 'bcc' => static::PROPERTY_BCC, + 'replyTo' => static::PROPERTY_REPLY_TO, + ]; + + foreach ($addressProperties as $field => $property) { $addresses = $message->{$field}(); if ($addresses === []) { continue; } - $this->data[$field] = array_map( + $this->data[$property] = array_map( static fn ($address): array => $address->toArray(), $addresses, ); } + if ($message->subject() !== null) { + $this->data[static::PROPERTY_SUBJECT] = $message->subject(); + } + if ($message->bodyStructure() !== null) { - $this->data['body'] = $message->bodyStructure()->toArray(); + $this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray(); $attachments = []; - self::collectAttachments($message->bodyStructure(), $attachments); + $this->collectAttachments($message->bodyStructure(), $attachments); if ($attachments !== []) { - $this->data['attachments'] = $attachments; + $this->data[static::PROPERTY_ATTACHMENTS] = $attachments; } } if ($message->bodyStructure() !== null) { - $this->data['body'] = $message->bodyStructure()->toArray(); + $this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray(); // Recursively add content from bodyValues to matching parts if (is_array($message->bodySections())) { $addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) { @@ -105,64 +108,36 @@ class MessageProperties extends MessagePropertiesMutableAbstract { } }; - $addContentToParts($this->data['body'], $message->bodySections()); + $addContentToParts($this->data[static::PROPERTY_BODY], $message->bodySections()); } } + $this->data[static::PROPERTY_FLAGS] = []; + foreach ($message->flags() as $flag) { + $flag = ltrim($flag, '\\'); + $normalized = match (strtolower($flag)) { + 'seen' => 'read', + 'flagged' => 'flagged', + 'answered' => 'answered', + 'draft' => 'draft', + 'deleted' => 'deleted', + default => strtolower($flag), + }; + $this->data[static::PROPERTY_FLAGS][$normalized] = true; + } + return $this; } - private static function normalizeFlag(string $flag): string - { - $flag = ltrim($flag, '\\'); - - return match (strtolower($flag)) { - 'seen' => 'read', - 'flagged' => 'flagged', - 'answered' => 'answered', - 'draft' => 'draft', - 'deleted' => 'deleted', - default => strtolower($flag), - }; - } - - /** - * Convert a string to UTF-8 from the given charset. - * - * Tries mb_convert_encoding first; falls back to iconv when mbstring does - * not recognise the charset name (e.g. "windows-1250"). - */ - public static function toUtf8(string $content, string $charset): string - { - if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) { - // Content claims to be UTF-8 but may still have invalid sequences; scrub to be safe. - return mb_convert_encoding($content, 'UTF-8', 'UTF-8'); - } - // Try mbstring first - try { - $converted = mb_convert_encoding($content, 'UTF-8', $charset); - if ($converted !== false) { - return $converted; - } - } catch (\ValueError) { - // charset not recognised by mbstring — fall through to iconv - } - // iconv fallback (handles Windows-125x, ISO-8859-*, etc.) - $converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $content); - $content = ($converted !== false) ? $converted : $content; - // Final scrub: strip any residual invalid UTF-8 bytes so json_encode never fails. - return mb_convert_encoding($content, 'UTF-8', 'UTF-8'); - } - /** * Recursively collect attachment parts from body structure */ - private static function collectAttachments(ClientMessagePart $part, array &$attachments): void + private function collectAttachments(ClientMessagePart $part, array &$attachments): void { $children = $part->parts(); if ($children !== []) { foreach ($children as $childPart) { - self::collectAttachments($childPart, $attachments); + $this->collectAttachments($childPart, $attachments); } return; } @@ -181,18 +156,4 @@ class MessageProperties extends MessagePropertiesMutableAbstract { $attachments[] = $part->toArray(); } - /** - * Serialize to store array - */ - public function toStore(): array { - return $this->data; - } - - /** - * Hydrate from store array - */ - public function fromStore(array $data): static { - $this->data = $data; - return $this; - } } diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php index cde7e86..39b9df9 100644 --- a/lib/Providers/Provider.php +++ b/lib/Providers/Provider.php @@ -14,6 +14,7 @@ use KTXF\Mail\Provider\ProviderServiceDiscoverInterface; use KTXF\Mail\Provider\ProviderServiceMutateInterface; use KTXF\Mail\Provider\ProviderServiceTestInterface; use KTXF\Mail\Service\ServiceBaseInterface; +use KTXF\Mail\Service\ServiceMutableInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Provider\ResourceServiceMutateInterface; use KTXM\ProviderImap\Service\Discovery; @@ -23,7 +24,7 @@ use KTXM\ProviderImap\Stores\ServiceStore; /** * IMAP Mail Provider */ -class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface +class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface { public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE; @@ -180,7 +181,7 @@ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscove return $discovery->discover($identity, $location, $secret, $verifySSL); } - public function serviceTest(ServiceBaseInterface $service, array $options = []): array + public function serviceTest(ServiceBaseInterface|ServiceMutableInterface $service, array $options = []): array { $startTime = microtime(true); diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index 8cf2993..c80d4c3 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -12,6 +12,7 @@ namespace KTXM\ProviderImap\Providers; use Generator; use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionMutableInterface; +use KTXF\Mail\Collection\CollectionPropertiesBaseInterface; use KTXF\Mail\Object\Address; use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Service\ServiceBaseInterface; @@ -37,15 +38,15 @@ use KTXM\ProviderImap\Service\Remote\RemoteMailService; use KTXM\ProviderImap\Service\Remote\RemoteService; use KTXM\ProviderImap\Providers\CollectionResource; use KTXF\Mail\Collection\CollectionRoles; +use KTXF\Mail\Object\MessagePropertiesMutableInterface; +use KTXF\Mail\Service\ServiceEntityMutableInterface; use KTXM\ProviderImap\Providers\EntityResource; /** * IMAP Mail Service */ -class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface +class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface { - public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE; - private const PROVIDER_IDENTIFIER = 'imap'; private ?string $serviceTenantId = null; @@ -88,6 +89,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC self::CAPABILITY_ENTITY_LIST_RANGE => ['tally' => ['absolute', 'relative']], self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, + self::CAPABILITY_ENTITY_CREATE => false, + self::CAPABILITY_ENTITY_MODIFY => false, + self::CAPABILITY_ENTITY_DELETE => true, + self::CAPABILITY_ENTITY_MOVE => true, + self::CAPABILITY_ENTITY_COPY => false, ]; private RemoteMailService $mailService; @@ -354,6 +360,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); + $list = []; + foreach ($this->mailService->collectionList($location, $filter, $sort) as $mailbox) { $resource = $this->collectionFresh(); $resource->fromImap($mailbox); @@ -377,12 +385,16 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $this->initialize(); - $mailboxes = $this->collectionList(); - $extant = []; - foreach ($identifiers as $id) { - $extant[(string) $id] = isset($existing[(string) $id]); + $list = []; + + foreach ($identifiers as $identifier) { + $key = (string) $identifier; + $result = $this->mailService->collectionFetch($key); + + $list[$key] = $result !== false; } - return $extant; + + return $list; } public function collectionFetch(string|int $identifier): ?CollectionBaseInterface @@ -400,22 +412,28 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $collection; } - public function collectionFresh(): CollectionMutableInterface + public function collectionFresh(): CollectionResource { return new CollectionResource($this->provider(), $this->identifier()); } - public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface + public function collectionCreate(CollectionIdentifier|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface { $this->initialize(); + if (!$properties->getLabel()) { + throw new \InvalidArgumentException('Collection label is required property'); + } + $label = $properties->getLabel(); + // Resolve the full name: if a parent location is given, prepend it - $label = $collection->getProperties()->getLabel() ?? ''; - if ($location !== null && $location !== '') { + 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, '')); - $delimiter = $mailboxes ? reset($mailboxes)->delimiter() ?? '/' : '/'; - $label = rtrim((string) $location, $delimiter) . $delimiter . ltrim($label, $delimiter); + $rootMailbox = $mailboxes === [] ? null : reset($mailboxes); + $delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/'); + $label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter); } $mailbox = $this->mailService->collectionCreate($label); @@ -426,20 +444,26 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $collection; } - public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface + public function collectionUpdate(CollectionIdentifier $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface { $this->initialize(); + if (!$properties->getLabel()) { + throw new \InvalidArgumentException('Collection label is a required property'); + } + $label = $properties->getLabel(); + // In IMAP, "update" = rename to the new label - $newName = $collection->getProperties()->getLabel() ?? (string) $identifier; - $mailbox = $this->mailService->collectionRename((string) $identifier, $newName); + $oldPath = (string) $target->collection(); + $newName = $properties->getLabel(); + $mailbox = $this->mailService->collectionRename($oldPath, $newName); $collection = $this->collectionFresh(); $collection->fromImap($mailbox); return $collection; } - public function collectionDelete(string|int $identifier, bool $force = false): CollectionBaseInterface | true + public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true { $this->initialize(); @@ -452,7 +476,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC // Move to target collection (e.g. Trash) instead of deleting if ($deleteMode === 'soft' && $deleteTarget !== null) { - return $this->collectionMove((string) $identifier, (string) $deleteTarget); + return $this->collectionMove($target, new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget)); } if ($deleteMode === 'soft' && $deleteTarget === null) { @@ -468,24 +492,24 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } // we need to determine if the folder being deleted is already in the trash - if (str_starts_with((string) $identifier, (string) $deleteTarget)) { + if (str_starts_with((string) $target->collection(), (string) $deleteTarget)) { // if so, we should hard delete instead of moving to avoid duplicates in the trash $deleteMode = 'hard'; } $result = match ($deleteMode) { - 'soft' => $this->collectionMove((string) $identifier, (string) $deleteTarget), - 'hard' => $this->mailService->collectionDestroy((string) $identifier) + 'soft' => $this->collectionMove($target, new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget)), + 'hard' => $this->mailService->collectionDestroy((string) $target->collection()), }; return $result; } - public function collectionMove(string|int $identifier, string|int|null $target): CollectionBaseInterface + public function collectionMove(CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface { $this->initialize(); - $sourceMailbox = $this->mailService->collectionFetch((string) $identifier); - $targetMailbox = $this->mailService->collectionFetch((string) $target); + $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'); } @@ -508,7 +532,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { - return itterator_to_array($this->entityList((string) $collection, $filter, $sort, $range), true); + return iterator_to_array($this->entityList((string) $collection, $filter, $sort, $range), true); } public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator @@ -571,6 +595,16 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return new EntityResource($this->provider(), $this->identifier()); } + public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource + { + throw new \RuntimeException('Entity creation is not supported in this service'); + } + + public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource + { + throw new \RuntimeException('Entity modification is not supported in this service'); + } + public function entityDelete(EntityIdentifier ...$identifiers): array { // validate identifiers and group by collection @@ -597,7 +631,12 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC throw new \RuntimeException('No Trash collection configured or found for deletion'); } - $deleteTargetNative = reset($mailboxes)->name(); + $rootMailbox = reset($mailboxes); + if ($rootMailbox === false) { + throw new \RuntimeException('No Trash collection configured or found for deletion'); + } + + $deleteTargetNative = $rootMailbox->name(); $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); } else { $deleteTargetNative = $deleteTarget; @@ -620,7 +659,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC foreach ($uids as $uid) { $mutatedUid = $mutations[$uid] ?? null; - $results[(string)$sourceEntities[$uid]] = [ + $list[(string)$sourceEntities[$uid]] = [ 'disposition' => $deleteMode === 'soft' ? 'moved' : 'deleted', 'destination' => $deleteMode === 'soft' ? $deleteTargetIdentifier : null, 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $deleteTargetIdentifier->collection(), $mutatedUid) : null, @@ -628,7 +667,12 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC } } - return $results; + return $list; + } + + public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array + { + throw new \RuntimeException('Entity patching is not supported in this service'); } public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$identifiers): array @@ -662,6 +706,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return $list; } + public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array + { + throw new \RuntimeException('Entity copying is not supported in this service'); + } + private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array { $list = []; diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 398b669..8ceae27 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -108,7 +108,7 @@ class RemoteMailService } try { $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); - $mailbox->fromStatus($status); + $mailbox = $mailbox->fromStatus($status); } catch (ImapException) { // do nothing } @@ -134,7 +134,7 @@ class RemoteMailService $mailbox = reset($mailbox); // enrich with STATUS $status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS)); - $mailbox->fromStatus($status); + $mailbox = $mailbox->fromStatus($status); return $mailbox; } @@ -243,14 +243,31 @@ class RemoteMailService */ public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator { + $options = FetchOptions::message()->withBodyText(); + + // fast path: fetch all messages without filtering, sorting or pagination + if ($filter === null && $sort === null && $range === null) { + $mailbox = $this->client->perform(new SelectCommand($collection, true)); + + if ($mailbox === null) { + return []; + } + + yield from $this->client->perform(new FetchManyCommand( + FetchTarget::all(), + $options, + )); + + return; + } + // find all the UIDs matching the filter $uids = $this->entityFind($collection, $filter, $sort, $range); + if (empty($uids)) { return []; } - $options = FetchOptions::default()->withBodyText(); - yield from $this->entityFetch($collection, $options, ...$uids); } @@ -266,7 +283,7 @@ class RemoteMailService return []; } - $options ??= FetchOptions::default(); + $options ??= FetchOptions::message()->withBodyText(); $this->client->perform(new SelectCommand($collection, true)); $request = new FetchManyCommand( @@ -576,7 +593,7 @@ class RemoteMailService */ private function entitySortClientSide(array $uids, ISort $sort): array { - $options = FetchOptions::summary(); + $options = FetchOptions::default(); foreach ($sort->conditions() as $condition) { if (in_array($condition['attribute'] ?? '', ['from', 'to', 'subject', 'sent'], true)) { $options = $options->withEnvelope(); @@ -613,7 +630,7 @@ class RemoteMailService 'from' => $this->entityPrimaryAddressValue($left->from()) <=> $this->entityPrimaryAddressValue($right->from()), 'to' => $this->entityPrimaryAddressValue($left->to()) <=> $this->entityPrimaryAddressValue($right->to()), 'subject' => $this->entityScalarValue($left->subject()) <=> $this->entityScalarValue($right->subject()), - 'received' => $this->entityTimestampValue($left->internalDate()) <=> $this->entityTimestampValue($right->internalDate()), + 'received' => $this->entityTimestampValue($left->receivedAt() ?? $left->internalDate()) <=> $this->entityTimestampValue($right->receivedAt() ?? $right->internalDate()), 'sent' => $this->entityTimestampValue($left->sentAt()) <=> $this->entityTimestampValue($right->sentAt()), 'size' => $left->size() <=> $right->size(), default => 0,