generated from Nodarx/template
refactor: use custom imap client
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
62
lib/Client/Command/CapabilityCommand.php
Normal file
62
lib/Client/Command/CapabilityCommand.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CapabilityResult;
|
||||
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\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CapabilityResult>
|
||||
*/
|
||||
final class CapabilityCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'CAPABILITY';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::NotAuthenticated,
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('CAPABILITY');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CapabilityResult
|
||||
{
|
||||
$capabilities = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'CAPABILITY') {
|
||||
$capabilities = array_map('strtoupper', $response->payloadTokens());
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('CAPABILITY failed: ' . $response->text());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$context->replaceCapabilities(...$capabilities);
|
||||
|
||||
return new CapabilityResult($context->capabilities());
|
||||
}
|
||||
}
|
||||
30
lib/Client/Command/CommandInterface.php
Normal file
30
lib/Client/Command/CommandInterface.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @template TResult
|
||||
*/
|
||||
interface CommandInterface
|
||||
{
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* @return list<SessionState>
|
||||
*/
|
||||
public function allowedStates(): array;
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame;
|
||||
|
||||
/**
|
||||
* @return TResult
|
||||
*/
|
||||
public function handle(ResponseStream $responses, SessionContext $context): mixed;
|
||||
}
|
||||
47
lib/Client/Command/CopyCommand.php
Normal file
47
lib/Client/Command/CopyCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<MessageTransferResult>
|
||||
*/
|
||||
final class CopyCommand implements CommandInterface
|
||||
{
|
||||
private readonly MessageTransferCommand $command;
|
||||
|
||||
public function __construct(
|
||||
FetchTarget|string|SequenceSet|null $target = null,
|
||||
string $destinationMailbox = '',
|
||||
) {
|
||||
$this->command = new MessageTransferCommand('COPY', $target, $destinationMailbox);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->command->name();
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return $this->command->allowedStates();
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
return $this->command->encode($tag, $context);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
|
||||
{
|
||||
return $this->command->handle($responses, $context);
|
||||
}
|
||||
}
|
||||
65
lib/Client/Command/CreateCommand.php
Normal file
65
lib/Client/Command/CreateCommand.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class CreateCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'CREATE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf('CREATE %s', $this->quote($this->mailbox)));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
unset($context);
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('CREATE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('CREATE did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
68
lib/Client/Command/DeleteCommand.php
Normal file
68
lib/Client/Command/DeleteCommand.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class DeleteCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'DELETE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf('DELETE %s', $this->quote($this->mailbox)));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
if ($context->selectedMailbox() === $this->mailbox) {
|
||||
$context->setSelectedMailbox(null);
|
||||
$context->setState(SessionState::Authenticated);
|
||||
}
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('DELETE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('DELETE did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
106
lib/Client/Command/ExpungeCommand.php
Normal file
106
lib/Client/Command/ExpungeCommand.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
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<list<int>>
|
||||
*/
|
||||
final class ExpungeCommand implements CommandInterface
|
||||
{
|
||||
private readonly ?SequenceSet $sequenceSet;
|
||||
|
||||
public function __construct(FetchTarget|string|SequenceSet|null $target = null)
|
||||
{
|
||||
if ($target === null) {
|
||||
$this->sequenceSet = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedTarget = match (true) {
|
||||
$target instanceof FetchTarget => $target,
|
||||
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||
is_string($target) => FetchTarget::sequence($target),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($resolvedTarget === null || $resolvedTarget->identifierMode() !== IdentifierMode::Uid) {
|
||||
throw new ImapException('Targeted EXPUNGE requires a UID target.');
|
||||
}
|
||||
|
||||
$this->sequenceSet = $resolvedTarget->sequenceSet();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'EXPUNGE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag);
|
||||
|
||||
if ($this->sequenceSet === null) {
|
||||
unset($context);
|
||||
|
||||
return new RequestFrame('EXPUNGE');
|
||||
}
|
||||
|
||||
if (!$context->hasCapability('UIDPLUS')) {
|
||||
throw new ImapException('UID EXPUNGE requires the IMAP UIDPLUS capability.');
|
||||
}
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'UID EXPUNGE %s',
|
||||
$this->sequenceSet->toCommand(),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): array
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('EXPUNGE requires a selected mailbox.');
|
||||
}
|
||||
|
||||
$expunged = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && preg_match('/^\*\s+(\d+)\s+EXPUNGE$/i', $response->raw(), $matches) === 1) {
|
||||
$expunged[] = (int) $matches[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException($this->sequenceSet === null
|
||||
? 'EXPUNGE failed: ' . $response->text()
|
||||
: 'UID EXPUNGE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return $expunged;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException($this->sequenceSet === null
|
||||
? 'EXPUNGE did not receive a tagged completion response.'
|
||||
: 'UID EXPUNGE did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
67
lib/Client/Command/FetchManyCommand.php
Normal file
67
lib/Client/Command/FetchManyCommand.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\FetchOptions;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Generator<int, Message>>
|
||||
*/
|
||||
final class FetchManyCommand implements CommandInterface
|
||||
{
|
||||
private readonly FetchTarget $target;
|
||||
private readonly FetchOptions $options;
|
||||
|
||||
public function __construct(FetchTarget|string|SequenceSet|null $target = null, ?FetchOptions $options = null)
|
||||
{
|
||||
$this->target = match (true) {
|
||||
$target instanceof FetchTarget => $target,
|
||||
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||
is_string($target) => FetchTarget::sequence($target),
|
||||
default => FetchTarget::all(),
|
||||
};
|
||||
$this->options = $options ?? FetchOptions::default();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'FETCH';
|
||||
}
|
||||
|
||||
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)',
|
||||
$this->target->toCommand(),
|
||||
$this->target->sequenceSet()->toCommand(),
|
||||
$this->options->toCommand(),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): Generator
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('FETCH requires a selected mailbox.');
|
||||
}
|
||||
|
||||
return (new FetchResponseParser())->parseMany($responses);
|
||||
}
|
||||
}
|
||||
62
lib/Client/Command/FetchOneCommand.php
Normal file
62
lib/Client/Command/FetchOneCommand.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\FetchOptions;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Message>
|
||||
*/
|
||||
final class FetchOneCommand implements CommandInterface
|
||||
{
|
||||
private readonly FetchTarget $target;
|
||||
private readonly FetchOptions $options;
|
||||
|
||||
public function __construct(FetchTarget|int|string $target, ?FetchOptions $options = null)
|
||||
{
|
||||
$this->target = $target instanceof FetchTarget
|
||||
? $target
|
||||
: FetchTarget::sequence($target);
|
||||
$this->options = $options ?? FetchOptions::default();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'FETCH';
|
||||
}
|
||||
|
||||
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)',
|
||||
$this->target->toCommand(),
|
||||
$this->target->sequenceSet()->toCommand(),
|
||||
$this->options->toCommand(),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): Message
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('FETCH requires a selected mailbox.');
|
||||
}
|
||||
|
||||
return (new FetchResponseParser())->parseOne($responses);
|
||||
}
|
||||
}
|
||||
58
lib/Client/Command/FetchResponseParser.php
Normal file
58
lib/Client/Command/FetchResponseParser.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\MessageParser;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
|
||||
final class FetchResponseParser
|
||||
{
|
||||
public function parseOne(ResponseStream $responses): Message
|
||||
{
|
||||
$message = null;
|
||||
|
||||
foreach ($this->parseMany($responses) as $summary) {
|
||||
if ($message !== null) {
|
||||
throw new ImapException('FETCH returned multiple messages for a single-message request.');
|
||||
}
|
||||
|
||||
$message = $summary;
|
||||
}
|
||||
|
||||
if ($message === null) {
|
||||
throw new ImapException('FETCH did not return a message summary.');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Generator<int, Message>
|
||||
*/
|
||||
public function parseMany(ResponseStream $responses): Generator
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && MessageParser::isFetchMessage($response->payload())) {
|
||||
yield MessageParser::parse($response->raw());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('FETCH failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('FETCH did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
263
lib/Client/Command/ListCommand.php
Normal file
263
lib/Client/Command/ListCommand.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\ListReturnOptions;
|
||||
use KTXM\ProviderImap\Client\ListSelectionOptions;
|
||||
use KTXM\ProviderImap\Client\Mailbox;
|
||||
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\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Generator<int, Mailbox>>
|
||||
*/
|
||||
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<string, int> $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, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
67
lib/Client/Command/LoginCommand.php
Normal file
67
lib/Client/Command/LoginCommand.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class LoginCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $username,
|
||||
private readonly string $password,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'LOGIN';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::NotAuthenticated];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'LOGIN %s %s',
|
||||
$this->quote($this->username),
|
||||
$this->quote($this->password),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('LOGIN failed: ' . $response->text());
|
||||
}
|
||||
|
||||
$context->setState(SessionState::Authenticated);
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('LOGIN did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
59
lib/Client/Command/LogoutCommand.php
Normal file
59
lib/Client/Command/LogoutCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class LogoutCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'LOGOUT';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::NotAuthenticated,
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('LOGOUT');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('LOGOUT failed: ' . $response->text());
|
||||
}
|
||||
|
||||
$context->setSelectedMailbox(null);
|
||||
$context->setState(SessionState::Logout);
|
||||
$context->connection()->disconnect();
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('LOGOUT did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
243
lib/Client/Command/MessageTransferCommand.php
Normal file
243
lib/Client/Command/MessageTransferCommand.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?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, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
47
lib/Client/Command/MoveCommand.php
Normal file
47
lib/Client/Command/MoveCommand.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<MessageTransferResult>
|
||||
*/
|
||||
final class MoveCommand implements CommandInterface
|
||||
{
|
||||
private readonly MessageTransferCommand $command;
|
||||
|
||||
public function __construct(
|
||||
FetchTarget|string|SequenceSet|null $target = null,
|
||||
string $destinationMailbox = '',
|
||||
) {
|
||||
$this->command = new MessageTransferCommand('MOVE', $target, $destinationMailbox);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->command->name();
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return $this->command->allowedStates();
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
return $this->command->encode($tag, $context);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): MessageTransferResult
|
||||
{
|
||||
return $this->command->handle($responses, $context);
|
||||
}
|
||||
}
|
||||
57
lib/Client/Command/NoopCommand.php
Normal file
57
lib/Client/Command/NoopCommand.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class NoopCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'NOOP';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::NotAuthenticated,
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('NOOP');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
unset($context);
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('NOOP failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('NOOP did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
72
lib/Client/Command/RenameCommand.php
Normal file
72
lib/Client/Command/RenameCommand.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class RenameCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $fromMailbox,
|
||||
private readonly string $toMailbox,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'RENAME';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'RENAME %s %s',
|
||||
$this->quote($this->fromMailbox),
|
||||
$this->quote($this->toMailbox),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('RENAME failed: ' . $response->text());
|
||||
}
|
||||
|
||||
if ($context->selectedMailbox() === $this->fromMailbox) {
|
||||
$context->setSelectedMailbox($this->toMailbox);
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('RENAME did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
28
lib/Client/Command/Result/CapabilityResult.php
Normal file
28
lib/Client/Command/Result/CapabilityResult.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class CapabilityResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $capabilities
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $capabilities,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function capabilities(): array
|
||||
{
|
||||
return $this->capabilities;
|
||||
}
|
||||
|
||||
public function has(string $capability): bool
|
||||
{
|
||||
return in_array(strtoupper($capability), $this->capabilities, true);
|
||||
}
|
||||
}
|
||||
28
lib/Client/Command/Result/CommandStatusResult.php
Normal file
28
lib/Client/Command/Result/CommandStatusResult.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class CommandStatusResult
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $status,
|
||||
private readonly string $text,
|
||||
) {}
|
||||
|
||||
public function status(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function text(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function isOk(): bool
|
||||
{
|
||||
return $this->status === 'OK';
|
||||
}
|
||||
}
|
||||
152
lib/Client/Command/Result/MessageTransferResult.php
Normal file
152
lib/Client/Command/Result/MessageTransferResult.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class MessageTransferResult
|
||||
{
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $status,
|
||||
private readonly string $text,
|
||||
private readonly array $responseCodes = [],
|
||||
private readonly ?array $copyUid = null,
|
||||
private readonly bool $tryCreate = false,
|
||||
private readonly ?string $highestModSeq = null,
|
||||
private readonly array $expunged = [],
|
||||
private readonly array $vanished = [],
|
||||
) {}
|
||||
|
||||
public function status(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function text(): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
|
||||
public function isOk(): bool
|
||||
{
|
||||
return $this->status === 'OK';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{source:string, name:string, arguments:list<string>, text:string}>
|
||||
*/
|
||||
public function responseCodes(): array
|
||||
{
|
||||
return $this->responseCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?array{uidValidity:string, sourceUids:string, destinationUids:string}
|
||||
*/
|
||||
public function copyUid(): ?array
|
||||
{
|
||||
return $this->copyUid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public function copyUidMap(): array
|
||||
{
|
||||
if ($this->copyUid === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sourceUids = $this->expandUidSet($this->copyUid['sourceUids']);
|
||||
$destinationUids = $this->expandUidSet($this->copyUid['destinationUids']);
|
||||
|
||||
if (count($sourceUids) !== count($destinationUids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$mapping = [];
|
||||
foreach ($sourceUids as $index => $sourceUid) {
|
||||
$mapping[$sourceUid] = $destinationUids[$index];
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
public function tryCreate(): bool
|
||||
{
|
||||
return $this->tryCreate;
|
||||
}
|
||||
|
||||
public function highestModSeq(): ?string
|
||||
{
|
||||
return $this->highestModSeq;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function expunged(): array
|
||||
{
|
||||
return $this->expunged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{earlier:bool, knownUids:string}>
|
||||
*/
|
||||
public function vanished(): array
|
||||
{
|
||||
return $this->vanished;
|
||||
}
|
||||
|
||||
public function hasResponseCode(string $name): bool
|
||||
{
|
||||
$name = strtoupper(trim($name));
|
||||
|
||||
foreach ($this->responseCodes as $responseCode) {
|
||||
if ($responseCode['name'] === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function expandUidSet(string $value): array
|
||||
{
|
||||
$expanded = [];
|
||||
|
||||
foreach (explode(',', $value) as $segment) {
|
||||
$segment = trim($segment);
|
||||
if ($segment === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!str_contains($segment, ':')) {
|
||||
$expanded[] = $segment;
|
||||
continue;
|
||||
}
|
||||
|
||||
[$start, $end] = array_map('trim', explode(':', $segment, 2));
|
||||
|
||||
if (!ctype_digit($start) || !ctype_digit($end)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$range = range((int) $start, (int) $end, (int) $start <= (int) $end ? 1 : -1);
|
||||
foreach ($range as $uid) {
|
||||
$expanded[] = (string) $uid;
|
||||
}
|
||||
}
|
||||
|
||||
return $expanded;
|
||||
}
|
||||
}
|
||||
36
lib/Client/Command/Result/SearchResult.php
Normal file
36
lib/Client/Command/Result/SearchResult.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
|
||||
final class SearchResult
|
||||
{
|
||||
/**
|
||||
* @param list<int> $matches
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $matches,
|
||||
private readonly IdentifierMode $identifierMode,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function matches(): array
|
||||
{
|
||||
return $this->matches;
|
||||
}
|
||||
|
||||
public function identifierMode(): IdentifierMode
|
||||
{
|
||||
return $this->identifierMode;
|
||||
}
|
||||
|
||||
public function isUidSearch(): bool
|
||||
{
|
||||
return $this->identifierMode === IdentifierMode::Uid;
|
||||
}
|
||||
}
|
||||
36
lib/Client/Command/Result/SortResult.php
Normal file
36
lib/Client/Command/Result/SortResult.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
|
||||
final class SortResult
|
||||
{
|
||||
/**
|
||||
* @param list<int> $matches
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $matches,
|
||||
private readonly IdentifierMode $identifierMode,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function matches(): array
|
||||
{
|
||||
return $this->matches;
|
||||
}
|
||||
|
||||
public function identifierMode(): IdentifierMode
|
||||
{
|
||||
return $this->identifierMode;
|
||||
}
|
||||
|
||||
public function isUidSort(): bool
|
||||
{
|
||||
return $this->identifierMode === IdentifierMode::Uid;
|
||||
}
|
||||
}
|
||||
54
lib/Client/Command/Result/StatusResult.php
Normal file
54
lib/Client/Command/Result/StatusResult.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command\Result;
|
||||
|
||||
final class StatusResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $items
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
private readonly array $items,
|
||||
) {}
|
||||
|
||||
public function mailbox(): string
|
||||
{
|
||||
return $this->mailbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function items(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function value(string $item, int $default = 0): int
|
||||
{
|
||||
return $this->items[strtoupper(trim($item))] ?? $default;
|
||||
}
|
||||
|
||||
public function messages(): int
|
||||
{
|
||||
return $this->value('MESSAGES');
|
||||
}
|
||||
|
||||
public function unseen(): int
|
||||
{
|
||||
return $this->value('UNSEEN');
|
||||
}
|
||||
|
||||
public function read(): int
|
||||
{
|
||||
return max(0, $this->messages() - $this->unseen());
|
||||
}
|
||||
|
||||
public function state(): int
|
||||
{
|
||||
return $this->value('UIDVALIDITY');
|
||||
}
|
||||
}
|
||||
126
lib/Client/Command/SearchCommand.php
Normal file
126
lib/Client/Command/SearchCommand.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\SearchResult;
|
||||
use KTXM\ProviderImap\Client\IdentifierMode;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
|
||||
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\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<SearchResult>
|
||||
*/
|
||||
final class SearchCommand implements CommandInterface
|
||||
{
|
||||
/**
|
||||
* @param SearchCriteriaBuilder|list<string> $criteria
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly SearchCriteriaBuilder|array $criteria = ['ALL'],
|
||||
private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence,
|
||||
private readonly ?string $charset = 'UTF-8',
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'SEARCH';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
$criteria = $this->normalizeCriteria(
|
||||
$this->criteria instanceof SearchCriteriaBuilder
|
||||
? $this->criteria->toArray()
|
||||
: $this->criteria,
|
||||
);
|
||||
$command = $this->identifierMode === IdentifierMode::Uid ? 'UID SEARCH' : 'SEARCH';
|
||||
|
||||
if ($this->charset !== null && $this->charset !== '') {
|
||||
$command .= ' CHARSET ' . strtoupper(trim($this->charset));
|
||||
}
|
||||
|
||||
$command .= ' ' . implode(' ', $criteria);
|
||||
|
||||
return new RequestFrame($command);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): SearchResult
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('SEARCH requires a selected mailbox.');
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'SEARCH') {
|
||||
$matches = $this->parseMatches($response->payloadTokens());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('SEARCH failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new SearchResult($matches, $this->identifierMode);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('SEARCH did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $criteria
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeCriteria(array $criteria): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($criteria as $criterion) {
|
||||
$criterion = trim($criterion);
|
||||
if ($criterion === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $criterion;
|
||||
}
|
||||
|
||||
return $normalized === [] ? ['ALL'] : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parseMatches(array $tokens): array
|
||||
{
|
||||
$matches = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (preg_match('/^[1-9]\d*$/', $token) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = (int) $token;
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
}
|
||||
124
lib/Client/Command/SelectCommand.php
Normal file
124
lib/Client/Command/SelectCommand.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Mailbox;
|
||||
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\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<Mailbox>
|
||||
*/
|
||||
final class SelectCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
private readonly bool $readOnly = true,
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->readOnly ? 'EXAMINE' : 'SELECT';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'%s %s',
|
||||
$this->name(),
|
||||
$this->quote($this->mailbox),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): Mailbox
|
||||
{
|
||||
$exists = 0;
|
||||
$recent = 0;
|
||||
$flags = [];
|
||||
$readOnly = $this->readOnly;
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse) {
|
||||
$raw = $response->raw();
|
||||
|
||||
if (preg_match('/^\*\s+(\d+)\s+EXISTS$/i', $raw, $matches)) {
|
||||
$exists = (int) $matches[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\*\s+(\d+)\s+RECENT$/i', $raw, $matches)) {
|
||||
$recent = (int) $matches[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response->label() === 'FLAGS' && preg_match('/\(([^)]*)\)/', $response->payload(), $matches)) {
|
||||
$flags = $this->parseFlags($matches[1]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException($this->name() . ' failed: ' . $response->text());
|
||||
}
|
||||
|
||||
if (str_contains(strtoupper($response->text()), 'READ-ONLY')) {
|
||||
$readOnly = true;
|
||||
}
|
||||
|
||||
$context->setSelectedMailbox($this->mailbox);
|
||||
$context->setState(SessionState::Selected);
|
||||
|
||||
return new Mailbox(
|
||||
$this->mailbox,
|
||||
null,
|
||||
[],
|
||||
$exists,
|
||||
0,
|
||||
null,
|
||||
$recent,
|
||||
$flags,
|
||||
$readOnly,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException($this->name() . ' did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseFlags(string $flags): array
|
||||
{
|
||||
$flags = trim($flags);
|
||||
|
||||
if ($flags === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return preg_split('/\s+/', $flags) ?: [];
|
||||
}
|
||||
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
154
lib/Client/Command/SortCommand.php
Normal file
154
lib/Client/Command/SortCommand.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\SortResult;
|
||||
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\SearchCriteriaBuilder;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<SortResult>
|
||||
*/
|
||||
final class SortCommand implements CommandInterface
|
||||
{
|
||||
/**
|
||||
* @param SearchCriteriaBuilder|list<string> $criteria
|
||||
* @param list<string> $sortCriteria
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $sortCriteria,
|
||||
private readonly SearchCriteriaBuilder|array $criteria = ['ALL'],
|
||||
private readonly IdentifierMode $identifierMode = IdentifierMode::Sequence,
|
||||
private readonly string $charset = 'UTF-8',
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'SORT';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
$sortCriteria = $this->normalizeSortCriteria($this->sortCriteria);
|
||||
if ($sortCriteria === []) {
|
||||
throw new ImapException('SORT requires at least one sort criterion.');
|
||||
}
|
||||
|
||||
$criteria = $this->normalizeCriteria(
|
||||
$this->criteria instanceof SearchCriteriaBuilder
|
||||
? $this->criteria->toArray()
|
||||
: $this->criteria,
|
||||
);
|
||||
|
||||
$command = $this->identifierMode === IdentifierMode::Uid ? 'UID SORT' : 'SORT';
|
||||
$command .= sprintf(
|
||||
' (%s) %s %s',
|
||||
implode(' ', $sortCriteria),
|
||||
strtoupper(trim($this->charset)),
|
||||
implode(' ', $criteria),
|
||||
);
|
||||
|
||||
return new RequestFrame($command);
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): SortResult
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('SORT requires a selected mailbox.');
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'SORT') {
|
||||
$matches = $this->parseMatches($response->payloadTokens());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('SORT failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new SortResult($matches, $this->identifierMode);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('SORT did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $criteria
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeCriteria(array $criteria): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($criteria as $criterion) {
|
||||
$criterion = trim($criterion);
|
||||
if ($criterion === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $criterion;
|
||||
}
|
||||
|
||||
return $normalized === [] ? ['ALL'] : $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $criteria
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeSortCriteria(array $criteria): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($criteria as $criterion) {
|
||||
$criterion = strtoupper(trim($criterion));
|
||||
if ($criterion === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $criterion;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parseMatches(array $tokens): array
|
||||
{
|
||||
$matches = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (preg_match('/^[1-9]\d*$/', $token) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = (int) $token;
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
}
|
||||
54
lib/Client/Command/StartTlsCommand.php
Normal file
54
lib/Client/Command/StartTlsCommand.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class StartTlsCommand implements CommandInterface
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'STARTTLS';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::NotAuthenticated];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame('STARTTLS');
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('STARTTLS failed: ' . $response->text());
|
||||
}
|
||||
|
||||
$context->connection()->upgradeToTls();
|
||||
$context->replaceCapabilities();
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('STARTTLS did not receive a tagged completion response.');
|
||||
}
|
||||
}
|
||||
118
lib/Client/Command/StatusCommand.php
Normal file
118
lib/Client/Command/StatusCommand.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\StatusResult;
|
||||
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\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<StatusResult>
|
||||
*/
|
||||
final class StatusCommand implements CommandInterface
|
||||
{
|
||||
private readonly StatusResponseParser $statusResponseParser;
|
||||
|
||||
/**
|
||||
* @param list<string> $items
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $mailbox,
|
||||
private readonly array $items = ['MESSAGES', 'UNSEEN'],
|
||||
?StatusResponseParser $statusResponseParser = null,
|
||||
) {
|
||||
$this->statusResponseParser = $statusResponseParser ?? new StatusResponseParser();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'STATUS';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [
|
||||
SessionState::Authenticated,
|
||||
SessionState::Selected,
|
||||
];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'STATUS %s (%s)',
|
||||
$this->quote($this->mailbox),
|
||||
implode(' ', $this->normalizeItems($this->items)),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): StatusResult
|
||||
{
|
||||
unset($context);
|
||||
|
||||
$items = [];
|
||||
$mailbox = $this->mailbox;
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof UntaggedResponse && $response->label() === 'STATUS') {
|
||||
[$mailbox, $items] = $this->statusResponseParser->parse($response->payload());
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('STATUS failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new StatusResult($mailbox, $items);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('STATUS did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $items
|
||||
* @return list<string>
|
||||
*/
|
||||
private function normalizeItems(array $items): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$item = strtoupper(trim($item));
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[A-Z0-9.-]+$/', $item)) {
|
||||
throw new ImapException('Invalid STATUS item: ' . $item);
|
||||
}
|
||||
|
||||
if (in_array($item, $normalized, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $item;
|
||||
}
|
||||
|
||||
if ($normalized === []) {
|
||||
throw new ImapException('STATUS requires at least one data item.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
private function quote(string $value): string
|
||||
{
|
||||
return '"' . addcslashes($value, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
139
lib/Client/Command/StatusResponseParser.php
Normal file
139
lib/Client/Command/StatusResponseParser.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
|
||||
final class StatusResponseParser
|
||||
{
|
||||
/**
|
||||
* @return array{0: string, 1: array<string, int>}
|
||||
*/
|
||||
public function parse(string $payload): array
|
||||
{
|
||||
$payload = trim($payload);
|
||||
$offset = 0;
|
||||
|
||||
$nameToken = $this->readToken($payload, $offset);
|
||||
$statusToken = $this->readToken($payload, $offset);
|
||||
|
||||
if ($nameToken === null || $statusToken === null) {
|
||||
throw new ImapException('Unable to parse STATUS response payload: ' . $payload);
|
||||
}
|
||||
|
||||
$mailbox = $this->decodeAtom($nameToken);
|
||||
if ($mailbox === null || $mailbox === '') {
|
||||
throw new ImapException('STATUS response is missing a mailbox name: ' . $payload);
|
||||
}
|
||||
|
||||
return [$mailbox, $this->parseItems($statusToken, $payload)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function parseItems(string $statusToken, string $payload): array
|
||||
{
|
||||
$statusToken = trim($statusToken);
|
||||
|
||||
if (!str_starts_with($statusToken, '(') || !str_ends_with($statusToken, ')')) {
|
||||
throw new ImapException('Invalid STATUS data payload: ' . $payload);
|
||||
}
|
||||
|
||||
$items = trim(substr($statusToken, 1, -1));
|
||||
if ($items === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', $items) ?: [];
|
||||
if (count($tokens) % 2 !== 0) {
|
||||
throw new ImapException('Malformed STATUS item list: ' . $payload);
|
||||
}
|
||||
|
||||
$status = [];
|
||||
|
||||
for ($index = 0; $index < count($tokens); $index += 2) {
|
||||
$item = strtoupper($tokens[$index]);
|
||||
$value = $tokens[$index + 1];
|
||||
|
||||
if (!preg_match('/^\d+$/', $value)) {
|
||||
throw new ImapException('STATUS item value must be numeric: ' . $payload);
|
||||
}
|
||||
|
||||
$status[$item] = (int) $value;
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
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 STATUS item 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 STATUS 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;
|
||||
}
|
||||
}
|
||||
112
lib/Client/Command/StoreCommand.php
Normal file
112
lib/Client/Command/StoreCommand.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Command;
|
||||
|
||||
use KTXM\ProviderImap\Client\Command\Result\CommandStatusResult;
|
||||
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\ResponseStream;
|
||||
use KTXM\ProviderImap\Client\SequenceSet;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<CommandStatusResult>
|
||||
*/
|
||||
final class StoreCommand implements CommandInterface
|
||||
{
|
||||
private readonly SequenceSet $sequenceSet;
|
||||
private readonly IdentifierMode $identifierMode;
|
||||
|
||||
/**
|
||||
* @param list<string> $flags
|
||||
*/
|
||||
public function __construct(
|
||||
FetchTarget|string|SequenceSet|null $target = null,
|
||||
private readonly array $flags = [],
|
||||
private readonly string $action = '',
|
||||
private readonly bool $silent = true,
|
||||
) {
|
||||
$resolvedTarget = match (true) {
|
||||
$target instanceof FetchTarget => $target,
|
||||
$target instanceof SequenceSet => FetchTarget::sequence($target),
|
||||
is_string($target) => FetchTarget::sequence($target),
|
||||
default => FetchTarget::all(),
|
||||
};
|
||||
|
||||
$normalizedAction = trim($this->action);
|
||||
if (!in_array($normalizedAction, ['', '+', '-'], true)) {
|
||||
throw new ImapException('STORE action must be one of "", "+", or "-".');
|
||||
}
|
||||
|
||||
$normalizedFlags = array_values(array_filter(array_map(
|
||||
static fn (string $flag): string => trim($flag),
|
||||
$this->flags,
|
||||
), static fn (string $flag): bool => $flag !== ''));
|
||||
|
||||
if ($normalizedFlags === []) {
|
||||
throw new ImapException('STORE requires at least one flag.');
|
||||
}
|
||||
|
||||
$this->flags = $normalizedFlags;
|
||||
$this->action = $normalizedAction;
|
||||
$this->sequenceSet = $resolvedTarget->sequenceSet();
|
||||
$this->identifierMode = $resolvedTarget->identifierMode();
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'STORE';
|
||||
}
|
||||
|
||||
public function allowedStates(): array
|
||||
{
|
||||
return [SessionState::Selected];
|
||||
}
|
||||
|
||||
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||
{
|
||||
unset($tag, $context);
|
||||
|
||||
return new RequestFrame(sprintf(
|
||||
'%sSTORE %s %s (%s)',
|
||||
$this->identifierMode === IdentifierMode::Uid ? 'UID ' : '',
|
||||
$this->sequenceSet->toCommand(),
|
||||
$this->itemName(),
|
||||
implode(' ', $this->flags),
|
||||
));
|
||||
}
|
||||
|
||||
public function handle(ResponseStream $responses, SessionContext $context): CommandStatusResult
|
||||
{
|
||||
if ($context->selectedMailbox() === null) {
|
||||
throw new ImapException('STORE requires a selected mailbox.');
|
||||
}
|
||||
|
||||
foreach ($responses as $response) {
|
||||
if ($response instanceof TaggedResponse) {
|
||||
if (!$response->isOk()) {
|
||||
throw new ImapException('STORE failed: ' . $response->text());
|
||||
}
|
||||
|
||||
return new CommandStatusResult($response->status(), $response->text());
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImapException('STORE did not receive a tagged completion response.');
|
||||
}
|
||||
|
||||
private function itemName(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%sFLAGS%s',
|
||||
$this->action,
|
||||
$this->silent ? '.SILENT' : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user