* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImapMail\Service\Remote; use DateTimeImmutable; use Generator; use Gricob\IMAP\Client; use Gricob\IMAP\Protocol\Command\Argument\Search\Before; use Gricob\IMAP\Protocol\Command\Argument\Search\Body; use Gricob\IMAP\Protocol\Command\Argument\Search\Flagged; use Gricob\IMAP\Protocol\Command\Argument\Search\From; use Gricob\IMAP\Protocol\Command\Argument\Search\Larger; use Gricob\IMAP\Protocol\Command\Argument\Search\Seen; use Gricob\IMAP\Protocol\Command\Argument\Search\Since; use Gricob\IMAP\Protocol\Command\Argument\Search\Smaller; use Gricob\IMAP\Protocol\Command\Argument\Search\Subject; use Gricob\IMAP\Protocol\Command\Argument\Search\To; use Gricob\IMAP\Protocol\Command\Argument\Search\Unflagged; use Gricob\IMAP\Protocol\Command\Argument\Search\Unseen; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXM\ProviderImapMail\Providers\CollectionResource; use KTXM\ProviderImapMail\Providers\EntityResource; /** * IMAP Remote Mail Service */ class RemoteMailService { /** * Default IMAP FETCH data items used for message hydration */ private const DEFAULT_FETCH_ITEMS = [ 'FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID', 'BODY[TEXT]' ]; public function __construct( private readonly Client $client, private readonly string $provider, private readonly string|int $service, ) {} /** * List all selectable mailboxes on the server. * * @return array keyed by mailbox name */ public function collectionList(): array { $result = []; foreach ($this->client->mailboxes() as $mailbox) { if (!$mailbox->isSelectable()) { continue; } $resource = new CollectionResource($this->provider, $this->service); $resource->fromImap($mailbox); $result[$resource->identifier()] = $resource; } return $result; } /** * Fetch a single mailbox by its full name. * * Returns null when no mailbox matching $name is found. */ public function collectionFetch(string $name): ?CollectionResource { foreach ($this->client->mailboxes() as $mailbox) { if ($mailbox->name === $name) { $resource = new CollectionResource($this->provider, $this->service); $resource->fromImap($mailbox); return $resource; } } return null; } /** * Create a new IMAP mailbox and return it. * * 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): CollectionResource { $this->client->createMailbox($name); // Attempt to refetch the new mailbox from the server $resource = $this->collectionFetch($name); if ($resource !== null) { return $resource; } // Fallback: return a minimal resource with just the name set $resource = new CollectionResource($this->provider, $this->service); $resource->fromStore(['identifier' => $name, 'collection' => null]); return $resource; } /** * Rename a mailbox and return the updated resource. */ public function collectionRename(string $oldName, string $newName): CollectionResource { $this->client->renameMailbox($oldName, $newName); $resource = $this->collectionFetch($newName); if ($resource !== null) { return $resource; } $resource = new CollectionResource($this->provider, $this->service); $resource->fromStore(['identifier' => $newName, 'collection' => null]); return $resource; } /** * Delete a mailbox by its full name. */ public function collectionDestroy(string $name): bool { $this->client->deleteMailbox($name); return true; } // ── Entity (message) operations ─────────────────────────────────────────── /** * Return UIDs present in a mailbox, optionally filtered and paginated. * * UIDs are always returned descending (highest = newest first). * When a RangeTally $range is supplied: * - ABSOLUTE anchor: slice from position offset for tally items * - RELATIVE anchor: find the UID whose value equals position, then * return the next tally items (cursor-based paging) * * @return int[] */ public function entityList(string $collection, ?IFilter $filter = null, ?IRange $range = null): array { $criteria = []; if ($filter !== null) { foreach ($filter->conditions() as $condition) { $attribute = $condition['attribute']; $value = $condition['value']; $criterion = match ($attribute) { 'seen' => $value ? new Seen() : new Unseen(), 'flagged' => $value ? new Flagged() : new Unflagged(), 'from' => new From($value), 'to' => new To($value), 'subject' => new Subject($value), 'body' => new Body($value), 'before' => new Before(new DateTimeImmutable($value)), 'after' => new Since(new DateTimeImmutable($value)), 'min' => new Larger($value), 'max' => new Smaller($value), default => null, }; if ($criterion !== null) { $criteria[] = $criterion; } } } $uids = $this->client->searchMessages($collection, $criteria); if (empty($uids)) { return []; } rsort($uids); if ($range instanceof RangeTally) { $position = (int) $range->getPosition(); $tally = $range->getTally(); if ($range->getAnchor() === RangeAnchorType::RELATIVE) { // Cursor-based: find the anchor UID then take the next slice $index = array_search($position, $uids, true); $start = $index !== false ? $index + 1 : 0; } else { // Absolute offset $start = $position; } $uids = array_slice($uids, $start, $tally); } return $uids; } /** * Fetch one or more messages by UID and return EntityResource objects. * * @param int ...$uids * @return EntityResource[] keyed by UID */ public function entityFetch(string $collection, int ...$uids): array { if (empty($uids)) { return []; } $this->client->select($collection); $result = []; foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection); $result[$uid] = $resource; } return $result; } public function entityFetchStream(string $collection, int ...$uids): Generator { if (empty($uids)) { return; } $this->client->select($collection); foreach ($this->client->streamByUids(array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection); yield $uid => $resource; } } /** * Fetch every message in a mailbox using a single FETCH 1:* command and * return all EntityResource objects as an array keyed by UID. * * Use this for unfiltered, unpaginated listing where a two-round-trip * SEARCH-then-FETCH approach would be wasteful. * * @param string[] $items IMAP fetch data items * @return EntityResource[] keyed by UID */ public function entityFetchAll(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): array { $result = []; foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection); $result[$uid] = $resource; } return $result; } /** * Stream every message in a mailbox using FETCH 1:*, yielding * uid => EntityResource as each FETCH response arrives off the socket. * * Use this for unfiltered streaming where a SEARCH ALL round-trip would be * an unnecessary extra RTT. * * @param string[] $items IMAP fetch data items * @return Generator */ public function entityFetchAllStream(string $collection, array $items = self::DEFAULT_FETCH_ITEMS): Generator { foreach ($this->client->streamAll($collection, $items) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection); yield $uid => $resource; } } /** * Stream messages one at a time as EntityResource objects. * * Yields uid (int) => EntityResource. Use this for large mailbox syncs to * avoid holding thousands of objects in memory simultaneously. * * Pass a custom $items array to restrict the fetched data (e.g. ['FLAGS', 'UID'] * for a flags-only sync). Defaults to DEFAULT_FETCH_ITEMS. * * @param int[] $uids * @param string[] $items IMAP fetch data items * @return \Generator */ public function entitySyncStream(string $collection, array $uids, array $items = self::DEFAULT_FETCH_ITEMS): Generator { $this->client->select($collection); foreach ($this->client->streamByUids($uids, $items) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection); yield $uid => $resource; } } /** * Append a raw RFC 822 message to a mailbox and return the assigned UID. * * @param string[] $flags optional initial flags, e.g. ['\\Seen'] */ public function entityCreate(string $collection, string $rawMessage, array $flags = []): int { return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null); } /** * Modify message flags for one or more messages. * * @param string $action '+' to add, '-' to remove, '' to replace * @param string[] $flags e.g. ['\\Seen', '\\Flagged'] * @param int ...$uids */ public function entityModify(string $collection, string $action, array $flags, int ...$uids): void { if (empty($uids)) { return; } $this->client->storeFlags($collection, array_values($uids), $action, $flags); } /** * Permanently delete one or more messages by UID. */ public function entityDestroy(string $collection, int ...$uids): void { if (empty($uids)) { return; } $this->client->deleteMessages($collection, array_values($uids)); } /** * Copy one or more messages to a destination mailbox. */ public function entityCopy(string $collection, string $destination, int ...$uids): void { if (empty($uids)) { return; } $this->client->copyMessages($collection, array_values($uids), $destination); } }