* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImapMail\Service\Remote; use Generator; use DateTimeInterface; use Gricob\IMAP\Client; use Gricob\IMAP\Mailbox; use Gricob\IMAP\Mime\Part\Body; use Gricob\IMAP\Mime\Part\Disposition; use Gricob\IMAP\Mime\Part\LazyBody; use Gricob\IMAP\Mime\Part\MultiPart; use Gricob\IMAP\Mime\Part\Part; use Gricob\IMAP\Mime\Part\SinglePart; use Gricob\IMAP\Protocol\Command\SearchCommand; use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; use Gricob\IMAP\Protocol\Command\Argument\Store\Flags; use Gricob\IMAP\Protocol\Command\ExpungeCommand; use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; use Gricob\IMAP\Protocol\Response\Line\Data\SearchData; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart as BodySinglePart; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart as BodyMultiPart; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part as BodyPart; use KTXM\ProviderImapMail\Service\Remote\Command\CopyCommand; use KTXM\ProviderImapMail\Service\Remote\Command\DeleteCommand; use KTXM\ProviderImapMail\Service\Remote\Command\RenameCommand; use KTXM\ProviderImapMail\Service\Remote\Command\StreamFetchCommand; use KTXM\ProviderImapMail\Service\Remote\Command\StoreCommand as ModuleStoreCommand; use KTXM\ProviderImapMail\Service\Remote\Command\UnseenCriteria; /** * Wraps a gricob IMAP Client to provide a stable, higher-level interface. * * Goals * ----- * - Hide raw gricob types from the rest of the service layer * - Fill gricob's gaps (DELETE, RENAME, UID COPY) with thin command wrappers * - Provide memory-efficient streaming via a Generator for cache sync * - Make the wrapper easy to mock in tests */ class ImapClientWrapper { public function __construct(private readonly Client $client) {} // ── Escape hatch ───────────────────────────────────────────────────────── /** Access the underlying gricob Client directly for operations not covered here. */ public function raw(): Client { return $this->client; } // ── Message Fetch ───────────────────────────────────────────────────────── /** * Stream messages one UID at a time to avoid loading entire mailboxes into * memory. Ideal for cache-sync operations on large mailboxes. * * @param string[] $items IMAP fetch data items ('FLAGS', 'ENVELOPE', 'INTERNALDATE', 'BODYSTRUCTURE', 'BODY[]', …) * @return Generator Yields uid => FetchData */ public function streamMessages(string $mailbox, array $uids, array $items): Generator { $this->client->select($mailbox); $response = $this->client->send(new StreamFetchCommand($uids, $items)); foreach ($response->getData(FetchData::class) as $fetchData) { yield ($fetchData->uid ?? $fetchData->id) => $fetchData; } } /** * Stream-fetch messages for specific UIDs using the Client's sendStreaming * path. Responses are processed one at a time as they arrive off the * socket — no buffering of the full server reply. * * Prefer this over streamMessages() for large UID sets where memory * pressure matters. streamMessages() collects all FetchData objects into * a Response first; this method yields each one as it is received. * * @param int[] $uids * @param string[] $items IMAP fetch data items * @return Generator Yields uid => FetchData */ public function fetchMultiple(string $mailbox, array $uids, array $items): Generator { if (empty($uids)) { return; } $this->client->select($mailbox); yield from $this->client->streamByUids($uids, $items); } /** * Bulk-fetch a known small batch of messages in a single IMAP round-trip. * * @param int[] $uids * @param string[] $items * @return FetchData[] */ public function fetchMessages(string $mailbox, array $uids, array $items): array { if (empty($uids)) { return []; } $this->client->select($mailbox); $response = $this->client->send(new StreamFetchCommand($uids, $items)); return $response->getData(FetchData::class); } // ── Flag Mutation ───────────────────────────────────────────────────────── /** * Apply a flag store operation to multiple messages in a single round-trip. * * @param int[] $uids * @param string $action '+' | '-' | '' (replace) * @param string[] $flags e.g. ['\Seen', '\Answered'] */ public function storeFlags(string $mailbox, array $uids, string $action, array $flags): void { if (empty($uids)) { return; } $this->client->select($mailbox); $this->client->send(new ModuleStoreCommand($uids, new Flags($flags, $action))); } // ── Message Copy ────────────────────────────────────────────────────────── /** * Copy multiple messages to a destination mailbox in a single round-trip. * * @param int[] $uids */ public function copyMessages(string $mailbox, array $uids, string $destination): void { if (empty($uids)) { return; } $this->client->select($mailbox); $this->client->send(new CopyCommand($uids, $destination)); } // ── Mailbox Commands ────────────────────────────────────────────────────── /** Delete a mailbox (gricob gap — uses raw DELETE command). */ public function deleteMailbox(string $mailbox): void { $this->client->send(new DeleteCommand($mailbox)); } /** Rename a mailbox (gricob gap — uses raw RENAME command). */ public function renameMailbox(string $oldName, string $newName): void { $this->client->send(new RenameCommand($oldName, $newName)); } /** * List all mailboxes (via IMAP LIST command). * * @return Mailbox[] */ public function mailboxes(): array { return $this->client->mailboxes(); } /** * Create a new mailbox. */ public function createMailbox(string $name): void { $this->client->createMailbox($name); } /** * Append a raw RFC822 message to a mailbox. * * @param string[] $flags e.g. ['\\Seen'] * @return int the UID assigned to the new message */ public function append( string $rawMessage, string $mailbox = 'INBOX', array $flags = [], ?DateTimeInterface $internalDate = null, ): int { return $this->client->append( $rawMessage, $mailbox, !empty($flags) ? $flags : null, $internalDate, ); } /** * Mark multiple messages as \\Deleted then EXPUNGE the mailbox. * * Performs a UID STORE +FLAGS.SILENT (\\Deleted) on all UIDs in a single * round-trip, then issues a plain EXPUNGE. * * @param int[] $uids */ public function deleteMessages(string $mailbox, array $uids): void { if (empty($uids)) { return; } $this->storeFlags($mailbox, $uids, '+', ['\\Deleted']); $this->client->send(new ExpungeCommand()); } // ── Search ──────────────────────────────────────────────────────────────── /** * Return all UIDs in the currently-selected mailbox matching UNSEEN. * * Sends UID SEARCH UNSEEN directly since gricob's Search builder has no * unseen() method. Results are UIDs (useUid=true on Configuration). * * @return int[] */ public function searchUnseen(string $mailbox): array { $this->client->select($mailbox); $response = $this->client->send( new SearchCommand( $this->client->configuration->useUid, new UnseenCriteria(), ) ); $ids = []; foreach ($response->getData(SearchData::class) as $searchData) { array_push($ids, ...$searchData->numbers); } return $ids; } /** * Return all UIDs in the selected mailbox. * * @return int[] */ public function searchAll(string $mailbox): array { $mailbox = $this->client->select($mailbox); // search()->get() with no criteria uses ALL; returns LazyMessage[] // where id() is the UID when useUid=true $messages = $this->client->search()->get(); return array_map(fn ($m) => $m->id(), $messages); } /** * Return UIDs in the selected mailbox matching the given criteria. * * Sends a UID SEARCH command with the provided Criteria instances. * Pass an empty array to match ALL messages. * * @param \Gricob\IMAP\Protocol\Command\Argument\Search\Criteria[] $criteria * @return int[] */ public function searchMessages(string $mailbox, array $criteria): array { $this->client->select($mailbox); $response = $this->client->send( new SearchCommand( $this->client->configuration->useUid, ...$criteria, ) ); $ids = []; foreach ($response->getData(SearchData::class) as $searchData) { array_push($ids, ...$searchData->numbers); } return $ids; } /** * Fetch the MIME body Part tree for a single message. * * The returned Part tree uses LazyBody instances that defer actual * BODY[section] fetches until decodedBody() is called. Call * Part::findPartByMimeType('text/html') and * Part::findPartByMimeType('text/plain') on the result. * * Note: the mailbox must already be selected (fetchMessages() does this). */ public function fetchBodyParts(int $uid): Part { return $this->client->fetchBody($uid); } /** * Build the MIME body Part tree from an already-fetched FetchData object. * * Optionally accepts a pre-loaded sections map (section => raw text) to * avoid any secondary IMAP fetches. Falls back to LazyBody for sections * not present in the map. * * @param array $sections pre-fetched section bodies keyed by section path */ public function fetchBodyPartsFromData(FetchData $fetchData, array $sections = []): ?Part { if ($fetchData->bodyStructure === null || $fetchData->bodyStructure->part === null) { return null; } $uid = $fetchData->uid ?? $fetchData->id; return $this->buildPartsFromStructure($uid, '0', $fetchData->bodyStructure->part, $sections); } /** * Two-phase batch fetch: metadata+BODYSTRUCTURE for all UIDs in one * command, then all text/* body sections for all UIDs in a second command. * * Yields uid => [FetchData $meta, ?Part $body] with no per-message or * per-section secondary fetches. * * @param int[] $uids * @return \Generator */ public function fetchMessagesWithBody(string $mailbox, array $uids): \Generator { $this->client->select($mailbox); // ── Phase 1: metadata + BODYSTRUCTURE for all UIDs in one round-trip ── $metaItems = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID']; $response = $this->client->send(new StreamFetchCommand($uids, $metaItems)); /** @var array $metaByUid */ $metaByUid = []; foreach ($response->getData(FetchData::class) as $fetchData) { $uid = $fetchData->uid ?? $fetchData->id; $metaByUid[$uid] = $fetchData; } // ── Discover text section paths across all messages ─────────────────── $allSections = []; // section-path => true (de-duplicated union) foreach ($metaByUid as $fetchData) { if ($fetchData->bodyStructure?->part !== null) { foreach ($this->findTextSections($fetchData->bodyStructure->part) as $section) { $allSections[$section] = true; } } } // ── Phase 2: fetch all text sections for all UIDs in one round-trip ─── // Some servers return NIL for an empty/missing body section instead of // a literal, which gricob's parser cannot handle. If the batch fetch // fails for any reason we leave $sectionsByUid empty so that individual // parts fall back to LazyBody on first access. /** @var array> $sectionsByUid uid => [section => text] */ $sectionsByUid = []; if (!empty($allSections)) { try { $sectionItems = array_map(fn ($s) => 'BODY[' . $s . ']', array_keys($allSections)); array_unshift($sectionItems, 'UID'); $bodyResponse = $this->client->send(new StreamFetchCommand($uids, $sectionItems)); foreach ($bodyResponse->getData(FetchData::class) as $fetchData) { $uid = $fetchData->uid ?? $fetchData->id; $map = []; foreach ($fetchData->bodySections as $bodySection) { $map[$bodySection->section] = $bodySection->text; } $sectionsByUid[$uid] = $map; } } catch (\Throwable) { // Parser could not handle a NIL body section — LazyBody will // fetch individual sections on demand instead. $sectionsByUid = []; } } // ── Yield merged result ─────────────────────────────────────────────── foreach ($metaByUid as $uid => $fetchData) { $sections = $sectionsByUid[$uid] ?? []; yield $uid => [$fetchData, $this->fetchBodyPartsFromData($fetchData, $sections)]; } } /** * Walk a BodyStructure tree and return the IMAP section paths of all * text/* leaves (text/plain, text/html, text/calendar, …). * * Sections with size=0 are excluded: servers respond to such fetches with * NIL instead of a literal and gricob's parser cannot handle that. * * @return string[] e.g. ['1', '2'] or ['1.1', '1.2'] */ private function findTextSections(BodyPart $part, string $section = '0'): array { if ($part instanceof BodySinglePart) { $resolvedSection = $section === '0' ? '1' : $section; return (strtolower($part->type) === 'text' && $part->size > 0) ? [$resolvedSection] : []; } if ($part instanceof BodyMultiPart) { $sections = []; foreach ($part->parts as $index => $childPart) { $childIndex = (string) ($index + 1); $childSection = $section === '0' ? $childIndex : $section . '.' . $childIndex; array_push($sections, ...$this->findTextSections($childPart, $childSection)); } return $sections; } return []; } /** * Recursively build a Mime Part tree from a BodyStructure part. * * @param array $sections pre-fetched section bodies (section path => raw text). * Missing sections fall back to LazyBody. */ private function buildPartsFromStructure(int $uid, string $section, BodyPart $part, array $sections = []): Part { if ($part instanceof BodySinglePart) { $resolvedSection = $section === '0' ? '1' : $section; $body = isset($sections[$resolvedSection]) ? new Body($sections[$resolvedSection]) : new LazyBody($this->client, $uid, $resolvedSection); return new SinglePart( $part->type, $part->subtype, $part->attributes, $body, $part->attributes['charset'] ?? 'utf-8', $part->encoding, $part->disposition !== null ? new Disposition( $part->disposition->type, $part->disposition->attributes['filename'] ?? null, ) : null, ); } if ($part instanceof BodyMultiPart) { $childParts = []; foreach ($part->parts as $index => $childPart) { $childIndex = (string) ($index + 1); $childSection = $section === '0' ? $childIndex : $section . '.' . $childIndex; $childParts[] = $this->buildPartsFromStructure($uid, $childSection, $childPart, $sections); } return new MultiPart($part->subtype, $part->attributes, $childParts); } throw new \RuntimeException('Unexpected BodyStructure part type: ' . $part::class); } }