* 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\Service\Live\LiveMailService; 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 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 LiveMailService $mailService; /** * @param LiveMailService|null $mailService share an existing IMAP connection (e.g. CachedService); created on demand otherwise */ public function __construct(?LiveMailService $mailService = null) { if ($mailService !== null) { $this->mailService = $mailService; } } public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $this->initialize(); return $this->mailService->collectionList($location === null ? null : (string) $location, $filter, $sort); } public function collectionExtant(string|int ...$identifiers): array { $this->initialize(); $list = []; foreach ($identifiers as $identifier) { $key = (string) $identifier; $list[$key] = $this->mailService->collectionFetch($key) !== null; } return $list; } public function collectionFetch(string|int $identifier): ?CollectionResource { $this->initialize(); return $this->mailService->collectionFetch((string) $identifier); } 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 if ($target !== null) { $path = $target->collection(); // Determine the hierarchy delimiter from an existing mailbox, default to '/' $delimiter = $this->mailService->collectionDelimiter(); $label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter); } return $this->mailService->collectionCreate($label, $delimiter ?? null); } 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 $oldPath = (string) $target->collection(); $newName = $properties->getLabel(); return $this->mailService->collectionRename($oldPath, $newName); } public function collectionDelete(CollectionIdentifier $target, bool $force = false): CollectionBaseInterface | true { $this->initialize(); $deleteMode = $this->auxiliary['deleteMode'] ?? 'soft'; if ($deleteMode !== 'soft' && $deleteMode !== 'hard') { throw new \InvalidArgumentException("Invalid delete mode: $deleteMode"); } $deleteTarget = $deleteMode === 'soft' ? $this->resolveDeleteDestination() : null; // we need to determine if the folder being deleted is already in the trash if ($deleteTarget !== null && str_starts_with((string) $target->collection(), $deleteTarget)) { // if so, we should hard delete instead of moving to avoid duplicates in the trash $deleteMode = 'hard'; } $result = match ($deleteMode) { 'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target), 'hard' => $this->mailService->collectionDestroy((string) $target->collection()), }; return $result; } public function collectionMove(CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface { $this->initialize(); $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'); } if ($targetMailbox === null) { throw new \RuntimeException('Target collection not found for move operation'); } $sourceDelimiter = $sourceMailbox->getProperties()->getDelimiter() ?: '/'; $targetDelimiter = $targetMailbox->getProperties()->getDelimiter() ?: '/'; $extantPath = (string) $sourceMailbox->identifier(); $extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter)); $freshPath = rtrim((string) $targetMailbox->identifier(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs); 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 { return iterator_to_array($this->entityListStream((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 { $this->initialize(); foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $resource) { yield $resource->urn() => $resource; } } public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array { return iterator_to_array($this->entityFetchStream(...$identifiers), true); } public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator { $this->initialize(); $identifiers = $this->groupEntitiesByCollection(...$identifiers); foreach ($identifiers as $collection => $entities) { $uids = array_keys($entities); foreach ($this->mailService->entityFetch((string) $collection, ...$uids) as $resource) { yield $resource->urn() => $resource; } } } public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource { $this->initialize(); $collection = $target->collection(); $uid = (int) $target->entity(); $partId = isset($part['partId']) ? (string) $part['partId'] : null; return $this->mailService->entityDownload($collection, $uid, $partId); } 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(); // only positive integers are valid UIDs; anything else cannot exist $uids = array_values(array_filter( array_map(static fn (string|int $id): int => (int) $id, $identifiers), static fn (int $uid): bool => $uid > 0, )); $existing = array_flip($this->mailService->entityExtant((string) $collection, ...$uids)); $extant = []; foreach ($identifiers as $id) { $extant[$id] = isset($existing[(int) $id]) && (string) (int) $id === (string) $id; } return $extant; } public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult { if ($message === null) { return new EntitySubmitResult( EntitySubmitResult::DISPOSITION_ERROR, errorCode: 'invalid_message', errorMessage: 'No message properties were provided for submission.', ); } // Outbound submission via SMTP — independent of the IMAP connection. try { $raw = (new MessageBuilder())->build($message); $recipients = MessageBuilder::recipients($message); if ($recipients === []) { throw new \RuntimeException('Message has no recipients.'); } $this->initialize(); $smtp = $this->mailService->smtpClient(); try { $queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw); } finally { $smtp->quit(); } } catch (\Throwable $e) { return new EntitySubmitResult( EntitySubmitResult::DISPOSITION_ERROR, errorCode: 'submission_failed', errorMessage: $e->getMessage(), ); } // Best-effort: store a copy in the Sent collection. A failure here must // not turn a successful delivery into an error. $sentEntity = null; try { $this->initialize(); $sentCollection = $this->resolveSentCollection(); if ($sentCollection !== null) { $uid = $this->mailService->entityCreate($sentCollection, $raw, ['\\Seen']); if ($uid !== null && $uid > 0) { $sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid); } } } catch (\Throwable) { // ignore — the message was already delivered } // Best-effort: IMAP has no native "submit existing draft" operation, so the // message above was always sent fresh. If it originated from a synced draft, // remove the now-superseded draft UID. A failure here must not turn a // successful delivery into an error; the draft is simply left for a later // discard or retry. if ($source !== null && $source->provider() === $this->provider() && $source->service() === $this->identifier()) { try { $this->initialize(); $this->mailService->entityDestroy($source->collection(), (int) $source->entity()); } catch (\Throwable) { // ignore — the message was already delivered } } return new EntitySubmitResult( disposition: EntitySubmitResult::DISPOSITION_SENT, transportId: $queueId !== '' ? $queueId : null, sentEntity: $sentEntity, sourceDraft: $source instanceof EntityIdentifier ? $source : null, ); } public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource { if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) { throw new \InvalidArgumentException('Target collection does not belong to this service: ' . (string)$target); } $this->initialize(); [$nativeMessage, $nativeFlags] = $this->messagePayload($properties, $options); $created = $this->mailService->entityCreate( (string)$target->collection(), $nativeMessage, $nativeFlags, ); if ($created === null || $created <= 0) { throw new \RuntimeException('IMAP APPEND did not return a valid UID'); } return $this->entityFresh()->fromMutation((string)$target->collection(), $created, $properties); } public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityResource { if ($target->provider() !== $this->provider() || (string)$target->service() !== (string)$this->identifier()) { throw new \InvalidArgumentException('Target entity does not belong to this service: ' . (string)$target); } $this->initialize(); [$nativeMessage, $nativeFlags] = $this->messagePayload($properties); $modified = $this->mailService->entityReplace( (string)$target->collection(), (int)$target->entity(), $nativeMessage, $nativeFlags, ); if ($modified === null || $modified <= 0) { throw new \RuntimeException('IMAP replacement did not return a valid UID'); } return $this->entityFresh()->fromMutation((string)$target->collection(), $modified, $properties); } public function entityPatch(MessagePropertiesMutableInterface $properties, EntityIdentifier ...$targets): array { // validate identifiers and group by collection $targets = $this->groupEntitiesByCollection(...$targets); // move entities on remote store and construct result map $this->initialize(); $list = []; foreach ($targets as $targetCollection => $targetIdentifiers) { $uids = array_keys($targetIdentifiers); $flagsAdd = []; $flagsRemove = []; foreach ($properties->getFlags() as $flag => $value) { if ($value === true) { $flagsAdd[] = $flag; } elseif ($value === false) { $flagsRemove[] = $flag; } } $mutations = $this->mailService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids); foreach ($uids as $uid) { $list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched']; } } return $list; } public function entityDelete(EntityIdentifier ...$targets): array { // validate identifiers and group by collection $targets = $this->groupEntitiesByCollection(...$targets); // determine delete mode and target collection (e.g. Trash) if applicable $deleteMode = $this->auxiliary['deleteMode'] ?? 'soft'; if ($deleteMode !== 'soft' && $deleteMode !== 'hard') { throw new \InvalidArgumentException("Invalid delete mode: $deleteMode"); } // connect to remote store $this->initialize(); $deleteTargetNative = null; $deleteTargetIdentifier = null; if ($deleteMode === 'soft') { $deleteTargetNative = $this->resolveDeleteDestination(); $deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative); } // if all targets are already in the delete target collection, we should hard delete instead of moving to avoid duplicates in the trash if (array_keys($targets) === [$deleteTargetNative]) { $deleteMode = 'hard'; } // entities need to be moved or deleted by collection $list = []; foreach ($targets as $sourceCollection => $sourceEntities) { if ($deleteMode === 'soft' && $sourceCollection === $deleteTargetNative) { continue; } $uids = array_keys($sourceEntities); $mutations = match ($deleteMode) { 'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids), 'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids), }; foreach ($uids as $uid) { $mutatedUid = !isset($mutations[$uid]) || $mutations[$uid] === true ? null : $mutations[$uid]; $list[(string)$sourceEntities[$uid]] = [ 'disposition' => $deleteMode === 'soft' ? 'moved' : 'deleted', 'destination' => $deleteMode === 'soft' ? $deleteTargetIdentifier : null, 'mutation' => $deleteMode === 'soft' && $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $deleteTargetIdentifier->collection(), $mutatedUid) : null, ]; } } return $list; } public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array { // validate target belongs to this service if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) { throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target); } // validate identifiers and group by collection $sources = $this->groupEntitiesByCollection(...$sources); // move entities on remote store and construct result map $this->initialize(); $list = []; foreach ($sources as $sourceCollection => $sourceEntities) { $uids = array_keys($sourceEntities); $mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids); foreach ($uids as $uid) { $mutatedUid = $mutations[$uid] ?? null; $list[(string)$sourceEntities[$uid]] = [ 'disposition' => 'moved', 'destination' => $target, 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null, ]; unset($sourceEntities[$uid]); } } return $list; } public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array { // validate target belongs to this service if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) { throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target); } // validate identifiers and group by collection $sources = $this->groupEntitiesByCollection(...$sources); // copy entities on remote store and construct result map $this->initialize(); $list = []; foreach ($sources as $sourceCollection => $sourceEntities) { $uids = array_keys($sourceEntities); $mutations = $this->mailService->entityCopy($target->collection(), $sourceCollection, ...$uids); foreach ($uids as $uid) { $mutatedUid = $mutations[$uid] ?? null; $list[(string)$sourceEntities[$uid]] = [ 'disposition' => $mutatedUid !== null ? 'copied' : 'error', 'destination' => $target, 'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null, ]; } } return $list; } /** * Resolve the native name of the first collection flagged with the given role. */ private function resolveRoleCollection(CollectionRoles $role): ?string { $filter = $this->collectionListFilter(); $filter->condition('role', $role->value); $collections = $this->mailService->collectionList(null, $filter, null); return $collections === [] ? null : (string) array_key_first($collections); } /** * Resolve the native name of the collection flagged with the Sent role. */ private function resolveSentCollection(): ?string { return $this->resolveRoleCollection(CollectionRoles::Sent); } /** * Resolve the native name of the collection soft-deleted messages and collections are moved to. */ private function resolveDeleteDestination(): string { $destination = trim((string) ($this->auxiliary['deleteDestination'] ?? '')); if ($destination === '') { return $this->resolveRoleCollection(CollectionRoles::Trash) ?? throw new \RuntimeException('No Trash collection configured or found for deletion'); } $role = CollectionRoles::tryFrom(strtolower($destination)); if ($role !== null && $role !== CollectionRoles::None) { return $this->resolveRoleCollection($role) ?? $destination; } return $destination; } private function groupEntitiesByCollection(EntityIdentifier ...$identifiers): array { $list = []; foreach ($identifiers as $identifier) { if ($identifier->provider() !== $this->provider() || $identifier->service() !== $this->identifier()) { throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . $identifier); } $list[$identifier->collection()][$identifier->entity()] = $identifier; } return $list; } /** * Convert canonical message flags to IMAP system flags. * * @return string[] */ private function messageFlags(MessagePropertiesMutableInterface $properties): array { $flags = []; foreach ($properties->getFlags() as $flag => $enabled) { if ($enabled !== true) { continue; } $flags[] = match (strtolower((string)$flag)) { 'seen' => '\\Seen', 'flagged' => '\\Flagged', 'answered' => '\\Answered', 'draft' => '\\Draft', 'deleted' => '\\Deleted', default => (string)$flag, }; } return array_values(array_unique($flags)); } /** * Convert message properties into the native IMAP append payload. * * @return array{0: string, 1: string[]} */ private function messagePayload(MessagePropertiesMutableInterface $properties, array $options = []): array { $flags = $this->messageFlags($properties); if (isset($options['flags']) && is_array($options['flags'])) { $flags = array_values(array_unique([...$flags, ...$options['flags']])); } return [(new MessageBuilder())->build($properties), $flags]; } protected function initialize(): void { if (!isset($this->mailService)) { $this->mailService = new LiveMailService($this); } } }