generated from Nodarx/template
refactor: re-organize files
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, "\\\"") . '"';
|
||||
}
|
||||
}
|
||||
@@ -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' : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,14 @@ declare(strict_types=1);
|
||||
namespace KTXM\ProviderImap\Client\Protocol;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||
use KTXM\ProviderImap\Client\FetchTarget;
|
||||
use KTXM\ProviderImap\Client\Protocol\Command\CommandInterface;
|
||||
use KTXM\ProviderImap\Client\Protocol\Command\Argument\MessageTarget;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||
use KTXM\ProviderImap\Client\SessionContext;
|
||||
use KTXM\ProviderImap\Client\SessionState;
|
||||
use KTXM\ProviderImap\Client\Protocol\SessionContext;
|
||||
use KTXM\ProviderImap\Client\Protocol\SessionState;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class CommandExecutor
|
||||
@@ -62,7 +62,7 @@ final class CommandExecutor
|
||||
*
|
||||
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
|
||||
*/
|
||||
public function download(FetchTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator
|
||||
public function download(MessageTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator
|
||||
{
|
||||
$this->assertState([SessionState::Selected], $context->state(), 'FETCH (download)');
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol;
|
||||
|
||||
enum IdentifierMode
|
||||
{
|
||||
case Sequence;
|
||||
case Uid;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol\Parser;
|
||||
|
||||
use Generator;
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\Protocol\Parser\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->raw())) {
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol\Parser;
|
||||
|
||||
use KTXM\ProviderImap\Client\ImapException;
|
||||
use KTXM\ProviderImap\Client\Message;
|
||||
use KTXM\ProviderImap\Client\MessageAddress;
|
||||
use KTXM\ProviderImap\Client\MessagePart;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class MessageParser
|
||||
{
|
||||
public static function isFetchMessage(string $raw): bool
|
||||
{
|
||||
return preg_match('/^\*\s+\d+\s+FETCH\s+\(/i', $raw) === 1;
|
||||
}
|
||||
|
||||
public static function parse(string $raw): Message
|
||||
{
|
||||
if (preg_match('/^\*\s+(\d+)\s+FETCH\s+\(/iA', $raw, $matches) !== 1
|
||||
|| !str_ends_with($raw, ')')) {
|
||||
throw new ImapException('Unable to parse FETCH response: ' . $raw);
|
||||
}
|
||||
|
||||
$sequence = (int) $matches[1];
|
||||
$offset = strlen($matches[0]);
|
||||
$attributes = self::parseAttributes($raw, $offset, strlen($raw) - 1);
|
||||
$uid = self::toInt($attributes['UID'] ?? null, 'FETCH response is missing UID: ' . $raw);
|
||||
$envelope = is_array($attributes['ENVELOPE'] ?? null) ? $attributes['ENVELOPE'] : null;
|
||||
$bodyStructure = isset($attributes['BODYSTRUCTURE']) ? self::parseBodyPart($attributes['BODYSTRUCTURE'], '') : null;
|
||||
$bodySections = self::parseBodySections($attributes, $bodyStructure);
|
||||
$headers = self::parseFetchedHeaders($attributes);
|
||||
|
||||
return new Message(
|
||||
$sequence,
|
||||
$uid,
|
||||
self::toOptionalInt($attributes['RFC822.SIZE'] ?? null) ?? 0,
|
||||
self::toNullableString($attributes['INTERNALDATE'] ?? null),
|
||||
self::extractReceivedAt($headers),
|
||||
self::parseFlags($attributes['FLAGS'] ?? null),
|
||||
self::decodeMimeHeader(self::envelopeString($envelope, 1)),
|
||||
self::envelopeString($envelope, 0),
|
||||
self::trimAngles(self::envelopeString($envelope, 9)),
|
||||
self::envelopeString($envelope, 8),
|
||||
self::parseAddressList($envelope[2] ?? null),
|
||||
self::parseAddressList($envelope[3] ?? null),
|
||||
self::parseAddressList($envelope[4] ?? null),
|
||||
self::parseAddressList($envelope[5] ?? null),
|
||||
self::parseAddressList($envelope[6] ?? null),
|
||||
self::parseAddressList($envelope[7] ?? null),
|
||||
$bodyStructure,
|
||||
$bodySections,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function parseAttributes(string $payload, int &$offset, int $end): array
|
||||
{
|
||||
$attributes = [];
|
||||
|
||||
while ($offset < $end) {
|
||||
self::skipWhitespace($payload, $offset);
|
||||
if ($offset >= $end) {
|
||||
break;
|
||||
}
|
||||
|
||||
$name = self::parseAttributeName($payload, $offset);
|
||||
if (!is_string($name) || $name === '') {
|
||||
throw new ImapException('Unable to parse FETCH attribute name: ' . $payload);
|
||||
}
|
||||
|
||||
self::skipWhitespace($payload, $offset);
|
||||
$attributes[strtoupper($name)] = self::parseToken($payload, $offset);
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private static function parseAttributeName(string $payload, int &$offset): string
|
||||
{
|
||||
self::skipWhitespace($payload, $offset);
|
||||
|
||||
if (preg_match('/\GBODY(?:\.PEEK)?\[/Ai', $payload, $matches, 0, $offset) === 1) {
|
||||
$start = $offset;
|
||||
$offset += strlen($matches[0]);
|
||||
$depth = 1;
|
||||
$length = strlen($payload);
|
||||
|
||||
while ($offset < $length) {
|
||||
$char = $payload[$offset];
|
||||
|
||||
if ($char === '[') {
|
||||
$depth++;
|
||||
} elseif ($char === ']') {
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
$offset++;
|
||||
return substr($payload, $start, $offset - $start);
|
||||
}
|
||||
}
|
||||
|
||||
$offset++;
|
||||
}
|
||||
|
||||
throw new ImapException('Unterminated FETCH BODY section attribute.');
|
||||
}
|
||||
|
||||
$name = self::parseToken($payload, $offset);
|
||||
if (!is_string($name)) {
|
||||
throw new ImapException('Invalid FETCH attribute name.');
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private static function parseToken(string $payload, int &$offset): mixed
|
||||
{
|
||||
self::skipWhitespace($payload, $offset);
|
||||
$length = strlen($payload);
|
||||
|
||||
if ($offset >= $length) {
|
||||
throw new ImapException('Unexpected end of FETCH response.');
|
||||
}
|
||||
|
||||
$char = $payload[$offset];
|
||||
|
||||
if ($char === '(') {
|
||||
$offset++;
|
||||
$items = [];
|
||||
|
||||
while (true) {
|
||||
self::skipWhitespace($payload, $offset);
|
||||
if ($offset >= $length) {
|
||||
throw new ImapException('Unterminated FETCH list response.');
|
||||
}
|
||||
|
||||
if ($payload[$offset] === ')') {
|
||||
$offset++;
|
||||
return $items;
|
||||
}
|
||||
|
||||
$items[] = self::parseToken($payload, $offset);
|
||||
}
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
return self::parseQuotedString($payload, $offset);
|
||||
}
|
||||
|
||||
if ($char === '{') {
|
||||
return self::parseLiteral($payload, $offset);
|
||||
}
|
||||
|
||||
$start = $offset;
|
||||
while ($offset < $length && !ctype_space($payload[$offset]) && $payload[$offset] !== '(' && $payload[$offset] !== ')') {
|
||||
$offset++;
|
||||
}
|
||||
|
||||
$atom = substr($payload, $start, $offset - $start);
|
||||
if (strtoupper($atom) === 'NIL') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $atom;
|
||||
}
|
||||
|
||||
private static function parseLiteral(string $payload, int &$offset): string
|
||||
{
|
||||
if (preg_match('/\G\{(\d+)\}\r\n/As', $payload, $matches, 0, $offset) !== 1
|
||||
&& preg_match('/\G\{(\d+)\}\n/As', $payload, $matches, 0, $offset) !== 1) {
|
||||
throw new ImapException('Invalid FETCH literal marker.');
|
||||
}
|
||||
|
||||
$offset += strlen($matches[0]);
|
||||
$length = (int) $matches[1];
|
||||
$literal = substr($payload, $offset, $length);
|
||||
|
||||
if (strlen($literal) !== $length) {
|
||||
throw new ImapException('FETCH literal length does not match payload.');
|
||||
}
|
||||
|
||||
$offset += $length;
|
||||
|
||||
return $literal;
|
||||
}
|
||||
|
||||
private static function parseQuotedString(string $payload, int &$offset): string
|
||||
{
|
||||
$offset++;
|
||||
$length = strlen($payload);
|
||||
$value = '';
|
||||
|
||||
while ($offset < $length) {
|
||||
$char = $payload[$offset];
|
||||
|
||||
if ($char === '\\') {
|
||||
$offset++;
|
||||
if ($offset >= $length) {
|
||||
break;
|
||||
}
|
||||
|
||||
$value .= $payload[$offset];
|
||||
$offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"') {
|
||||
$offset++;
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value .= $char;
|
||||
$offset++;
|
||||
}
|
||||
|
||||
throw new ImapException('Unterminated quoted FETCH string.');
|
||||
}
|
||||
|
||||
private static function skipWhitespace(string $payload, int &$offset): void
|
||||
{
|
||||
$length = strlen($payload);
|
||||
while ($offset < $length && ctype_space($payload[$offset])) {
|
||||
$offset++;
|
||||
}
|
||||
}
|
||||
|
||||
private static function toInt(mixed $value, string $message): int
|
||||
{
|
||||
if ($value === null || !preg_match('/^\d+$/', (string) $value)) {
|
||||
throw new ImapException($message);
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private static function toOptionalInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || !preg_match('/^\d+$/', (string) $value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function parseFlags(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $flag): ?string => is_string($flag) && $flag !== '' ? $flag : null,
|
||||
$value,
|
||||
)));
|
||||
}
|
||||
|
||||
private static function toNullableString(mixed $value): ?string
|
||||
{
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private static function parseFetchedHeaders(array $attributes): array
|
||||
{
|
||||
$headers = [];
|
||||
|
||||
foreach ($attributes as $name => $value) {
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^BODY(?:\.PEEK)?\[(.+)\]$/i', $name, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$section = strtoupper(trim($matches[1]));
|
||||
if (!str_starts_with($section, 'HEADER')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (self::parseHeaderBlock($value) as $headerName => $headerValues) {
|
||||
$normalized = strtolower($headerName);
|
||||
$headers[$normalized] ??= [];
|
||||
array_push($headers[$normalized], ...$headerValues);
|
||||
}
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private static function parseHeaderBlock(string $headers): array
|
||||
{
|
||||
$parsed = [];
|
||||
$currentName = null;
|
||||
$currentValue = '';
|
||||
|
||||
foreach (preg_split("/\r\n|\n|\r/", $headers) ?: [] as $line) {
|
||||
if ($line === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (($line[0] === ' ' || $line[0] === "\t") && $currentName !== null) {
|
||||
$currentValue .= ' ' . trim($line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentName !== null) {
|
||||
$parsed[$currentName] ??= [];
|
||||
$parsed[$currentName][] = trim($currentValue);
|
||||
}
|
||||
|
||||
$separator = strpos($line, ':');
|
||||
if ($separator === false) {
|
||||
$currentName = null;
|
||||
$currentValue = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentName = substr($line, 0, $separator);
|
||||
$currentValue = substr($line, $separator + 1);
|
||||
}
|
||||
|
||||
if ($currentName !== null) {
|
||||
$parsed[$currentName] ??= [];
|
||||
$parsed[$currentName][] = trim($currentValue);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $headers
|
||||
*/
|
||||
private static function extractReceivedAt(array $headers): ?string
|
||||
{
|
||||
foreach ($headers['delivery-date'] ?? [] as $value) {
|
||||
$date = self::parseHeaderDate($value);
|
||||
if ($date !== null) {
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($headers['received'] ?? [] as $value) {
|
||||
$date = self::parseHeaderDateFromReceived($value);
|
||||
if ($date !== null) {
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function parseHeaderDate(?string $value): ?string
|
||||
{
|
||||
$value = self::toNullableString($value);
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new \DateTimeImmutable($value))->format(DateTimeInterface::ATOM);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function parseHeaderDateFromReceived(string $value): ?string
|
||||
{
|
||||
$separator = strrpos($value, ';');
|
||||
if ($separator === false) {
|
||||
return self::parseHeaderDate($value);
|
||||
}
|
||||
|
||||
return self::parseHeaderDate(substr($value, $separator + 1));
|
||||
}
|
||||
|
||||
private static function envelopeString(?array $envelope, int $index): ?string
|
||||
{
|
||||
if ($envelope === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::toNullableString($envelope[$index] ?? null);
|
||||
}
|
||||
|
||||
private static function decodeMimeHeader(?string $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return function_exists('mb_decode_mimeheader') ? mb_decode_mimeheader($value) : $value;
|
||||
}
|
||||
|
||||
private static function trimAngles(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($value, '<>');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MessageAddress>
|
||||
*/
|
||||
private static function parseAddressList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$addresses = [];
|
||||
foreach ($value as $address) {
|
||||
if (!is_array($address)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$addresses[] = new MessageAddress(
|
||||
self::decodeMimeHeader(self::toNullableString($address[0] ?? null)),
|
||||
self::toNullableString($address[2] ?? null),
|
||||
self::toNullableString($address[3] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
return $addresses;
|
||||
}
|
||||
|
||||
private static function parseBodyPart(mixed $value, string $partId): ?MessagePart
|
||||
{
|
||||
if (!is_array($value) || $value === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_array($value[0] ?? null)) {
|
||||
$parts = [];
|
||||
$index = 0;
|
||||
while (isset($value[$index]) && is_array($value[$index])) {
|
||||
$childPartId = $partId === '' ? (string) ($index + 1) : $partId . '.' . ($index + 1);
|
||||
$child = self::parseBodyPart($value[$index], $childPartId);
|
||||
if ($child !== null) {
|
||||
$parts[] = $child;
|
||||
}
|
||||
$index++;
|
||||
}
|
||||
|
||||
$subtype = strtolower(self::toNullableString($value[$index] ?? null) ?? 'mixed');
|
||||
$parameters = self::parsePairs($value[$index + 1] ?? null);
|
||||
[$disposition, $dispositionParameters] = self::parseDisposition($value[$index + 2] ?? null);
|
||||
$language = self::parseStringList($value[$index + 3] ?? null);
|
||||
$location = self::toNullableString($value[$index + 4] ?? null);
|
||||
|
||||
return new MessagePart(
|
||||
$partId,
|
||||
'multipart/' . $subtype,
|
||||
$parameters,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$disposition,
|
||||
$dispositionParameters,
|
||||
$language,
|
||||
$location,
|
||||
null,
|
||||
$parts,
|
||||
);
|
||||
}
|
||||
|
||||
$type = strtolower(self::toNullableString($value[0] ?? null) ?? 'application');
|
||||
$subtype = strtolower(self::toNullableString($value[1] ?? null) ?? 'octet-stream');
|
||||
$parameters = self::parsePairs($value[2] ?? null);
|
||||
$contentId = self::trimAngles(self::toNullableString($value[3] ?? null));
|
||||
$description = self::toNullableString($value[4] ?? null);
|
||||
$encoding = self::toNullableString($value[5] ?? null);
|
||||
$size = self::toOptionalInt($value[6] ?? null);
|
||||
|
||||
$tailOffset = in_array($type, ['text', 'message'], true) ? 8 : 7;
|
||||
[$disposition, $dispositionParameters] = self::parseDisposition($value[$tailOffset + 1] ?? null);
|
||||
$language = self::parseStringList($value[$tailOffset + 2] ?? null);
|
||||
$location = self::toNullableString($value[$tailOffset + 3] ?? null);
|
||||
|
||||
return new MessagePart(
|
||||
$partId === '' ? '1' : $partId,
|
||||
$type . '/' . $subtype,
|
||||
$parameters,
|
||||
$contentId,
|
||||
$description,
|
||||
$encoding,
|
||||
$size,
|
||||
$disposition,
|
||||
$dispositionParameters,
|
||||
$language,
|
||||
$location,
|
||||
null,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function parseBodySections(array $attributes, ?MessagePart $bodyStructure = null): array
|
||||
{
|
||||
$sections = [];
|
||||
|
||||
foreach ($attributes as $name => $value) {
|
||||
if (!preg_match('/^BODY(?:\.PEEK)?\[(.*)\]$/i', $name, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$section = strtoupper(trim($matches[1]));
|
||||
|
||||
if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) {
|
||||
$section = $partMatches[1];
|
||||
}
|
||||
|
||||
$sections[$section] = $value;
|
||||
}
|
||||
|
||||
if ($bodyStructure === null || !isset($sections['TEXT'])) {
|
||||
return $bodyStructure === null ? $sections : self::decodeSections($sections, $bodyStructure);
|
||||
}
|
||||
|
||||
if ($bodyStructure->isMultipart()) {
|
||||
$derivedSections = self::sectionsFromBodyText($sections['TEXT'], $bodyStructure);
|
||||
unset($sections['TEXT']);
|
||||
|
||||
foreach ($derivedSections as $section => $content) {
|
||||
$sections[$section] ??= $content;
|
||||
}
|
||||
|
||||
return self::decodeSections($sections, $bodyStructure);
|
||||
}
|
||||
|
||||
if (str_starts_with($bodyStructure->mimeType(), 'text/')) {
|
||||
$sections[$bodyStructure->partId()] ??= $sections['TEXT'];
|
||||
unset($sections['TEXT']);
|
||||
}
|
||||
|
||||
return self::decodeSections($sections, $bodyStructure);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $sections
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function decodeSections(array $sections, MessagePart $bodyStructure): array
|
||||
{
|
||||
$decodedSections = [];
|
||||
|
||||
foreach ($sections as $section => $content) {
|
||||
$part = self::findBodyPart($bodyStructure, (string) $section);
|
||||
if ($part === null || !str_starts_with($part->mimeType(), 'text/')) {
|
||||
$decodedSections[$section] = $content;
|
||||
continue;
|
||||
}
|
||||
|
||||
$decodedSections[$section] = self::decodeSectionContent(
|
||||
$content,
|
||||
$part->encoding(),
|
||||
$part->parameters()['charset'] ?? 'us-ascii',
|
||||
);
|
||||
}
|
||||
|
||||
return $decodedSections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function sectionsFromBodyText(string $content, MessagePart $part): array
|
||||
{
|
||||
if ($part->isMultipart()) {
|
||||
$boundary = $part->parameters()['boundary'] ?? '';
|
||||
if ($boundary === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sections = [];
|
||||
$segments = self::splitMultipartBody($content, $boundary);
|
||||
foreach ($part->parts() as $index => $childPart) {
|
||||
if (!isset($segments[$index])) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (self::sectionsFromMimeEntity($segments[$index], $childPart) as $section => $childContent) {
|
||||
$sections[$section] = $childContent;
|
||||
}
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
if (!str_starts_with($part->mimeType(), 'text/')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$part->partId() => $content];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function sectionsFromMimeEntity(string $content, MessagePart $part): array
|
||||
{
|
||||
[, $body] = self::splitMimeEntity($content);
|
||||
|
||||
if ($part->isMultipart()) {
|
||||
return self::sectionsFromBodyText($body, $part);
|
||||
}
|
||||
|
||||
if (!str_starts_with($part->mimeType(), 'text/')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$part->partId() => $body];
|
||||
}
|
||||
|
||||
private static function findBodyPart(MessagePart $part, string $section): ?MessagePart
|
||||
{
|
||||
if ($part->partId() === $section) {
|
||||
return $part;
|
||||
}
|
||||
|
||||
foreach ($part->parts() as $childPart) {
|
||||
$match = self::findBodyPart($childPart, $section);
|
||||
if ($match !== null) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function splitMultipartBody(string $content, string $boundary): array
|
||||
{
|
||||
$pattern = '/(?:^|\r\n|\n)--' . preg_quote($boundary, '/') . '(--)?[ \t]*(?:\r\n|\n|$)/';
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
$segmentStart = null;
|
||||
|
||||
foreach ($matches[0] as $index => [$match, $offset]) {
|
||||
if ($segmentStart !== null) {
|
||||
$segments[] = substr($content, $segmentStart, $offset - $segmentStart);
|
||||
}
|
||||
|
||||
$isClosing = isset($matches[1][$index][1])
|
||||
&& $matches[1][$index][1] !== -1
|
||||
&& $matches[1][$index][0] === '--';
|
||||
|
||||
if ($isClosing) {
|
||||
break;
|
||||
}
|
||||
|
||||
$segmentStart = $offset + strlen($match);
|
||||
}
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private static function splitMimeEntity(string $content): array
|
||||
{
|
||||
foreach (["\r\n\r\n", "\n\n"] as $separator) {
|
||||
$position = strpos($content, $separator);
|
||||
if ($position === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return [
|
||||
substr($content, 0, $position),
|
||||
substr($content, $position + strlen($separator)),
|
||||
];
|
||||
}
|
||||
|
||||
return ['', $content];
|
||||
}
|
||||
|
||||
private static function decodeSectionContent(string $content, ?string $encoding, string $charset): string
|
||||
{
|
||||
$decoded = match (strtolower($encoding ?? '7bit')) {
|
||||
'quoted-printable' => quoted_printable_decode($content),
|
||||
'base64' => base64_decode($content, true) ?: '',
|
||||
default => $content,
|
||||
};
|
||||
|
||||
if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) {
|
||||
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
try {
|
||||
$converted = mb_convert_encoding($decoded, 'UTF-8', $charset);
|
||||
if ($converted !== false) {
|
||||
return $converted;
|
||||
}
|
||||
} catch (\ValueError) {
|
||||
}
|
||||
|
||||
$converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $decoded);
|
||||
$decoded = $converted !== false ? $converted : $decoded;
|
||||
|
||||
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function parsePairs(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pairs = [];
|
||||
for ($index = 0; $index < count($value); $index += 2) {
|
||||
$name = self::toNullableString($value[$index] ?? null);
|
||||
if ($name === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pairs[strtolower($name)] = self::toNullableString($value[$index + 1] ?? null) ?? '';
|
||||
}
|
||||
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: array<string, string>}
|
||||
*/
|
||||
private static function parseDisposition(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
return [
|
||||
strtolower(self::toNullableString($value[0] ?? null) ?? ''),
|
||||
self::parsePairs($value[1] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function parseStringList(mixed $value): array
|
||||
{
|
||||
if ($value === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return [$value];
|
||||
}
|
||||
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $item): ?string => is_string($item) && $item !== '' ? $item : null,
|
||||
$value,
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol\Parser;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class SequenceSet
|
||||
{
|
||||
private function __construct(
|
||||
private readonly string $value,
|
||||
) {}
|
||||
|
||||
public static function all(): self
|
||||
{
|
||||
return new self('1:*');
|
||||
}
|
||||
|
||||
public static function single(int|string $identifier): self
|
||||
{
|
||||
return new self(self::normalizeIdentifier($identifier));
|
||||
}
|
||||
|
||||
public static function range(int|string $from, int|string $to): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'%s:%s',
|
||||
self::normalizeIdentifier($from),
|
||||
self::normalizeIdentifier($to),
|
||||
));
|
||||
}
|
||||
|
||||
public static function from(int|string $from): self
|
||||
{
|
||||
return self::range($from, '*');
|
||||
}
|
||||
|
||||
public static function items(int|string ...$identifiers): self
|
||||
{
|
||||
if ($identifiers === []) {
|
||||
throw new InvalidArgumentException('Sequence sets require at least one identifier.');
|
||||
}
|
||||
|
||||
return new self(implode(',', array_map(
|
||||
static fn (int|string $identifier): string => self::normalizeIdentifier($identifier),
|
||||
$identifiers,
|
||||
)));
|
||||
}
|
||||
|
||||
public static function parse(string $value): self
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('Sequence sets cannot be empty.');
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s*,\s*/', $value) ?: [];
|
||||
if ($parts === []) {
|
||||
throw new InvalidArgumentException('Sequence sets require at least one identifier.');
|
||||
}
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!preg_match('/^(?:\*|[1-9]\d*)(?::(?:\*|[1-9]\d*))?$/', $part)) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid IMAP sequence-set segment: %s', $part));
|
||||
}
|
||||
}
|
||||
|
||||
return new self(implode(',', $parts));
|
||||
}
|
||||
|
||||
public function toCommand(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
private static function normalizeIdentifier(int|string $identifier): string
|
||||
{
|
||||
if ($identifier === '*') {
|
||||
return '*';
|
||||
}
|
||||
|
||||
if (is_int($identifier)) {
|
||||
if ($identifier < 1) {
|
||||
throw new InvalidArgumentException('IMAP sequence identifiers must be greater than zero.');
|
||||
}
|
||||
|
||||
return (string) $identifier;
|
||||
}
|
||||
|
||||
$identifier = trim($identifier);
|
||||
if (preg_match('/^[1-9]\d*$/', $identifier) !== 1) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid IMAP sequence identifier: %s', $identifier));
|
||||
}
|
||||
|
||||
return $identifier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol;
|
||||
|
||||
use KTXM\ProviderImap\Client\ConnectionConfig;
|
||||
|
||||
use KTXM\ProviderImap\Client\Protocol\Response\GreetingResponse;
|
||||
use KTXM\ProviderImap\Client\Transport\ConnectionInterface;
|
||||
|
||||
final class SessionContext
|
||||
{
|
||||
/** @var array<string, true> */
|
||||
private array $capabilities = [];
|
||||
private SessionState $state = SessionState::Disconnected;
|
||||
private ?GreetingResponse $greeting = null;
|
||||
private ?string $selectedMailbox = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionConfig $config,
|
||||
private readonly ConnectionInterface $connection,
|
||||
) {}
|
||||
|
||||
public function config(): ConnectionConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function connection(): ConnectionInterface
|
||||
{
|
||||
return $this->connection;
|
||||
}
|
||||
|
||||
public function state(): SessionState
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
public function setState(SessionState $state): void
|
||||
{
|
||||
$this->state = $state;
|
||||
}
|
||||
|
||||
public function greeting(): ?GreetingResponse
|
||||
{
|
||||
return $this->greeting;
|
||||
}
|
||||
|
||||
public function setGreeting(GreetingResponse $greeting): void
|
||||
{
|
||||
$this->greeting = $greeting;
|
||||
}
|
||||
|
||||
public function selectedMailbox(): ?string
|
||||
{
|
||||
return $this->selectedMailbox;
|
||||
}
|
||||
|
||||
public function setSelectedMailbox(?string $selectedMailbox): void
|
||||
{
|
||||
$this->selectedMailbox = $selectedMailbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function capabilities(): array
|
||||
{
|
||||
return array_keys($this->capabilities);
|
||||
}
|
||||
|
||||
public function replaceCapabilities(string ...$capabilities): void
|
||||
{
|
||||
$this->capabilities = [];
|
||||
|
||||
foreach ($capabilities as $capability) {
|
||||
$normalized = strtoupper(trim($capability));
|
||||
if ($normalized === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->capabilities[$normalized] = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasCapability(string $capability): bool
|
||||
{
|
||||
return isset($this->capabilities[strtoupper($capability)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderImap\Client\Protocol;
|
||||
|
||||
enum SessionState: string
|
||||
{
|
||||
case Disconnected = 'disconnected';
|
||||
case NotAuthenticated = 'not_authenticated';
|
||||
case Authenticated = 'authenticated';
|
||||
case Selected = 'selected';
|
||||
case Logout = 'logout';
|
||||
}
|
||||
Reference in New Issue
Block a user