From 7446edced30a3edcb44be13dc08d92fb0263d174 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 20 Feb 2026 23:34:30 -0500 Subject: [PATCH] feat: speed improvements Signed-off-by: Sebastian Krupinski --- lib/Client/Client.php | 110 +++- .../Protocol/Command/Argument/Search/Body.php | 15 + .../Command/Argument/Search/Flagged.php | 13 + .../Protocol/Command/Argument/Search/From.php | 15 + .../Command/Argument/Search/Larger.php | 15 + .../Protocol/Command/Argument/Search/Seen.php | 13 + .../Command/Argument/Search/Smaller.php | 15 + .../Command/Argument/Search/Subject.php | 15 + .../Protocol/Command/Argument/Search/To.php | 15 + .../Command/Argument/Search/Unflagged.php | 13 + .../Command/Argument/Search/Unseen.php | 13 + .../Protocol/Command/Argument/SequenceSet.php | 50 ++ .../Protocol/Response/Parser/Parser.php | 190 ++++++- lib/Providers/CollectionProperties.php | 7 +- lib/Providers/EntityResource.php | 11 +- lib/Providers/MessagePart.php | 61 ++- lib/Providers/MessageProperties.php | 46 +- lib/Providers/Service.php | 19 +- lib/Service/Remote/Command/BodyCriteria.php | 25 - lib/Service/Remote/Command/CopyCommand.php | 38 -- lib/Service/Remote/Command/DeleteCommand.php | 28 -- .../Remote/Command/FlaggedCriteria.php | 23 - lib/Service/Remote/Command/FromCriteria.php | 25 - lib/Service/Remote/Command/LargerCriteria.php | 25 - lib/Service/Remote/Command/RenameCommand.php | 28 -- lib/Service/Remote/Command/SeenCriteria.php | 23 - .../Remote/Command/SmallerCriteria.php | 25 - .../Remote/Command/StartTlsCommand.php | 27 - lib/Service/Remote/Command/StoreCommand.php | 39 -- .../Remote/Command/StreamFetchCommand.php | 39 -- .../Remote/Command/SubjectCriteria.php | 25 - lib/Service/Remote/Command/ToCriteria.php | 25 - .../Remote/Command/UnflaggedCriteria.php | 23 - lib/Service/Remote/Command/UnseenCriteria.php | 26 - lib/Service/Remote/ImapClientWrapper.php | 470 ------------------ lib/Service/Remote/RemoteMailService.php | 164 +++--- lib/Service/Remote/RemoteService.php | 20 +- 37 files changed, 648 insertions(+), 1086 deletions(-) create mode 100644 lib/Client/Protocol/Command/Argument/Search/Body.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/Flagged.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/From.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/Larger.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/Seen.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/Smaller.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/Subject.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/To.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/Unflagged.php create mode 100644 lib/Client/Protocol/Command/Argument/Search/Unseen.php delete mode 100644 lib/Service/Remote/Command/BodyCriteria.php delete mode 100644 lib/Service/Remote/Command/CopyCommand.php delete mode 100644 lib/Service/Remote/Command/DeleteCommand.php delete mode 100644 lib/Service/Remote/Command/FlaggedCriteria.php delete mode 100644 lib/Service/Remote/Command/FromCriteria.php delete mode 100644 lib/Service/Remote/Command/LargerCriteria.php delete mode 100644 lib/Service/Remote/Command/RenameCommand.php delete mode 100644 lib/Service/Remote/Command/SeenCriteria.php delete mode 100644 lib/Service/Remote/Command/SmallerCriteria.php delete mode 100644 lib/Service/Remote/Command/StartTlsCommand.php delete mode 100644 lib/Service/Remote/Command/StoreCommand.php delete mode 100644 lib/Service/Remote/Command/StreamFetchCommand.php delete mode 100644 lib/Service/Remote/Command/SubjectCriteria.php delete mode 100644 lib/Service/Remote/Command/ToCriteria.php delete mode 100644 lib/Service/Remote/Command/UnflaggedCriteria.php delete mode 100644 lib/Service/Remote/Command/UnseenCriteria.php delete mode 100644 lib/Service/Remote/ImapClientWrapper.php diff --git a/lib/Client/Client.php b/lib/Client/Client.php index f475183..c630cfa 100644 --- a/lib/Client/Client.php +++ b/lib/Client/Client.php @@ -16,6 +16,7 @@ use Gricob\IMAP\Mime\Part\MultiPart; use Gricob\IMAP\Mime\Part\Part; use Gricob\IMAP\Mime\Part\SinglePart; use Gricob\IMAP\Protocol\Command\AppendCommand; +use Gricob\IMAP\Protocol\Command\Argument\QuotedString; use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; use Gricob\IMAP\Protocol\Command\Argument\Store\Flags; @@ -27,6 +28,7 @@ use Gricob\IMAP\Protocol\Command\ExpungeCommand; use Gricob\IMAP\Protocol\Command\FetchCommand; use Gricob\IMAP\Protocol\Command\ListCommand; use Gricob\IMAP\Protocol\Command\LogInCommand; +use Gricob\IMAP\Protocol\Command\SearchCommand; use Gricob\IMAP\Protocol\Command\SelectCommand; use Gricob\IMAP\Protocol\Command\StoreCommand; use Gricob\IMAP\Protocol\Imap; @@ -238,6 +240,43 @@ class Client } } + /** + * Stream every message in the currently-selected mailbox using a 1:* + * sequence set, yielding uid (or sequence number) => FetchData as each + * FETCH response arrives off the socket. + * + * @param string $mailbox Mailbox to select before fetching + * @param string[] $items IMAP FETCH data items + * @return Generator + */ + public function streamAll( + string $mailbox, + array $items = ['FLAGS', 'ENVELOPE', 'INTERNALDATE', 'RFC822.SIZE', 'BODYSTRUCTURE', 'UID'], + ): Generator { + $this->select($mailbox); + + $gen = $this->imap->sendStreaming( + new FetchCommand( + $this->configuration->useUid, + SequenceSet::all(), + $items, + ) + ); + + foreach ($gen as $line) { + if (!$line instanceof FetchData) { + continue; + } + + $id = $line->id; + if ($this->configuration->useUid) { + $id = $line->uid ?? throw new RuntimeException('Unable to get uid from message ' . $line->id); + } + + yield $id => $line; + } + } + /** * Stream messages from a sequence range as a Generator, yielding each * LazyMessage as soon as its FETCH response line arrives off the socket — @@ -378,6 +417,75 @@ class Client $this->send(new CreateCommand($name)); } + /** Delete a mailbox by name. */ + public function deleteMailbox(string $name): void + { + $this->send(new Command('DELETE', new QuotedString($name))); + } + + /** Rename a mailbox. */ + public function renameMailbox(string $oldName, string $newName): void + { + $this->send(new Command('RENAME', new QuotedString($oldName), new QuotedString($newName))); + } + + /** + * Copy messages to a destination mailbox. + * + * @param int[] $uids + */ + public function copyMessages(string $mailbox, array $uids, string $destination): void + { + $this->select($mailbox); + $this->send(new Command('UID COPY', new SequenceSet(...$uids), new QuotedString($destination))); + } + + /** + * Set, add, or remove flags on a set of messages in a single round-trip. + * + * @param string $action '+' to add, '-' to remove, '' to replace + * @param string[] $flags e.g. ['\\Seen', '\\Flagged'] + * @param int[] $uids + */ + public function storeFlags(string $mailbox, array $uids, string $action, array $flags): void + { + $this->select($mailbox); + $this->send(new StoreCommand( + $this->configuration->useUid, + new SequenceSet(...$uids), + new Flags($flags, $action), + )); + } + + /** + * Permanently delete messages by UID (marks \\Deleted then EXPUNGEs). + * + * @param int[] $uids + */ + public function deleteMessages(string $mailbox, array $uids): void + { + $this->storeFlags($mailbox, $uids, '+', ['\\Deleted']); + $this->send(new ExpungeCommand()); + } + + /** + * Search a mailbox with the given criteria and return matching UIDs (or + * sequence numbers when useUid is false). + * + * @param Criteria[] $criteria Pass no criteria to match ALL messages. + * @return int[] + */ + public function searchMessages(string $mailbox, array $criteria = []): array + { + $this->select($mailbox); + $response = $this->send(new SearchCommand($this->configuration->useUid, ...$criteria)); + $ids = []; + foreach ($response->getData(SearchData::class) as $searchData) { + array_push($ids, ...$searchData->numbers); + } + return $ids; + } + /** * @param list|null $flags */ @@ -412,7 +520,7 @@ class Client public function doSearch(array $criteria, ?PreFetchOptions $preFetchOptions = null): array { $response = $this->send( - new Protocol\Command\SearchCommand( + new SearchCommand( $this->configuration->useUid, ...$criteria ) diff --git a/lib/Client/Protocol/Command/Argument/Search/Body.php b/lib/Client/Protocol/Command/Argument/Search/Body.php new file mode 100644 index 0000000..9721c94 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Body.php @@ -0,0 +1,15 @@ +value) . '"'; + } +} diff --git a/lib/Client/Protocol/Command/Argument/Search/Flagged.php b/lib/Client/Protocol/Command/Argument/Search/Flagged.php new file mode 100644 index 0000000..d8e8234 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Flagged.php @@ -0,0 +1,13 @@ +value) . '"'; + } +} diff --git a/lib/Client/Protocol/Command/Argument/Search/Larger.php b/lib/Client/Protocol/Command/Argument/Search/Larger.php new file mode 100644 index 0000000..1e79555 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Larger.php @@ -0,0 +1,15 @@ +size; + } +} diff --git a/lib/Client/Protocol/Command/Argument/Search/Seen.php b/lib/Client/Protocol/Command/Argument/Search/Seen.php new file mode 100644 index 0000000..cb0a4f2 --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Seen.php @@ -0,0 +1,13 @@ +size; + } +} diff --git a/lib/Client/Protocol/Command/Argument/Search/Subject.php b/lib/Client/Protocol/Command/Argument/Search/Subject.php new file mode 100644 index 0000000..8e6991b --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Subject.php @@ -0,0 +1,15 @@ +value) . '"'; + } +} diff --git a/lib/Client/Protocol/Command/Argument/Search/To.php b/lib/Client/Protocol/Command/Argument/Search/To.php new file mode 100644 index 0000000..db4dbbb --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/To.php @@ -0,0 +1,15 @@ +value) . '"'; + } +} diff --git a/lib/Client/Protocol/Command/Argument/Search/Unflagged.php b/lib/Client/Protocol/Command/Argument/Search/Unflagged.php new file mode 100644 index 0000000..074a64e --- /dev/null +++ b/lib/Client/Protocol/Command/Argument/Search/Unflagged.php @@ -0,0 +1,13 @@ +range = '1:*'; + return $set; + } + + /** + * Build a SequenceSet from a flat array of UIDs, collapsing consecutive + * values into n:m ranges. + * + * Examples: + * [1, 2, 3, 5, 6, 10] → "1:3,5:6,10" + * [42] → "42" + * [7, 3, 4, 5] → "3:5,7" + * + * @param int[] $uids + */ + public static function list(array $uids): self + { + if (empty($uids)) { + return new self(); + } + + $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; + } + } + } + + $set = new self(); + $set->range = implode(',', $ranges); + return $set; + } + public function __toString(): string { if ($this->range !== null) { diff --git a/lib/Client/Protocol/Response/Parser/Parser.php b/lib/Client/Protocol/Response/Parser/Parser.php index a718a1a..bc6e6c5 100644 --- a/lib/Client/Protocol/Response/Parser/Parser.php +++ b/lib/Client/Protocol/Response/Parser/Parser.php @@ -4,6 +4,7 @@ namespace Gricob\IMAP\Protocol\Response\Parser; use DateTimeImmutable; use Doctrine\Common\Lexer\Token; +use Gricob\IMAP\Mime\Part\Body; use Gricob\IMAP\Protocol\Response\Line\CommandContinuation; use Gricob\IMAP\Protocol\Response\Line\Data\CapabilityData; use Gricob\IMAP\Protocol\Response\Line\Data\ExistsData; @@ -11,6 +12,9 @@ use Gricob\IMAP\Protocol\Response\Line\Data\ExpungeData; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Address; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodySection; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part; +use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope; use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; use Gricob\IMAP\Protocol\Response\Line\Data\FlagsData; @@ -353,7 +357,7 @@ readonly class Parser $this->space(); $text = $this->literal(); - $bodySections[] = new BodySection($section, $text); + $bodySections = $this->fetchBody($bodyStructure, $text); } break; case TokenType::ENVELOPE: @@ -384,6 +388,145 @@ readonly class Parser ); } + /** + * @return BodySection[] + */ + private function fetchBody(?BodyStructure $node, string $data): array { + return $this->fetchBodyNode($node->part, $data); + } + + /** + * @return BodySection[] + */ + private function fetchBodyNode(?Part $node, string $data, string $partId = ''): array { + if ($node instanceof MultiPart) { + return $this->fetchBodyMultipart($node, $data, $partId); + } + + if ($node instanceof SinglePart) { + return [$this->fetchBodySinglePart($data, $partId)]; + } + + return []; + } + + /** + * @return BodySection + */ + private function fetchBodySinglePart(string $data, string $partId = ''): BodySection + { + $partId = empty($partId) ? '1' : $partId; + return new BodySection($partId, $data); + } + + /** + * @return BodySection[] + */ + private function fetchBodyMultipart(MultiPart $structure, string $data, string $partId = ''): array + { + $boundary = null; + foreach ($structure->attributes as $key => $value) { + if (strtolower($key) === 'boundary') { + $boundary = $value; + break; + } + } + + if ($boundary === null) { + throw new \RuntimeException('Multipart missing boundary attribute'); + } + + $chunks = $this->splitOnBoundary($data, $boundary); + + $parts = []; + foreach ($structure->parts as $i => $childStructure) { + $chunk = $chunks[$i] ?? ''; + $chunk = $this->stripPartHeaders($chunk); + $id = empty($partId) ? (string)($i + 1) : $partId . '.' . ($i + 1); + $parts = array_merge($parts, $this->fetchBodyNode($childStructure, $chunk, $id)); + } + + return $parts; + } + + /** + * Split $raw on MIME boundary delimiter lines, returning one string per + * body part. The preamble (before the first delimiter) and epilogue + * (after the close delimiter) are discarded. + * + * @return string[] + */ + private function splitOnBoundary(string $raw, string $boundary): array + { + $delimiter = '--' . $boundary; + $closeDelimiter = '--' . $boundary . '--'; + + $parts = []; + $current = null; + + // Handle both CRLF and bare-LF line endings + $lines = preg_split('/\r?\n/', $raw); + + foreach ($lines as $line) { + $trimmed = rtrim($line); + + if ($trimmed === $closeDelimiter) { + if ($current !== null) { + $parts[] = rtrim($current, "\r\n"); + } + break; + } + + if ($trimmed === $delimiter) { + if ($current !== null) { + $parts[] = rtrim($current, "\r\n"); + } + $current = ''; + continue; + } + + if ($current !== null) { + $current .= $line . "\r\n"; + } + // Lines before the first delimiter are preamble — ignored + } + + // If the close delimiter was absent, flush whatever is buffered + if ($current !== null && $current !== '') { + $trimmed = rtrim($current, "\r\n"); + if (!in_array($trimmed, $parts, true)) { + $parts[] = $trimmed; + } + } + + return $parts; + } + + /** + * Strip MIME part headers from a body chunk. + * + * Each part chunk begins with its own headers (Content-Type, + * Content-Transfer-Encoding, etc.) followed by a blank line. + * Since BODYSTRUCTURE already supplies all encoding/charset info, + * we discard the part headers and return the raw body bytes only. + */ + private function stripPartHeaders(string $raw): string + { + // Try CRLF blank line first, then bare LF + $crlfPos = strpos($raw, "\r\n\r\n"); + $lfPos = strpos($raw, "\n\n"); + + if ($crlfPos !== false && ($lfPos === false || $crlfPos <= $lfPos)) { + return substr($raw, $crlfPos + 4); + } + + if ($lfPos !== false) { + return substr($raw, $lfPos + 2); + } + + return $raw; + } + /** * @throws ParseError */ @@ -1005,13 +1148,48 @@ readonly class Parser return $raw; } - for ($i = 0; $i < strlen($raw); $i++) { - $character = $raw[$i]; - if (!mb_check_encoding($character, 'US-ASCII')) { - $raw[$i] = ' '; + $result = ''; + $pos = 0; + $len = strlen($raw); + + while ($pos < $len) { + if (preg_match('/\{(\d+)\}\r\n/', $raw, $m, PREG_OFFSET_CAPTURE, $pos)) { + $braceOff = (int) $m[0][1]; + $literalLen = (int) $m[1][0]; + $headerLen = strlen($m[0][0]); + + // Sanitize structural text that precedes this literal + $result .= $this->sanitizeChunk(substr($raw, $pos, $braceOff - $pos)); + + // Preserve the {N}\r\n marker verbatim + $result .= $m[0][0]; + + // Preserve the literal body bytes verbatim (may be UTF-8 / 8-bit) + $result .= substr($raw, $braceOff + $headerLen, $literalLen); + + $pos = $braceOff + $headerLen + $literalLen; + } else { + // No more literals — sanitize the remainder + $result .= $this->sanitizeChunk(substr($raw, $pos)); + break; } } - return $raw; + return $result; + } + + private function sanitizeChunk(string $chunk): string + { + if (mb_check_encoding($chunk, 'US-ASCII')) { + return $chunk; + } + + for ($i = 0, $len = strlen($chunk); $i < $len; $i++) { + if (!mb_check_encoding($chunk[$i], 'US-ASCII')) { + $chunk[$i] = ' '; + } + } + + return $chunk; } } diff --git a/lib/Providers/CollectionProperties.php b/lib/Providers/CollectionProperties.php index 729e086..b163ff4 100644 --- a/lib/Providers/CollectionProperties.php +++ b/lib/Providers/CollectionProperties.php @@ -31,8 +31,11 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract */ public function fromImap(Mailbox $mailbox): static { - $this->data['label'] = $mailbox->name; - $this->data['delimiter'] = $mailbox->hierarchyDelimiter; + $delimiter = $mailbox->hierarchyDelimiter; + $this->data['label'] = ($delimiter !== '' && str_contains($mailbox->name, $delimiter)) + ? substr($mailbox->name, strrpos($mailbox->name, $delimiter) + strlen($delimiter)) + : $mailbox->name; + $this->data['delimiter'] = $delimiter; $this->data['attributes'] = $mailbox->nameAttributes; $this->data['subscribed'] = in_array('\Subscribed', $mailbox->nameAttributes, true); $this->data['total'] = 0; diff --git a/lib/Providers/EntityResource.php b/lib/Providers/EntityResource.php index 4bbbdaf..b3b396b 100644 --- a/lib/Providers/EntityResource.php +++ b/lib/Providers/EntityResource.php @@ -31,9 +31,8 @@ class EntityResource extends EntityMutableAbstract { * * @param FetchData $fetchData result from IMAP FETCH command * @param string $mailbox IMAP mailbox name (used as collection) - * @param Part|null $bodyPart MIME Part tree for body content (optional) */ - public function fromImap(FetchData $fetchData, string $mailbox, ?Part $bodyPart = null): static { + public function fromImap(FetchData $fetchData, string $mailbox): static { // Collection = the IMAP mailbox name $this->data['collection'] = $mailbox; @@ -46,13 +45,7 @@ class EntityResource extends EntityMutableAbstract { $this->data['created'] = $fetchData->internalDate->format(DateTimeInterface::ATOM); } - $this->getProperties()->fromImap( - flags: $fetchData->flags ?? [], - envelope: $fetchData->envelope, - bodyStructure: $fetchData->bodyStructure, - size: $fetchData->rfc822Size ?? 0, - bodyPart: $bodyPart, - ); + $this->getProperties()->fromImap($fetchData); return $this; } diff --git a/lib/Providers/MessagePart.php b/lib/Providers/MessagePart.php index eba49e6..3accb37 100644 --- a/lib/Providers/MessagePart.php +++ b/lib/Providers/MessagePart.php @@ -93,8 +93,10 @@ class MessagePart extends MessagePartMutableAbstract { } // Recursively process sub-parts + // When this part has no section ID (root multipart) children are + // numbered "1", "2", … to match IMAP section numbering. foreach ($part->parts as $index => $subPart) { - $subPartId = $partId . '.' . ($index + 1); + $subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1); $this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId); } } @@ -135,42 +137,49 @@ class MessagePart extends MessagePartMutableAbstract { } /** - * Inject decoded body content from a parallel gricob Mime Part tree. + * Inject decoded body content from a map of IMAP section-ID → raw encoded text. * - * Walks the gricob Mime Part tree alongside this MessagePart tree and - * sets 'content' on each leaf single-part node from its decoded body. + * Walks the MessagePart tree recursively. For each text/* leaf part whose + * partId is present in $sectionMap the raw text is decoded according to the + * part's Content-Transfer-Encoding and converted to UTF-8 before being + * stored in 'content'. Binary parts (images, PDFs, …) are skipped. * - * @param \Gricob\IMAP\Mime\Part\Part $mimePart Corresponding gricob Mime Part node + * @param array $sectionMap Keys: IMAP section IDs (e.g. "1", "1.2"); + * Values: raw (transfer-encoded) body text */ - public function injectBodyContent(\Gricob\IMAP\Mime\Part\Part $mimePart): void + public function injectSections(array $sectionMap): void { - if ($mimePart instanceof \Gricob\IMAP\Mime\Part\MultiPart) { - foreach ($mimePart->parts as $index => $childMimePart) { - $childPart = $this->parts[$index] ?? null; + // MultiPart: recurse into children + if (!empty($this->parts)) { + foreach ($this->parts as $childPart) { if ($childPart instanceof MessagePart) { - $childPart->injectBodyContent($childMimePart); + $childPart->injectSections($sectionMap); } } return; } - if ($mimePart instanceof \Gricob\IMAP\Mime\Part\SinglePart) { - // Only inject content for text/* parts; binary parts (images, PDFs, …) - // produce raw bytes that cannot be JSON-encoded as UTF-8 strings. - $type = strtolower($this->data['type'] ?? ''); - if (!str_starts_with($type, 'text/')) { - return; - } - try { - $decoded = $mimePart->decodedBody(); - } catch (\Throwable) { - return; - } - if ($decoded !== null && $decoded !== '') { - $charset = $mimePart->charset() ?? 'utf-8'; - $this->data['content'] = MessageProperties::toUtf8($decoded, $charset); - } + // SinglePart: only inject decoded content for text/* MIME types + $type = strtolower($this->data['type'] ?? ''); + if (!str_starts_with($type, 'text/')) { + return; } + + $partId = $this->data['partId'] ?? null; + if ($partId === null || !array_key_exists($partId, $sectionMap)) { + return; + } + + $raw = $sectionMap[$partId]; + $encoding = strtolower($this->data['encoding'] ?? '7bit'); + $decoded = match ($encoding) { + 'quoted-printable' => quoted_printable_decode($raw), + 'base64' => base64_decode($raw, strict: false), + default => $raw, // 7bit, 8bit, binary + }; + + $charset = $this->data['charset'] ?? 'us-ascii'; + $this->data['content'] = MessageProperties::toUtf8($decoded, $charset); } } diff --git a/lib/Providers/MessageProperties.php b/lib/Providers/MessageProperties.php index 226b177..594593e 100644 --- a/lib/Providers/MessageProperties.php +++ b/lib/Providers/MessageProperties.php @@ -11,12 +11,10 @@ namespace KTXM\ProviderImapMail\Providers; use DateTimeImmutable; use DateTimeInterface; -use Gricob\IMAP\Mime\Part\Part as MimePart; -use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart; -use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope; +use Gricob\IMAP\Protocol\Response\Line\Data\FetchData; use KTXF\Mail\Object\MessagePropertiesMutableAbstract; /** @@ -27,25 +25,16 @@ class MessageProperties extends MessagePropertiesMutableAbstract { /** * Convert IMAP data to mail message properties object * - * @param array $flags IMAP flags (e.g. ['\Seen', '\Flagged', ...]) - * @param ?Envelope $envelope parsed envelope from gricob - * @param ?BodyStructure $bodyStructure parsed body structure from gricob - * @param int $size RFC822.SIZE byte count + * @param FetchData $fetchData result from IMAP FETCH command */ - public function fromImap( - array $flags, - ?Envelope $envelope, - ?BodyStructure $bodyStructure, - int $size = 0, - ?MimePart $bodyPart = null, - ): static { + public function fromImap(FetchData $fetchData): static { // ── Size ────────────────────────────────────────────────────── - $this->data['size'] = $size; + $this->data['size'] = $fetchData->rfc822Size ?? 0; // ── Flags ───────────────────────────────────────────────────── $this->data['flags'] = []; - foreach ($flags as $flag) { + foreach ($fetchData->flags ?? [] as $flag) { $flag = ltrim($flag, '\\'); $normalized = match (strtolower($flag)) { 'seen' => 'read', @@ -59,7 +48,8 @@ class MessageProperties extends MessagePropertiesMutableAbstract { } // ── Envelope ────────────────────────────────────────────────── - if ($envelope !== null) { + if ($fetchData->envelope !== null) { + $envelope = $fetchData->envelope; if ($envelope->messageId !== null) { $this->data['urid'] = trim($envelope->messageId, '<>'); @@ -114,19 +104,28 @@ class MessageProperties extends MessagePropertiesMutableAbstract { } // ── Body Structure ──────────────────────────────────────────── - if ($bodyStructure !== null) { - $rootPart = (new MessagePart())->fromImap($bodyStructure->part, '1'); + if ($fetchData->bodyStructure !== null) { + $bodyStructure = $fetchData->bodyStructure; + // Root multipart containers have no fetchable section ID; their + // children are numbered "1", "2", … to match IMAP section IDs. + $isRootMultipart = $bodyStructure->part instanceof MultiPart; + $rootPartId = $isRootMultipart ? '' : '1'; + $rootPart = (new MessagePart())->fromImap($bodyStructure->part, $rootPartId); // ── Body Content: inject decoded content onto part nodes ────── - if ($bodyPart !== null) { - $rootPart->injectBodyContent($bodyPart); + if (!empty($fetchData->bodySections)) { + $sectionMap = []; + foreach ($fetchData->bodySections as $bs) { + $sectionMap[$bs->section] = $bs->text; + } + $rootPart->injectSections($sectionMap); } $this->data['body'] = $rootPart->toStore(); // Collect attachments: non-body parts with name or attachment disposition $attachments = []; - self::collectAttachments($bodyStructure->part, '1', $attachments); + self::collectAttachments($bodyStructure->part, $rootPartId, $attachments); if (!empty($attachments)) { $this->data['attachments'] = $attachments; } @@ -198,7 +197,8 @@ class MessageProperties extends MessagePropertiesMutableAbstract { } } elseif ($part instanceof MultiPart) { foreach ($part->parts as $index => $subPart) { - self::collectAttachments($subPart, $partId . '.' . ($index + 1), $attachments); + $subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1); + self::collectAttachments($subPart, $subPartId, $attachments); } } } diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php index f3d0010..498e994 100644 --- a/lib/Providers/Service.php +++ b/lib/Providers/Service.php @@ -445,11 +445,17 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $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); } @@ -457,6 +463,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC { $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; @@ -483,10 +496,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC }; } - /** - * Delta sync is not supported for IMAP (no CONDSTORE/QRESYNC initially). - * Returns an empty Delta so callers detect the absence of changes gracefully. - */ public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta { return new Delta(signature: $signature); diff --git a/lib/Service/Remote/Command/BodyCriteria.php b/lib/Service/Remote/Command/BodyCriteria.php deleted file mode 100644 index 4b747ec..0000000 --- a/lib/Service/Remote/Command/BodyCriteria.php +++ /dev/null @@ -1,25 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP BODY search criteria. - */ -final readonly class BodyCriteria implements Criteria -{ - public function __construct(private string $value) {} - - public function __toString(): string - { - return 'BODY "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; - } -} diff --git a/lib/Service/Remote/Command/CopyCommand.php b/lib/Service/Remote/Command/CopyCommand.php deleted file mode 100644 index f3d6853..0000000 --- a/lib/Service/Remote/Command/CopyCommand.php +++ /dev/null @@ -1,38 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Command; -use Gricob\IMAP\Protocol\Command\Argument\QuotedString; -use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; - -/** - * Raw UID COPY command. - * - * gricob does not expose message copying; this thin wrapper fills the gap. - * Accepts a set of UIDs formatted as a comma-separated sequence set. - * - * Example: UID COPY 1,3,7 "INBOX.Archive" - */ -final readonly class CopyCommand extends Command -{ - /** - * @param int[] $uids Source message UIDs - * @param string $destination Target mailbox name - */ - public function __construct(array $uids, string $destination) - { - parent::__construct( - 'UID COPY', - new SequenceSet(...$uids), - new QuotedString($destination), - ); - } -} diff --git a/lib/Service/Remote/Command/DeleteCommand.php b/lib/Service/Remote/Command/DeleteCommand.php deleted file mode 100644 index 66aa951..0000000 --- a/lib/Service/Remote/Command/DeleteCommand.php +++ /dev/null @@ -1,28 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Command; -use Gricob\IMAP\Protocol\Command\Argument\QuotedString; - -/** - * Raw IMAP DELETE command for a mailbox. - * - * gricob does not expose mailbox deletion; this thin wrapper fills the gap. - * - * Example: DELETE "INBOX.Trash" - */ -final readonly class DeleteCommand extends Command -{ - public function __construct(string $mailbox) - { - parent::__construct('DELETE', new QuotedString($mailbox)); - } -} diff --git a/lib/Service/Remote/Command/FlaggedCriteria.php b/lib/Service/Remote/Command/FlaggedCriteria.php deleted file mode 100644 index 40a6bc0..0000000 --- a/lib/Service/Remote/Command/FlaggedCriteria.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP FLAGGED search criteria. - */ -final readonly class FlaggedCriteria implements Criteria -{ - public function __toString(): string - { - return 'FLAGGED'; - } -} diff --git a/lib/Service/Remote/Command/FromCriteria.php b/lib/Service/Remote/Command/FromCriteria.php deleted file mode 100644 index 401b27e..0000000 --- a/lib/Service/Remote/Command/FromCriteria.php +++ /dev/null @@ -1,25 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP FROM search criteria. - */ -final readonly class FromCriteria implements Criteria -{ - public function __construct(private string $value) {} - - public function __toString(): string - { - return 'FROM "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; - } -} diff --git a/lib/Service/Remote/Command/LargerCriteria.php b/lib/Service/Remote/Command/LargerCriteria.php deleted file mode 100644 index 5d5f1f6..0000000 --- a/lib/Service/Remote/Command/LargerCriteria.php +++ /dev/null @@ -1,25 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP LARGER search criteria (messages larger than n octets). - */ -final readonly class LargerCriteria implements Criteria -{ - public function __construct(private int $size) {} - - public function __toString(): string - { - return 'LARGER ' . $this->size; - } -} diff --git a/lib/Service/Remote/Command/RenameCommand.php b/lib/Service/Remote/Command/RenameCommand.php deleted file mode 100644 index a0ec31b..0000000 --- a/lib/Service/Remote/Command/RenameCommand.php +++ /dev/null @@ -1,28 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Command; -use Gricob\IMAP\Protocol\Command\Argument\QuotedString; - -/** - * Raw IMAP RENAME command. - * - * gricob does not expose mailbox renaming; this thin wrapper fills the gap. - * - * Example: RENAME "OldName" "NewName" - */ -final readonly class RenameCommand extends Command -{ - public function __construct(string $oldName, string $newName) - { - parent::__construct('RENAME', new QuotedString($oldName), new QuotedString($newName)); - } -} diff --git a/lib/Service/Remote/Command/SeenCriteria.php b/lib/Service/Remote/Command/SeenCriteria.php deleted file mode 100644 index 5760b29..0000000 --- a/lib/Service/Remote/Command/SeenCriteria.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP SEEN search criteria. - */ -final readonly class SeenCriteria implements Criteria -{ - public function __toString(): string - { - return 'SEEN'; - } -} diff --git a/lib/Service/Remote/Command/SmallerCriteria.php b/lib/Service/Remote/Command/SmallerCriteria.php deleted file mode 100644 index 396cd50..0000000 --- a/lib/Service/Remote/Command/SmallerCriteria.php +++ /dev/null @@ -1,25 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP SMALLER search criteria (messages smaller than n octets). - */ -final readonly class SmallerCriteria implements Criteria -{ - public function __construct(private int $size) {} - - public function __toString(): string - { - return 'SMALLER ' . $this->size; - } -} diff --git a/lib/Service/Remote/Command/StartTlsCommand.php b/lib/Service/Remote/Command/StartTlsCommand.php deleted file mode 100644 index 4706936..0000000 --- a/lib/Service/Remote/Command/StartTlsCommand.php +++ /dev/null @@ -1,27 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Command; - -/** - * STARTTLS command (RFC 3501 §6.2.1). - * - * Instructs the server to begin TLS negotiation on the current connection. - * After the server responds OK, the client must call upgradeTls() on the - * underlying SocketConnection to complete the handshake. - */ -final readonly class StartTlsCommand extends Command -{ - public function __construct() - { - parent::__construct('STARTTLS'); - } -} diff --git a/lib/Service/Remote/Command/StoreCommand.php b/lib/Service/Remote/Command/StoreCommand.php deleted file mode 100644 index a569997..0000000 --- a/lib/Service/Remote/Command/StoreCommand.php +++ /dev/null @@ -1,39 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Command; -use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; -use Gricob\IMAP\Protocol\Command\Argument\Store\Flags; - -/** - * Bulk UID STORE command for flag mutations. - * - * A thin ergonomic wrapper around gricob's FetchCommand that accepts an array - * of UIDs and a pre-built Flags argument so callers don't have to construct - * SequenceSet directly. - * - * Example: UID STORE 1,3,7 +FLAGS.SILENT (\Seen) - */ -final readonly class StoreCommand extends Command -{ - /** - * @param int[] $uids UIDs to operate on - * @param Flags $flags e.g. new Flags(['\Seen'], '+') - */ - public function __construct(array $uids, Flags $flags) - { - parent::__construct( - 'UID STORE', - new SequenceSet(...$uids), - $flags, - ); - } -} diff --git a/lib/Service/Remote/Command/StreamFetchCommand.php b/lib/Service/Remote/Command/StreamFetchCommand.php deleted file mode 100644 index e466598..0000000 --- a/lib/Service/Remote/Command/StreamFetchCommand.php +++ /dev/null @@ -1,39 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Command; -use Gricob\IMAP\Protocol\Command\Argument\ParenthesizedList; -use Gricob\IMAP\Protocol\Command\Argument\SequenceSet; - -/** - * Streaming single-message fetch command. - * - * Wraps gricob's UID FETCH for one or more UIDs with a configurable item list. - * Used inside ImapClientWrapper::streamMessages() (one UID per call) and - * ImapClientWrapper::fetchMessages() (variadic UIDs for bulk prefetch). - * - * Example: UID FETCH 42 (FLAGS ENVELOPE INTERNALDATE BODYSTRUCTURE BODY[]) - */ -final readonly class StreamFetchCommand extends Command -{ - /** - * @param int[] $uids One or more UIDs; formatted as "1,3,7" by SequenceSet - * @param string[] $items IMAP fetch data items (e.g. 'FLAGS', 'ENVELOPE', 'BODY[]') - */ - public function __construct(array $uids, array $items) - { - parent::__construct( - 'UID FETCH', - new SequenceSet(...$uids), - new ParenthesizedList($items), - ); - } -} diff --git a/lib/Service/Remote/Command/SubjectCriteria.php b/lib/Service/Remote/Command/SubjectCriteria.php deleted file mode 100644 index fa818c5..0000000 --- a/lib/Service/Remote/Command/SubjectCriteria.php +++ /dev/null @@ -1,25 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP SUBJECT search criteria. - */ -final readonly class SubjectCriteria implements Criteria -{ - public function __construct(private string $value) {} - - public function __toString(): string - { - return 'SUBJECT "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; - } -} diff --git a/lib/Service/Remote/Command/ToCriteria.php b/lib/Service/Remote/Command/ToCriteria.php deleted file mode 100644 index 79ff386..0000000 --- a/lib/Service/Remote/Command/ToCriteria.php +++ /dev/null @@ -1,25 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP TO search criteria. - */ -final readonly class ToCriteria implements Criteria -{ - public function __construct(private string $value) {} - - public function __toString(): string - { - return 'TO "' . str_replace(['"', '\\'], ['\\"', '\\\\'], $this->value) . '"'; - } -} diff --git a/lib/Service/Remote/Command/UnflaggedCriteria.php b/lib/Service/Remote/Command/UnflaggedCriteria.php deleted file mode 100644 index 1ffdf7b..0000000 --- a/lib/Service/Remote/Command/UnflaggedCriteria.php +++ /dev/null @@ -1,23 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP UNFLAGGED search criteria. - */ -final readonly class UnflaggedCriteria implements Criteria -{ - public function __toString(): string - { - return 'UNFLAGGED'; - } -} diff --git a/lib/Service/Remote/Command/UnseenCriteria.php b/lib/Service/Remote/Command/UnseenCriteria.php deleted file mode 100644 index 4bde5ad..0000000 --- a/lib/Service/Remote/Command/UnseenCriteria.php +++ /dev/null @@ -1,26 +0,0 @@ - - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -namespace KTXM\ProviderImapMail\Service\Remote\Command; - -use Gricob\IMAP\Protocol\Command\Argument\Search\Criteria; - -/** - * IMAP UNSEEN search criteria. - * - * gricob does not include an UNSEEN criteria; this thin class fills the gap. - * Used with SearchCommand to count unread messages via SEARCH UNSEEN. - */ -final readonly class UnseenCriteria implements Criteria -{ - public function __toString(): string - { - return 'UNSEEN'; - } -} diff --git a/lib/Service/Remote/ImapClientWrapper.php b/lib/Service/Remote/ImapClientWrapper.php deleted file mode 100644 index 6df87de..0000000 --- a/lib/Service/Remote/ImapClientWrapper.php +++ /dev/null @@ -1,470 +0,0 @@ - - * 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); - } -} diff --git a/lib/Service/Remote/RemoteMailService.php b/lib/Service/Remote/RemoteMailService.php index 4b11481..2b6d25b 100644 --- a/lib/Service/Remote/RemoteMailService.php +++ b/lib/Service/Remote/RemoteMailService.php @@ -11,40 +11,33 @@ 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; -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. + * Default IMAP FETCH data items used for message hydration */ private const DEFAULT_FETCH_ITEMS = [ 'FLAGS', @@ -57,13 +50,11 @@ class RemoteMailService ]; public function __construct( - private readonly ImapClientWrapper $client, + private readonly Client $client, private readonly string $provider, private readonly string|int $service, ) {} - // ── Collection (mailbox) operations ────────────────────────────────────── - /** * List all selectable mailboxes on the server. * @@ -165,23 +156,22 @@ class RemoteMailService */ 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), + '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 LargerCriteria($value), - 'max' => new SmallerCriteria($value), + 'min' => new Larger($value), + 'max' => new Smaller($value), default => null, }; if ($criterion !== null) { @@ -190,19 +180,14 @@ class RemoteMailService } } - // ── Execute IMAP SEARCH (ALL when no criteria) ──────────────────────── - $uids = empty($criteria) - ? $this->client->searchAll($collection) - : $this->client->searchMessages($collection, $criteria); + $uids = $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(); @@ -223,11 +208,6 @@ class RemoteMailService /** * 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 */ @@ -237,10 +217,11 @@ class RemoteMailService return []; } + $this->client->select($collection); $result = []; - foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { + 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, null); + $resource->fromImap($fetchData, $collection); $result[$uid] = $resource; } return $result; @@ -252,9 +233,50 @@ class RemoteMailService return; } - foreach ($this->client->fetchMultiple($collection, array_values($uids), self::DEFAULT_FETCH_ITEMS) as $uid => $fetchData) { + $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, null); + $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; } } @@ -272,11 +294,12 @@ class RemoteMailService * @param string[] $items IMAP fetch data items * @return \Generator */ - public function entitySyncStream(string $collection, array $uids, array $items = self::DEFAULT_FETCH_ITEMS): \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) { + $this->client->select($collection); + foreach ($this->client->streamByUids($uids, $items) as $uid => $fetchData) { $resource = new EntityResource($this->provider, $this->service); - $resource->fromImap($fetchData, $collection, null); + $resource->fromImap($fetchData, $collection); yield $uid => $resource; } } @@ -288,7 +311,7 @@ class RemoteMailService */ public function entityCreate(string $collection, string $rawMessage, array $flags = []): int { - return $this->client->append($rawMessage, $collection, $flags); + return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null); } /** @@ -331,45 +354,4 @@ class RemoteMailService $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); - } } diff --git a/lib/Service/Remote/RemoteService.php b/lib/Service/Remote/RemoteService.php index bcf105a..c4085f1 100644 --- a/lib/Service/Remote/RemoteService.php +++ b/lib/Service/Remote/RemoteService.php @@ -10,24 +10,25 @@ declare(strict_types=1); namespace KTXM\ProviderImapMail\Service\Remote; use Gricob\IMAP\Client; +use KTXC\Server; use KTXC\Logger\PlainFileLogger; use KTXM\ProviderImapMail\Providers\Service; /** * Static factory for IMAP remote service objects. * - * - freshClient() → builds a imap client from service config - * - mailService() → constructs a RemoteMailService from the wrapper + * - freshClient() → builds a gricob Client from service config + * - mailService() → constructs a RemoteMailService from the client */ class RemoteService { /** - * Build a fully-configured imap client from a Service's location and identity. + * Build a fully-configured IMAP client from a Service's location and identity. * * Handles STARTTLS: connects on plain TCP, sends STARTTLS, upgrades to TLS, - * then authenticates — all before returning the wrapper. + * then authenticates — all before returning the client. */ - public static function freshClient(Service $service, string $logDir): ImapClientWrapper + public static function freshClient(Service $service): Client { $location = $service->getLocation(); $identity = $service->getIdentity(); @@ -35,6 +36,7 @@ class RemoteService // Build a file logger when debug mode is enabled, otherwise pass null $logger = null; if ($service->getDebug()) { + $logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log'; $logger = new PlainFileLogger($logDir . '/imap', $service->identifier()); } @@ -47,17 +49,17 @@ class RemoteService $client->logIn($identity->getIdentity(), $identity->getSecret()); - return new ImapClientWrapper($client); + return $client; } /** - * Build a RemoteMailService from a Service and a pre-authenticated wrapper. + * Build a RemoteMailService from a Service and a pre-authenticated client. * * The provider identifier and service ID are taken directly from the Service * object so the caller does not have to repeat them. */ - public static function mailService(Service $service, ImapClientWrapper $wrapper): RemoteMailService + public static function mailService(Service $service, Client $client): RemoteMailService { - return new RemoteMailService($wrapper, $service->provider(), $service->identifier()); + return new RemoteMailService($client, $service->provider(), $service->identifier()); } }