Files
provider_imap/lib/Client/Command/MessageTransferCommand.php
2026-05-08 00:16:43 -04:00

243 lines
7.6 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Command;
use KTXM\ProviderImap\Client\Command\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\SequenceSet;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
/**
* @implements CommandInterface<MessageTransferResult>
*/
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<array{source:string, name:string, arguments:list<string>, text:string}> $responseCodes
* @param ?array{uidValidity:string, sourceUids:string, destinationUids:string} $copyUid
* @param list<int> $expunged
* @param list<array{earlier:bool, knownUids:string}> $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<array{source:string, name:string, arguments:list<string>, 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<string>, 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, "\\\"") . '"';
}
}