* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Providers; use Generator; use KTXF\Mail\Collection\CollectionBaseInterface; use KTXF\Mail\Collection\CollectionMutableInterface; use KTXF\Mail\Object\Address; use KTXF\Mail\Object\AddressInterface; use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Mail\Service\ServiceCollectionMutableInterface; use KTXF\Mail\Service\ServiceConfigurableInterface; use KTXF\Mail\Service\ServiceMutableInterface; 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\Service\Remote\RemoteMailService; use KTXM\ProviderImap\Service\Remote\RemoteService; /** * IMAP Mail Service * * Represents a single IMAP account configuration and acts as the primary * entry-point for all mail operations (collections + entities). * * The RemoteMailService is initialised lazily on first use so that the object * can be constructed cheaply for serialisation/deserialisation tasks. */ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface { public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE; 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 string $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_LIST_SORT => [], self::CAPABILITY_COLLECTION_EXTANT => true, self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_UPDATE => true, self::CAPABILITY_COLLECTION_DELETE => true, self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ 'seen' => 'b:0:1:1', 'flagged' => 'b:0:1:1', 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_LIST_RANGE => ['tally' => ['absolute', 'relative']], self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, ]; private RemoteMailService $mailService; public function __construct() {} // ── Lazy initialisation ─────────────────────────────────────────────────── private function initialize(): void { if (!isset($this->mailService)) { $wrapper = RemoteService::freshClient($this); $this->mailService = RemoteService::mailService($this, $wrapper); } } // ── Store (MongoDB persistence) ─────────────────────────────────────────── public function toStore(): array { return array_filter([ 'tid' => $this->serviceTenantId, 'uid' => $this->serviceUserId, 'sid' => $this->serviceIdentifier, 'label' => $this->serviceLabel, 'enabled' => $this->serviceEnabled, '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; } // ── JSON ────────────────────────────────────────────────────────────────── public function jsonSerialize(): array { return array_filter([ self::JSON_PROPERTY_TYPE => self::JSON_TYPE, self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier, self::JSON_PROPERTY_LABEL => $this->serviceLabel, self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(), self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(), self::JSON_PROPERTY_AUXILIARY => $this->auxiliary, ], fn($v) => $v !== null); } public function jsonDeserialize(array|string $data): static { if (is_string($data)) { $data = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } if (isset($data[self::JSON_PROPERTY_LABEL])) { $this->setLabel($data[self::JSON_PROPERTY_LABEL]); } if (isset($data[self::JSON_PROPERTY_ENABLED])) { $this->setEnabled($data[self::JSON_PROPERTY_ENABLED]); } if (isset($data[self::JSON_PROPERTY_LOCATION])) { $this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION])); } if (isset($data[self::JSON_PROPERTY_IDENTITY])) { $this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY])); } if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) { $this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])); } if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) { $this->setSecondaryAddresses(array_map( fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr), $data[self::JSON_PROPERTY_SECONDARY_ADDRESSES] )); } if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) { $this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]); } return $this; } // ── ServiceBaseInterface ────────────────────────────────────────────────── 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; } // ── ServiceMutableInterface ─────────────────────────────────────────────── 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 new Address($this->primaryAddress); } public function setPrimaryAddress(AddressInterface $value): static { $this->primaryAddress = $value->getAddress(); return $this; } public function getSecondaryAddresses(): array { return $this->secondaryAddresses; } public function setSecondaryAddresses(array $addresses): static { $this->secondaryAddresses = $addresses; return $this; } public function hasAddress(string $address): bool { $address = strtolower(trim($address)); if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) { return true; } foreach ($this->secondaryAddresses as $secondary) { $secondaryAddr = $secondary instanceof AddressInterface ? $secondary->getAddress() : (string) $secondary; if (strtolower($secondaryAddr) === $address) { return true; } } return false; } // ── ServiceConfigurableInterface ────────────────────────────────────────── 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; } // ── Collection operations ───────────────────────────────────────────────── public function collectionList(string|int|null $location, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array { $this->initialize(); return $this->mailService->collectionList(); } 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 collectionExtant(string|int ...$identifiers): array { $this->initialize(); $existing = $this->mailService->collectionList(); $extant = []; foreach ($identifiers as $id) { $extant[(string) $id] = isset($existing[(string) $id]); } return $extant; } public function collectionFetch(string|int $identifier): ?CollectionBaseInterface { $this->initialize(); return $this->mailService->collectionFetch((string) $identifier); } public function collectionFresh(): CollectionMutableInterface { return new CollectionResource(); } public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionBaseInterface { $this->initialize(); // Resolve the full name: if a parent location is given, prepend it $label = $collection->getProperties()->getLabel() ?? ''; if ($location !== null && $location !== '') { // Determine the hierarchy delimiter from an existing mailbox, default to '/' $existing = $this->mailService->collectionList(); $delimiter = '/'; foreach ($existing as $c) { $props = $c->getProperties(); if ($props instanceof CollectionProperties) { $d = $props->getDelimiter(); if ($d !== null && $d !== '') { $delimiter = $d; break; } } } $label = rtrim((string) $location, $delimiter) . $delimiter . ltrim($label, $delimiter); } return $this->mailService->collectionCreate($label); } public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionBaseInterface { $this->initialize(); // In IMAP, "update" = rename to the new label $newName = $collection->getProperties()->getLabel() ?? (string) $identifier; return $this->mailService->collectionRename((string) $identifier, $newName); } public function collectionDelete(string|int $identifier, bool $force = false, bool $recursive = false): bool { $this->initialize(); return $this->mailService->collectionDestroy((string) $identifier); } public function collectionMove(string|int $identifier, string|int|null $targetLocation): CollectionBaseInterface { $this->initialize(); // IMAP RENAME effectively moves+renames the mailbox $existing = $this->mailService->collectionFetch((string) $identifier); $label = $existing?->getProperties()->getLabel() ?? basename((string) $identifier); $newName = $targetLocation !== null ? rtrim((string) $targetLocation, '/') . '/' . $label : $label; return $this->mailService->collectionRename((string) $identifier, $newName); } // ── Entity operations ───────────────────────────────────────────────────── public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { $this->initialize(); // Unfiltered + unpaginated: skip the SEARCH round-trip and use FETCH 1:* if ($filter === null && $range === null) { return $this->mailService->entityFetchAll((string) $collection); } // Filtered or paginated: SEARCH to get a UID list, then FETCH by UIDs $uids = $this->mailService->entityList((string) $collection, $filter, $range); if (empty($uids)) { return []; } return $this->mailService->entityFetch((string) $collection, ...$uids); } public function entityListStream(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator { $this->initialize(); // Unfiltered: skip the SEARCH round-trip and stream via FETCH 1:* if ($filter === null) { yield from $this->mailService->entityFetchAllStream((string) $collection); return; } // Filtered: SEARCH for matching UIDs then stream only those messages $uids = $this->mailService->entityList((string) $collection, $filter, $range); if (empty($uids)) { return; } yield from $this->mailService->entityFetchStream((string) $collection, ...$uids); } 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 entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta { return new Delta(signature: $signature); } public function entityExtant(string|int $collection, string|int ...$identifiers): array { $this->initialize(); $allUids = $this->mailService->entityList((string) $collection); $uidSet = array_flip($allUids); // int[] → [uid => index] $extant = []; foreach ($identifiers as $id) { $extant[$id] = isset($uidSet[(int) $id]); } return $extant; } public function entityFetch(string|int $collection, string|int ...$identifiers): array { $this->initialize(); $uids = array_map('intval', $identifiers); return $this->mailService->entityFetch((string) $collection, ...$uids); } public function entityDelete(EntityIdentifier ...$identifiers): array { // validate identifiers and group by collection $collections = []; foreach ($identifiers as $identifier) { if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) { throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier); } $collections[$identifier->collection()][] = (int) $identifier->entity(); } $this->initialize(); // delete entities per collection and build result map $result = []; foreach ($collections as $collection => $uids) { $this->mailService->entityDestroy($collection, ...$uids); foreach ($uids as $uid) { $result[(string) $uid] = true; } } return $result; } public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$identifiers): array { // validate target belongs to this service if ($target->provider() !== $this->provider() || $target->service() !== (string)$this->identifier()) { throw new \InvalidArgumentException('Target collection does not belong to this service'); } // validate identifiers and construct ID list $ids = []; foreach ($identifiers as $identifier) { if ($identifier->provider() !== $this->provider() || $identifier->service() !== (string)$this->identifier()) { throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . (string)$identifier); } $ids[] = $identifier->entity(); } $this->initialize(); return $this->mailService->entityMove($target->collection(), ...$ids); } }