refactor: re-organize files

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-09-25 11:32:25 -04:00
parent 3fdb7b7a2e
commit 9a86292bcf
47 changed files with 244 additions and 228 deletions
@@ -0,0 +1,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, "\\\"") . '"';
}
}