Files
provider_imap/lib/Client/Protocol/CommandExecutor.php
2026-05-23 20:18:58 -04:00

120 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol;
use Generator;
use KTXM\ProviderImap\Client\Command\CommandInterface;
use KTXM\ProviderImap\Client\FetchTarget;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\SessionContext;
use KTXM\ProviderImap\Client\SessionState;
use Psr\Log\LoggerInterface;
final class CommandExecutor
{
public function __construct(
private readonly ProtocolReader $reader,
private readonly ProtocolWriter $writer,
private readonly TagGenerator $tags = new TagGenerator(),
private readonly ?LoggerInterface $logger = null,
) {}
/**
* @template TResult
* @param CommandInterface<TResult> $command
* @return TResult
*/
public function perform(CommandInterface $command, SessionContext $context): mixed
{
$this->assertState($command->allowedStates(), $context->state(), $command->name());
$this->logger?->debug('IMAP command execution started: {command} (state={state})', [
'command' => $command->name(),
'state' => $context->state()->value,
]);
$tag = $this->tags->next();
$frame = $command->encode($tag, $context);
$this->writer->write($tag, $frame);
return $command->handle(new ResponseStream(function () use ($tag, $context): Generator {
yield from $this->processPerform($tag, $context);
}), $context);
}
/**
* Stream the raw bytes of a single IMAP BODY section without buffering.
*
* Sends a UID FETCH for the given section and yields the literal bytes
* directly from the socket in chunks, never assembling a full string.
* The caller MUST fully exhaust the returned Generator before issuing
* any further IMAP commands.
*
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
*/
public function download(FetchTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator
{
$this->assertState([SessionState::Selected], $context->state(), 'FETCH (download)');
$tag = $this->tags->next();
$this->writer->write($tag, new RequestFrame(sprintf(
'UID FETCH %s (UID BODY[%s])',
$target->sequenceSet()->toCommand(),
$section,
)));
$result = $this->reader->readUntilFetchLiteral($tag);
if ($result === null) {
return; // UID not found or empty FETCH result
}
yield from $this->reader->streamLiteral($result['literalLength'], $chunkSize);
$this->reader->readToEnd($tag);
}
/**
* @param list<SessionState> $allowedStates
*/
private function assertState(array $allowedStates, SessionState $currentState, string $commandName): void
{
foreach ($allowedStates as $allowedState) {
if ($allowedState === $currentState) {
return;
}
}
throw new ImapException(sprintf(
'Command %s is not allowed while session is in state %s.',
$commandName,
$currentState->value,
));
}
private function processPerform(string $tag, SessionContext $context): Generator
{
while (true) {
$response = $this->reader->readResponse();
if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') {
$context->replaceCapabilities(...$response->payloadTokens());
}
yield $response;
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
$this->logger?->debug('IMAP command execution completed: tag={tag} status={status}', [
'tag' => $tag,
'status' => $response->status(),
]);
return;
}
}
}
}