*/ final class MessageTransferCommand implements CommandInterface { private readonly string $operation; private readonly SequenceSet $sequenceSet; private readonly IdentifierMode $identifierMode; public function __construct( string $operation, FetchTarget|string|SequenceSet|null $target = null, private readonly string $destinationMailbox = '', ) { $resolvedTarget = match (true) { $target instanceof FetchTarget => $target, $target instanceof SequenceSet => FetchTarget::sequence($target), is_string($target) => FetchTarget::sequence($target), default => FetchTarget::all(), }; $this->operation = strtoupper(trim($operation)); if (!in_array($this->operation, ['COPY', 'MOVE'], true)) { throw new ImapException('Unsupported transfer operation: ' . $this->operation); } $this->sequenceSet = $resolvedTarget->sequenceSet(); $this->identifierMode = $resolvedTarget->identifierMode(); } public function name(): string { return $this->operation; } public function allowedStates(): array { return [SessionState::Selected]; } public function encode(string $tag, SessionContext $context): RequestFrame { unset($tag, $context); return new RequestFrame(sprintf( '%s%s %s %s', $this->identifierMode === IdentifierMode::Uid ? 'UID ' : '', $this->operation, $this->sequenceSet->toCommand(), $this->quote($this->destinationMailbox), )); } public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult { if ($context->selectedMailbox() === null) { throw new ImapException($this->operation . ' requires a selected mailbox.'); } $responseCodes = []; $copyUid = null; $tryCreate = false; $highestModSeq = null; $expunged = []; $vanished = []; foreach ($responses as $response) { if ($response instanceof UntaggedResponse) { $this->collectUntaggedData( $response, $responseCodes, $copyUid, $tryCreate, $highestModSeq, $expunged, $vanished, ); continue; } if ($response instanceof TaggedResponse) { $this->collectResponseCode( 'tagged', $response->text(), $responseCodes, $copyUid, $tryCreate, $highestModSeq, ); $result = new MessageTransferResult( $response->status(), $response->text(), $responseCodes, $copyUid, $tryCreate, $highestModSeq, $expunged, $vanished, ); if (!$response->isOk()) { throw new ImapException($this->operation . ' failed: ' . $response->text()); } return $result; } } throw new ImapException($this->operation . ' did not receive a tagged completion response.'); } /** * @param list, text:string}> $responseCodes * @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid * @param list $expunged * @param list $vanished */ private function collectUntaggedData( UntaggedResponse $response, array &$responseCodes, ?array &$copyUid, bool &$tryCreate, ?string &$highestModSeq, array &$expunged, array &$vanished, ): void { $label = strtoupper($response->label()); if (in_array($label, ['OK', 'NO', 'BAD', 'BYE', 'PREAUTH'], true)) { $this->collectResponseCode( 'untagged', $response->payload(), $responseCodes, $copyUid, $tryCreate, $highestModSeq, ); } if (preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) { $expunged[] = (int) $matches[1]; return; } if (preg_match('/^\*\s+VANISHED(?:\s+\((EARLIER)\))?\s+(.+)$/i', $response->raw(), $matches) === 1) { $vanished[] = [ 'earlier' => isset($matches[1]) && strtoupper($matches[1]) === 'EARLIER', 'knownUids' => trim($matches[2]), ]; } } /** * @param list, text:string}> $responseCodes * @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid */ private function collectResponseCode( string $source, string $text, array &$responseCodes, ?array &$copyUid, bool &$tryCreate, ?string &$highestModSeq, ): void { $responseCode = $this->parseResponseCode($text); if ($responseCode === null) { return; } $responseCodes[] = [ 'source' => $source, 'name' => $responseCode['name'], 'arguments' => $responseCode['arguments'], 'text' => $responseCode['text'], ]; if ($responseCode['name'] === 'TRYCREATE') { $tryCreate = true; return; } if ($responseCode['name'] === 'HIGHESTMODSEQ' && isset($responseCode['arguments'][0])) { $highestModSeq = $responseCode['arguments'][0]; return; } if ($responseCode['name'] !== 'COPYUID' || count($responseCode['arguments']) < 3) { return; } $copyUid = [ 'uidValidity' => $responseCode['arguments'][0], 'sourceUids' => $responseCode['arguments'][1], 'destinationUids' => $responseCode['arguments'][2], ]; } /** * @return ?array{name:string, arguments:list, text:string} */ private function parseResponseCode(string $text): ?array { $text = trim($text); if (preg_match('/^\[([A-Z0-9.-]+)(?:\s+([^\]]+))?\](?:\s*(.*))?$/i', $text, $matches) !== 1) { return null; } $arguments = trim($matches[2] ?? ''); return [ 'name' => strtoupper($matches[1]), 'arguments' => $arguments === '' ? [] : (preg_split('/\s+/', $arguments) ?: []), 'text' => trim($matches[3] ?? ''), ]; } private function quote(string $value): string { return '"' . addcslashes($value, "\\\"") . '"'; } }