> */ final class ListCommand implements CommandInterface { private readonly ListSelectionOptions $selectionOptions; private readonly ListReturnOptions $returnOptions; private readonly StatusResponseParser $statusResponseParser; public function __construct( private readonly string $reference = '', private readonly string $pattern = '*', ?ListSelectionOptions $selectionOptions = null, ?ListReturnOptions $returnOptions = null, ) { $this->selectionOptions = $selectionOptions ?? ListSelectionOptions::none(); $this->returnOptions = $returnOptions ?? ListReturnOptions::none(); $this->statusResponseParser = new StatusResponseParser(); } public function name(): string { return 'LIST'; } public function allowedStates(): array { return [ SessionState::Authenticated, SessionState::Selected, ]; } public function encode(string $tag, SessionContext $context): RequestFrame { unset($tag, $context); $command = 'LIST'; $selectionOptions = $this->selectionOptions->toCommand(); if ($selectionOptions !== null) { $command .= ' ' . $selectionOptions; } $command .= sprintf( ' %s %s', $this->quote($this->reference), $this->quote($this->pattern), ); $returnOptions = $this->returnOptions->toCommand(); if ($returnOptions !== null) { $command .= ' RETURN ' . $returnOptions; } return new RequestFrame($command); } public function handle(ResponseStream $responses, SessionContext $context): Generator { unset($context); if (!$this->returnOptions->hasStatus()) { foreach ($responses as $response) { if ($response instanceof UntaggedResponse && $response->label() === 'LIST') { yield $this->parseMailbox($response->payload()); continue; } if ($response instanceof TaggedResponse) { if (!$response->isOk()) { throw new ImapException('LIST failed: ' . $response->text()); } return; } } throw new ImapException('LIST did not receive a tagged completion response.'); } $mailboxes = []; $statuses = []; foreach ($responses as $response) { if ($response instanceof UntaggedResponse && $response->label() === 'LIST') { $mailbox = $this->parseMailbox($response->payload()); $mailboxes[$mailbox->name()] = $this->applyStatus( $mailbox, $statuses[$mailbox->name()] ?? [], ); continue; } if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') { [$mailboxName, $status] = $this->statusResponseParser->parse($response->payload()); $statuses[$mailboxName] = $status; if (isset($mailboxes[$mailboxName])) { $mailboxes[$mailboxName] = $this->applyStatus($mailboxes[$mailboxName], $status); } continue; } if ($response instanceof TaggedResponse) { if (!$response->isOk()) { throw new ImapException('LIST failed: ' . $response->text()); } foreach ($mailboxes as $mailbox) { yield $mailbox; } return; } } throw new ImapException('LIST did not receive a tagged completion response.'); } private function parseMailbox(string $payload): Mailbox { $payload = trim($payload); $offset = 0; $attributesToken = $this->readToken($payload, $offset); $delimiterToken = $this->readToken($payload, $offset); $nameToken = $this->readToken($payload, $offset); if ($attributesToken === null || $delimiterToken === null || $nameToken === null) { throw new ImapException('Unable to parse LIST response payload: ' . $payload); } $attributeString = trim($attributesToken, '() '); $attributes = $attributeString === '' || strtoupper($attributeString) === 'NIL' ? [] : array_map('strtoupper', preg_split('/\s+/', $attributeString) ?: []); $delimiter = $this->decodeAtom($delimiterToken); $name = $this->decodeMailboxName($nameToken); return new Mailbox($name, $delimiter, $attributes); } /** * @param array $status */ private function applyStatus(Mailbox $mailbox, array $status): Mailbox { return new Mailbox( $mailbox->name(), $mailbox->delimiter(), $mailbox->attributes(), $status['MESSAGES'] ?? $mailbox->messages(), $status['UNSEEN'] ?? $mailbox->unread(), $mailbox->state(), $mailbox->recent(), $mailbox->flags(), $mailbox->readOnly(), ); } private function readToken(string $payload, int &$offset): ?string { $length = strlen($payload); while ($offset < $length && ctype_space($payload[$offset])) { $offset++; } if ($offset >= $length) { return null; } if ($payload[$offset] === '(') { $end = strpos($payload, ')', $offset); if ($end === false) { throw new ImapException('Unterminated LIST attribute block: ' . $payload); } $token = substr($payload, $offset, $end - $offset + 1); $offset = $end + 1; return $token; } if ($payload[$offset] === '"') { $start = $offset; $offset++; while ($offset < $length) { if ($payload[$offset] === '\\') { $offset += 2; continue; } if ($payload[$offset] === '"') { $offset++; return substr($payload, $start, $offset - $start); } $offset++; } throw new ImapException('Unterminated quoted LIST token: ' . $payload); } $start = $offset; while ($offset < $length && !ctype_space($payload[$offset])) { $offset++; } return substr($payload, $start, $offset - $start); } private function decodeAtom(string $value): ?string { $value = trim($value); if (strtoupper($value) === 'NIL') { return null; } if (str_starts_with($value, '"') && str_ends_with($value, '"')) { return stripcslashes(substr($value, 1, -1)); } return $value; } private function decodeMailboxName(string $value): string { $name = $this->decodeAtom($value); // LIST may advertise the root mailbox as an empty quoted string. return $name ?? ''; } private function quote(string $value): string { return '"' . addcslashes($value, "\\\"") . '"'; } }