refactor: re-organize files

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-09-25 11:32:25 -04:00
parent 3fdb7b7a2e
commit 9a86292bcf
47 changed files with 244 additions and 228 deletions
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
/**
* APPEND a raw RFC822 message to a mailbox (RFC 3501 §6.3.11).
*
* Uses a synchronizing literal: the command line ends with `{<len>}`, the
* server replies with a continuation request ("+"), and only then is the
* message streamed back via {@see ResponseStream::respond()}. Returns the
* assigned UID when the server reports APPENDUID (RFC 4315 / UIDPLUS),
* otherwise null.
*
* @implements CommandInterface<int|null>
*/
final class AppendCommand implements CommandInterface
{
private readonly string $literal;
/**
* @param list<string> $flags optional initial flags, e.g. ['\\Seen']
*/
public function __construct(
private readonly string $mailbox,
string $message,
private readonly array $flags = [],
) {
// IMAP literals are octet-counted; normalise to CRLF line endings.
$this->literal = (string) preg_replace('/\r\n|\r|\n/', "\r\n", $message);
}
public function name(): string
{
return 'APPEND';
}
public function allowedStates(): array
{
return [SessionState::Authenticated, SessionState::Selected];
}
public function encode(string $tag, SessionContext $context): RequestFrame
{
unset($tag, $context);
$flagSegment = $this->flags === [] ? '' : '(' . implode(' ', $this->flags) . ') ';
return new RequestFrame(sprintf(
'APPEND %s %s{%d}',
$this->quote($this->mailbox),
$flagSegment,
strlen($this->literal),
));
}
public function handle(ResponseStream $responses, SessionContext $context): ?int
{
unset($context);
foreach ($responses as $response) {
if ($response instanceof ContinuationResponse) {
// Continuation granted: stream the literal, then CRLF to end the command.
$responses->respond($this->literal . "\r\n");
continue;
}
if ($response instanceof TaggedResponse) {
if (!$response->isOk()) {
throw new ImapException('APPEND failed: ' . $response->text());
}
return $this->parseAppendUid($response->text());
}
}
throw new ImapException('APPEND did not receive a tagged completion response.');
}
private function quote(string $mailbox): string
{
return '"' . addcslashes($mailbox, "\\\"") . '"';
}
private function parseAppendUid(string $text): ?int
{
if (preg_match('/\[APPENDUID\s+\d+\s+(\d+)\]/i', $text, $matches) === 1) {
return (int) $matches[1];
}
return null;
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
final class FetchOptions
{
/**
* @param list<string> $items
*/
private function __construct(
private readonly array $items,
) {}
public static function default(): self
{
return (new self([]))
->withUid()
->withFlags()
->withInternalDate()
->withSize();
}
public static function message(): self
{
return self::default()
->withHeaders()
->withEnvelope()
->withBodyStructure();
}
public static function of(string ...$items): self
{
return new self(self::normalize($items));
}
public function withUid(): self
{
return $this->with('UID');
}
public function withFlags(): self
{
return $this->with('FLAGS');
}
public function withInternalDate(): self
{
return $this->with('INTERNALDATE');
}
public function withSize(): self
{
return $this->with('RFC822.SIZE');
}
public function withHeaders(): self
{
return $this->with('BODY[HEADER]');
}
public function withHeader(string ...$fields): self
{
$fields = array_values(array_filter(array_map(
static fn (string $field): string => strtoupper(trim($field)),
$fields,
), static fn (string $field): bool => $field !== ''));
if ($fields === []) {
return $this;
}
return $this->with(sprintf('BODY.PEEK[HEADER.FIELDS (%s)]', implode(' ', $fields)));
}
public function withEnvelope(): self
{
return $this->with('ENVELOPE');
}
public function withBodyStructure(): self
{
return $this->with('BODYSTRUCTURE');
}
public function withBodyText(): self
{
return $this->with('BODY[TEXT]');
}
public function withBody(): self
{
return $this->with('BODY[]');
}
public function withBodySection(string $section): self
{
$section = strtoupper(trim($section));
if ($section === '') {
return $this;
}
return $this->with(sprintf('BODY[%s]', $section));
}
public function with(string $item): self
{
return new self(self::normalize([
...$this->items,
$item,
]));
}
/**
* @return list<string>
*/
public function toArray(): array
{
return $this->items;
}
public function toCommand(): string
{
return implode(' ', $this->items);
}
/**
* @param list<string> $items
* @return list<string>
*/
private static function normalize(array $items): array
{
$normalized = [];
foreach ($items as $item) {
$item = trim($item);
if ($item === '' || in_array($item, $normalized, true)) {
continue;
}
$normalized[] = $item;
}
if (!in_array('UID', $normalized, true)) {
array_unshift($normalized, 'UID');
}
return $normalized;
}
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
use KTXM\ProviderImap\Client\ImapException;
final class ListReturnOptions
{
private const SUBSCRIBED = 'SUBSCRIBED';
private const CHILDREN = 'CHILDREN';
private const SPECIAL_USE = 'SPECIAL-USE';
/**
* @param list<string> $options
* @param list<string>|null $statusItems
*/
private function __construct(
private readonly array $options,
private readonly ?array $statusItems = null,
) {}
public static function none(): self
{
return new self([]);
}
public static function of(string ...$options): self
{
return new self(self::normalize($options));
}
public static function subscribed(): self
{
return self::none()->withSubscribed();
}
public static function children(): self
{
return self::none()->withChildren();
}
public static function specialUse(): self
{
return self::none()->withSpecialUse();
}
public static function status(string ...$items): self
{
return self::none()->withStatus(...$items);
}
public function withSubscribed(): self
{
return $this->with(self::SUBSCRIBED);
}
public function withChildren(): self
{
return $this->with(self::CHILDREN);
}
public function withSpecialUse(): self
{
return $this->with(self::SPECIAL_USE);
}
public function withStatus(string ...$items): self
{
return new self($this->options, self::normalizeStatusItems($items));
}
/**
* @return list<string>
*/
public function toArray(): array
{
$options = $this->options;
if ($this->statusItems !== null) {
$options[] = sprintf('STATUS (%s)', implode(' ', $this->statusItems));
}
return $options;
}
public function toCommand(): ?string
{
$options = $this->toArray();
if ($options === []) {
return null;
}
return '(' . implode(' ', $options) . ')';
}
public function hasStatus(): bool
{
return $this->statusItems !== null;
}
/**
* @return list<string>
*/
public function statusItems(): array
{
return $this->statusItems ?? [];
}
private function with(string $option): self
{
return new self(self::normalize([
...$this->options,
$option,
]), $this->statusItems);
}
/**
* @param list<string> $options
* @return list<string>
*/
private static function normalize(array $options): array
{
$normalized = [];
foreach ($options as $option) {
$option = strtoupper(trim($option));
if ($option === '') {
continue;
}
if (!in_array($option, [
self::SUBSCRIBED,
self::CHILDREN,
self::SPECIAL_USE,
], true)) {
throw new ImapException('Unsupported LIST return option: ' . $option);
}
if (in_array($option, $normalized, true)) {
continue;
}
$normalized[] = $option;
}
return $normalized;
}
/**
* @param list<string> $items
* @return list<string>
*/
private static function normalizeStatusItems(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 LIST STATUS data item: ' . $item);
}
if (in_array($item, $normalized, true)) {
continue;
}
$normalized[] = $item;
}
if ($normalized === []) {
throw new ImapException('LIST STATUS return option requires at least one STATUS data item.');
}
return $normalized;
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
use KTXM\ProviderImap\Client\ImapException;
final class ListSelectionOptions
{
private const SUBSCRIBED = 'SUBSCRIBED';
private const REMOTE = 'REMOTE';
private const RECURSIVEMATCH = 'RECURSIVEMATCH';
private const SPECIAL_USE = 'SPECIAL-USE';
/**
* @param list<string> $options
*/
private function __construct(
private readonly array $options,
) {}
public static function none(): self
{
return new self([]);
}
public static function of(string ...$options): self
{
return new self(self::normalize($options));
}
public static function subscribed(): self
{
return self::none()->withSubscribed();
}
public static function remote(): self
{
return self::none()->withRemote();
}
public static function specialUse(): self
{
return self::none()->withSpecialUse();
}
public function withSubscribed(): self
{
return $this->with(self::SUBSCRIBED);
}
public function withRemote(): self
{
return $this->with(self::REMOTE);
}
public function withRecursiveMatch(): self
{
return $this->with(self::RECURSIVEMATCH);
}
public function withSpecialUse(): self
{
return $this->with(self::SPECIAL_USE);
}
/**
* @return list<string>
*/
public function toArray(): array
{
return $this->options;
}
public function toCommand(): ?string
{
if ($this->options === []) {
return null;
}
return '(' . implode(' ', $this->options) . ')';
}
private function with(string $option): self
{
return new self(self::normalize([
...$this->options,
$option,
]));
}
/**
* @param list<string> $options
* @return list<string>
*/
private static function normalize(array $options): array
{
$normalized = [];
foreach ($options as $option) {
$option = strtoupper(trim($option));
if ($option === '') {
continue;
}
if (!in_array($option, [
self::SUBSCRIBED,
self::REMOTE,
self::RECURSIVEMATCH,
self::SPECIAL_USE,
], true)) {
throw new ImapException('Unsupported LIST selection option: ' . $option);
}
if (in_array($option, $normalized, true)) {
continue;
}
$normalized[] = $option;
}
if (in_array(self::RECURSIVEMATCH, $normalized, true)
&& !in_array(self::SUBSCRIBED, $normalized, true)) {
throw new ImapException('RECURSIVEMATCH requires SUBSCRIBED in LIST selection options.');
}
return $normalized;
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
final class MessageTarget
{
private function __construct(
private readonly SequenceSet $sequenceSet,
private readonly IdentifierMode $identifierMode,
) {}
public static function all(IdentifierMode $identifierMode = IdentifierMode::Sequence): self
{
return new self(SequenceSet::all(), $identifierMode);
}
public static function sequence(int|string|SequenceSet $target): self
{
return new self(self::coerceSequenceSet($target), IdentifierMode::Sequence);
}
public static function uid(int|string|SequenceSet $target): self
{
return new self(self::coerceSequenceSet($target), IdentifierMode::Uid);
}
public function sequenceSet(): SequenceSet
{
return $this->sequenceSet;
}
public function identifierMode(): IdentifierMode
{
return $this->identifierMode;
}
private static function coerceSequenceSet(int|string|SequenceSet $target): SequenceSet
{
return match (true) {
$target instanceof SequenceSet => $target,
is_int($target) => SequenceSet::single($target),
default => SequenceSet::parse($target),
};
}
}
@@ -0,0 +1,379 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command\Argument;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use DateTimeInterface;
use InvalidArgumentException;
final class SearchCriteriaBuilder
{
/**
* @var list<string>
*/
private array $criteria = [];
public static function create(): self
{
return new self();
}
public function all(): self
{
return $this->pushExpression('ALL');
}
public function answered(): self
{
return $this->pushExpression('ANSWERED');
}
public function unanswered(): self
{
return $this->pushExpression('UNANSWERED');
}
public function deleted(): self
{
return $this->pushExpression('DELETED');
}
public function undeleted(): self
{
return $this->pushExpression('UNDELETED');
}
public function draft(): self
{
return $this->pushExpression('DRAFT');
}
public function undraft(): self
{
return $this->pushExpression('UNDRAFT');
}
public function flagged(): self
{
return $this->pushExpression('FLAGGED');
}
public function unflagged(): self
{
return $this->pushExpression('UNFLAGGED');
}
public function seen(): self
{
return $this->pushExpression('SEEN');
}
public function unseen(): self
{
return $this->pushExpression('UNSEEN');
}
public function recent(): self
{
return $this->pushExpression('RECENT');
}
public function old(): self
{
return $this->pushExpression('OLD');
}
public function new(): self
{
return $this->pushExpression('NEW');
}
public function from(string $value): self
{
return $this->pushKeyValue('FROM', $value);
}
public function to(string $value): self
{
return $this->pushKeyValue('TO', $value);
}
public function cc(string $value): self
{
return $this->pushKeyValue('CC', $value);
}
public function bcc(string $value): self
{
return $this->pushKeyValue('BCC', $value);
}
public function subject(string $value): self
{
return $this->pushKeyValue('SUBJECT', $value);
}
public function body(string $value): self
{
return $this->pushKeyValue('BODY', $value);
}
public function text(string $value): self
{
return $this->pushKeyValue('TEXT', $value);
}
public function keyword(string $value): self
{
return $this->pushKeyValue('KEYWORD', $value, false);
}
public function unkeyword(string $value): self
{
return $this->pushKeyValue('UNKEYWORD', $value, false);
}
public function before(DateTimeInterface|string $value): self
{
return $this->pushDate('BEFORE', $value);
}
public function on(DateTimeInterface|string $value): self
{
return $this->pushDate('ON', $value);
}
public function since(DateTimeInterface|string $value): self
{
return $this->pushDate('SINCE', $value);
}
public function sentBefore(DateTimeInterface|string $value): self
{
return $this->pushDate('SENTBEFORE', $value);
}
public function sentOn(DateTimeInterface|string $value): self
{
return $this->pushDate('SENTON', $value);
}
public function sentSince(DateTimeInterface|string $value): self
{
return $this->pushDate('SENTSINCE', $value);
}
public function larger(int $value): self
{
return $this->pushNumber('LARGER', $value);
}
public function smaller(int $value): self
{
return $this->pushNumber('SMALLER', $value);
}
public function uid(int|string|SequenceSet $value): self
{
return $this->pushExpression('UID ' . $this->formatSequenceSet($value));
}
public function sequence(int|string|SequenceSet $value): self
{
return $this->pushExpression($this->formatSequenceSet($value));
}
public function header(string $name, string $value): self
{
return $this->pushExpression(sprintf(
'HEADER %s %s',
$this->formatString($name),
$this->formatString($value),
));
}
public function group(SearchCriteriaBuilder|array|string $criteria): self
{
$expression = $this->normalizeOperand($criteria, true);
if ($expression === '') {
return $this;
}
return $this->pushExpression($expression);
}
public function not(SearchCriteriaBuilder|array|string $criteria): self
{
$expression = $this->normalizeOperand($criteria, true);
if ($expression === '') {
throw new InvalidArgumentException('NOT search criteria cannot be empty.');
}
return $this->pushExpression('NOT ' . $expression);
}
public function or(SearchCriteriaBuilder|array|string $left, SearchCriteriaBuilder|array|string $right): self
{
$leftExpression = $this->normalizeOperand($left, true);
$rightExpression = $this->normalizeOperand($right, true);
if ($leftExpression === '' || $rightExpression === '') {
throw new InvalidArgumentException('OR search criteria requires two non-empty operands.');
}
return $this->pushExpression(sprintf('OR %s %s', $leftExpression, $rightExpression));
}
public function raw(string ...$criteria): self
{
$normalized = [];
foreach ($criteria as $criterion) {
$criterion = trim($criterion);
if ($criterion === '') {
continue;
}
$normalized[] = $criterion;
}
if ($normalized === []) {
return $this;
}
foreach ($normalized as $expression) {
$this->pushExpression($expression);
}
return $this;
}
/**
* @return list<string>
*/
public function build(): array
{
return $this->criteria === [] ? ['ALL'] : $this->criteria;
}
/**
* @return list<string>
*/
public function toArray(): array
{
return $this->build();
}
private function pushExpression(string $expression): self
{
$expression = trim($expression);
if ($expression !== '') {
$this->criteria[] = $expression;
}
return $this;
}
private function pushKeyValue(string $key, string $value, bool $quote = true): self
{
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException(sprintf('%s search criteria cannot be empty.', $key));
}
return $this->pushExpression(sprintf(
'%s %s',
$key,
$quote ? $this->formatString($value) : $value,
));
}
private function pushDate(string $key, DateTimeInterface|string $value): self
{
return $this->pushExpression(sprintf('%s %s', $key, $this->formatDate($value)));
}
private function pushNumber(string $key, int $value): self
{
if ($value < 0) {
throw new InvalidArgumentException(sprintf('%s search criteria must not be negative.', $key));
}
return $this->pushExpression(sprintf('%s %d', $key, $value));
}
private function normalizeOperand(SearchCriteriaBuilder|array|string $criteria, bool $wrapComposite): string
{
if ($criteria instanceof self) {
$expressions = $criteria->toArray();
return $this->combineExpressions($expressions, $wrapComposite);
}
if (is_string($criteria)) {
$criteria = trim($criteria);
return $criteria;
}
$normalized = [];
foreach ($criteria as $criterion) {
$criterion = trim((string) $criterion);
if ($criterion === '') {
continue;
}
$normalized[] = $criterion;
}
return $this->combineExpressions($normalized, $wrapComposite);
}
/**
* @param list<string> $expressions
*/
private function combineExpressions(array $expressions, bool $wrapComposite): string
{
$expression = implode(' ', $expressions);
if ($expression === '') {
return '';
}
if (!$wrapComposite || count($expressions) === 1) {
return $expression;
}
return '(' . $expression . ')';
}
private function formatDate(DateTimeInterface|string $value): string
{
if ($value instanceof DateTimeInterface) {
return $value->format('d-M-Y');
}
$value = trim($value);
if ($value === '') {
throw new InvalidArgumentException('Search dates cannot be empty.');
}
return $value;
}
private function formatSequenceSet(int|string|SequenceSet $value): string
{
return match (true) {
$value instanceof SequenceSet => $value->toCommand(),
is_int($value) => SequenceSet::single($value)->toCommand(),
default => SequenceSet::parse($value)->toCommand(),
};
}
private function formatString(string $value): string
{
return '"' . addcslashes($value, "\\\"") . '"';
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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());
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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;
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
/**
* @implements CommandInterface<MessageTransferResult>
*/
final class CopyCommand implements CommandInterface
{
private readonly MessageTransferCommand $command;
public function __construct(
MessageTarget|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);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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, "\\\"") . '"';
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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, "\\\"") . '"';
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\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\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
/**
* @implements CommandInterface<list<int>>
*/
final class ExpungeCommand implements CommandInterface
{
private readonly ?SequenceSet $sequenceSet;
public function __construct(MessageTarget|string|SequenceSet|null $target = null)
{
if ($target === null) {
$this->sequenceSet = null;
return;
}
$resolvedTarget = match (true) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::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.');
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\Protocol\Parser\FetchResponseParser;
use Generator;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\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\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
/**
* @implements CommandInterface<Generator<int, Message>>
*/
final class FetchManyCommand implements CommandInterface
{
private readonly MessageTarget $target;
private readonly FetchOptions $options;
public function __construct(MessageTarget|string|SequenceSet|null $target = null, ?FetchOptions $options = null)
{
$this->target = match (true) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::sequence($target),
default => MessageTarget::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->identifierMode() === IdentifierMode::Uid ? 'UID FETCH' : 'FETCH',
$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);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\Protocol\Parser\FetchResponseParser;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
/**
* @implements CommandInterface<Message>
*/
final class FetchOneCommand implements CommandInterface
{
private readonly MessageTarget $target;
private readonly FetchOptions $options;
public function __construct(MessageTarget|int|string $target, ?FetchOptions $options = null)
{
$this->target = $target instanceof MessageTarget
? $target
: MessageTarget::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->identifierMode() === IdentifierMode::Uid ? 'UID FETCH' : 'FETCH',
$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);
}
}
+264
View File
@@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Protocol\Parser\StatusResponseParser;
use Generator;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\ListReturnOptions;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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, "\\\"") . '"';
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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, "\\\"") . '"';
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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.');
}
}
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\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\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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,
MessageTarget|string|SequenceSet|null $target = null,
private readonly string $destinationMailbox = '',
) {
$resolvedTarget = match (true) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::sequence($target),
default => MessageTarget::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, "\\\"") . '"';
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Result\MessageTransferResult;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
use KTXM\ProviderImap\Client\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
/**
* @implements CommandInterface<MessageTransferResult>
*/
final class MoveCommand implements CommandInterface
{
private readonly MessageTransferCommand $command;
public function __construct(
MessageTarget|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);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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.');
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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, "\\\"") . '"';
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Result\SearchResult;
use KTXM\ProviderImap\Client\Protocol\IdentifierMode;
use KTXM\ProviderImap\Client\ImapException;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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;
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Result\SortResult;
use KTXM\ProviderImap\Client\Protocol\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\Protocol\Command\Argument\SearchCriteriaBuilder;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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.');
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Protocol\Parser\StatusResponseParser;
use KTXM\ProviderImap\Client\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\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\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, "\\\"") . '"';
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace KTXM\ProviderImap\Client\Protocol\Command;
use KTXM\ProviderImap\Client\Result\CommandStatusResult;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
use KTXM\ProviderImap\Client\Protocol\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\Protocol\SequenceSet;
use KTXM\ProviderImap\Client\Protocol\SessionContext;
use KTXM\ProviderImap\Client\Protocol\SessionState;
/**
* @implements CommandInterface<CommandStatusResult>
*/
final class StoreCommand implements CommandInterface
{
private readonly SequenceSet $sequenceSet;
private readonly IdentifierMode $identifierMode;
/**
* @param list<string> $flags
*/
public function __construct(
MessageTarget|string|SequenceSet|null $target = null,
private array $flags = [],
private string $action = '',
private bool $silent = true,
) {
$resolvedTarget = match (true) {
$target instanceof MessageTarget => $target,
$target instanceof SequenceSet => MessageTarget::sequence($target),
is_string($target) => MessageTarget::sequence($target),
default => MessageTarget::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' : '',
);
}
}