* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImapMail\Service\Remote; use DateTimeImmutable; use Generator; use Gricob\IMAP\Protocol\Command\Argument\Search\Before; use Gricob\IMAP\Protocol\Command\Argument\Search\Since; 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; use KTXM\ProviderImapMail\Service\Remote\Command\BodyCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\FlaggedCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\FromCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\LargerCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\SeenCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\SmallerCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\SubjectCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\ToCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\UnflaggedCriteria; use KTXM\ProviderImapMail\Service\Remote\Command\UnseenCriteria; /** * IMAP Remote Mail Service * * Provides collection (mailbox) and entity (message) operations against a live * IMAP server via ImapClientWrapper. All methods are stateless — no caching or * local storage happens here. */ class RemoteMailService { /** * Default IMAP FETCH data items used for message hydration. * * RFC 822 size, flags, arrival date, envelope headers, and the BODYSTRUCTURE * MIME tree give us everything needed to build an EntityResource without * downloading the full message body. */ private const DEFAULT_FETCH_ITEMS = [ 'FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID', 'BODY[TEXT]' ]; public function __construct( private readonly ImapClientWrapper $client, private readonly string $provider, private readonly string|int $service, ) {} // ── Collection (mailbox) operations ────────────────────────────────────── /** * 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 { // ── Build IMAP SEARCH criteria from filter ──────────────────────────── $criteria = []; if ($filter !== null) { foreach ($filter->conditions() as $condition) { $attribute = $condition['attribute']; $value = $condition['value']; $criterion = match ($attribute) { 'seen' => $value ? new SeenCriteria() : new UnseenCriteria(), 'flagged' => $value ? new FlaggedCriteria() : new UnflaggedCriteria(), 'from' => new FromCriteria($value), 'to' => new ToCriteria($value), 'subject' => new SubjectCriteria($value), 'body' => new BodyCriteria($value), 'before' => new Before(new DateTimeImmutable($value)), 'after' => new Since(new DateTimeImmutable($value)), 'min' => new LargerCriteria($value), 'max' => new SmallerCriteria($value), default => null, }; if ($criterion !== null) { $criteria[] = $criterion; } } } // ── Execute IMAP SEARCH (ALL when no criteria) ──────────────────────── $uids = empty($criteria) ? $this->client->searchAll($collection) : $this->client->searchMessages($collection, $criteria); if (empty($uids)) { return []; } // ── Sort descending: highest UID (newest) first ─────────────────────── rsort($uids); // ── Apply RangeTally pagination ─────────────────────────────────────── 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. * * Uses client->fetchMultiple() which streams FetchData responses one at a * time via sendStreaming — memory-efficient even for large UID sets. Body * content is NOT pre-loaded; call fetchBody() on the returned resource * when the decoded body is needed (lazy, one extra round-trip per message). * * @param int ...$uids * @return EntityResource[] keyed by UID */ public function entityFetch(string $collection, int ...$uids): array { if (empty($uids)) { return []; } $result = []; foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection, null); $result[$uid] = $resource; } return $result; } public function entityFetchStream(string $collection, int ...$uids): Generator { if (empty($uids)) { return; } foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection, null); 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 { foreach ($this->client->fetchMultiple($collection, $uids, $items) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); $resource->fromImap($fetchData, $collection, null); 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, $flags); } /** * 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); } // ── Helpers ─────────────────────────────────────────────────────────────── /** * Compact a flat array of UIDs into an IMAP sequence-set string. * * Consecutive UIDs are collapsed into n:m ranges; non-consecutive UIDs are * comma-separated. The input does not need to be sorted. * * Examples: * [1, 2, 3, 5, 6, 10] → "1:3,5:6,10" * [42] → "42" * [7, 3, 4, 5] → "3:5,7" * * @param int[] $uids */ private function uidsToRangeSet(array $uids): string { if (empty($uids)) { return ''; } $uids = array_unique($uids); sort($uids); $ranges = []; $start = $end = $uids[0]; for ($i = 1, $count = count($uids); $i <= $count; $i++) { $current = $uids[$i] ?? null; if ($current !== null && $current === $end + 1) { $end = $current; } else { $ranges[] = $start === $end ? (string) $start : $start . ':' . $end; if ($current !== null) { $start = $end = $current; } } } return implode(',', $ranges); } }