generated from Nodarx/template
856 lines
31 KiB
PHP
856 lines
31 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXM\ProviderImap\Service\Remote;
|
|
|
|
use DateTimeImmutable;
|
|
use Generator;
|
|
use KTXM\ProviderImap\Client\Client;
|
|
use KTXM\ProviderImap\Client\Command\FetchManyCommand;
|
|
use KTXM\ProviderImap\Client\Command\ExpungeCommand;
|
|
use KTXM\ProviderImap\Client\Command\ListCommand;
|
|
use KTXM\ProviderImap\Client\Command\SearchCommand;
|
|
use KTXM\ProviderImap\Client\Command\SelectCommand;
|
|
use KTXM\ProviderImap\Client\Command\SortCommand;
|
|
use KTXM\ProviderImap\Client\Command\StatusCommand;
|
|
use KTXM\ProviderImap\Client\Command\StoreCommand;
|
|
use KTXM\ProviderImap\Client\Command\CopyCommand;
|
|
use KTXM\ProviderImap\Client\Command\CreateCommand;
|
|
use KTXM\ProviderImap\Client\Command\RenameCommand;
|
|
use KTXM\ProviderImap\Client\Command\DeleteCommand;
|
|
use KTXM\ProviderImap\Client\FetchTarget;
|
|
use KTXM\ProviderImap\Client\FetchOptions;
|
|
use KTXM\ProviderImap\Client\IdentifierMode;
|
|
use KTXM\ProviderImap\Client\ImapException;
|
|
use KTXM\ProviderImap\Client\ListReturnOptions;
|
|
use KTXM\ProviderImap\Client\Mailbox;
|
|
use KTXM\ProviderImap\Client\Message;
|
|
use KTXM\ProviderImap\Client\MessageAddress;
|
|
use KTXM\ProviderImap\Client\MessagePart;
|
|
use KTXM\ProviderImap\Client\Command\MoveCommand;
|
|
use KTXM\ProviderImap\Client\SearchCriteriaBuilder;
|
|
use KTXM\ProviderImap\Client\SequenceSet;
|
|
use KTXF\Mail\Collection\CollectionRoles;
|
|
use KTXF\Resource\Filter\IFilter;
|
|
use KTXF\Resource\Filter\FilterComparisonOperator;
|
|
use KTXF\Resource\Filter\FilterConjunctionOperator;
|
|
use KTXF\Resource\Range\IRange;
|
|
use KTXF\Resource\Range\IRangeTally;
|
|
use KTXF\Resource\Range\RangeAnchorType;
|
|
use KTXF\Resource\Range\RangeTally;
|
|
use KTXF\Resource\Sort\ISort;
|
|
use KTXM\ProviderImap\Providers\CollectionResource;
|
|
use KTXM\ProviderImap\Providers\EntityResource;
|
|
|
|
/**
|
|
* IMAP Remote Mail Service
|
|
*/
|
|
class RemoteMailService
|
|
{
|
|
|
|
private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription'];
|
|
private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY'];
|
|
|
|
public function __construct(
|
|
private readonly Client $client,
|
|
) {}
|
|
|
|
/**
|
|
* list of collections in remote storage
|
|
*
|
|
* @since Release 1.0.0
|
|
*/
|
|
public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator
|
|
{
|
|
// Prepare location filter
|
|
if (!empty($location)) {
|
|
$location = ltrim($location, '/');
|
|
if (!str_ends_with($location, '/')) {
|
|
$location .= '/';
|
|
}
|
|
} else {
|
|
$location = '';
|
|
}
|
|
// construct the most efficient LIST command based on server capabilities
|
|
if ($this->client->hasCapability('LIST-STATUS') && !empty($depth)) {
|
|
$command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
|
$rfc5258 = true;
|
|
} else {
|
|
$command = new ListCommand($location, $depth);
|
|
$rfc5258 = false;
|
|
}
|
|
// retrieve list of mailboxes from remote
|
|
$mailboxes = [];
|
|
foreach ($this->client->perform($command) as $mailbox) {
|
|
// apply filter
|
|
if ($filter === null || $this->mailboxFilter($mailbox, $filter)) {
|
|
if ($rfc5258) {
|
|
yield $mailbox->name() => $mailbox;
|
|
} else {
|
|
$mailboxes[] = $mailbox;
|
|
}
|
|
}
|
|
}
|
|
// enrich with STATUS info if not already provided by LIST-STATUS response
|
|
if (!$rfc5258) {
|
|
foreach ($mailboxes as $index => $mailbox) {
|
|
if (!$mailbox->isSelectable()) {
|
|
yield $mailbox->name() => $mailbox;
|
|
continue;
|
|
}
|
|
try {
|
|
$status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
|
$mailbox->fromStatus($status);
|
|
} catch (ImapException) {
|
|
// do nothing
|
|
}
|
|
yield $mailbox->name() => $mailbox;
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Fetch a single mailbox by its full name.
|
|
*
|
|
* Returns null when no mailbox matching $identifier is found.
|
|
*/
|
|
public function collectionFetch(string $identifier): ?Mailbox
|
|
{
|
|
// retrieve mailbox from remote
|
|
$mailbox = iterator_to_array($this->client->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))));
|
|
if (empty($mailbox)) {
|
|
return null;
|
|
}
|
|
$mailbox = reset($mailbox);
|
|
// enrich with STATUS
|
|
$status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
|
$mailbox->fromStatus($status);
|
|
|
|
return $mailbox;
|
|
}
|
|
|
|
/**
|
|
* Create a new IMAP mailbox and return it.
|
|
*
|
|
* If the server-side LIST cannot confirm the new mailbox (e.g., immediate
|
|
* consistency), a lightweight stub resource is returned instead.
|
|
*/
|
|
public function collectionCreate(string $name): Mailbox
|
|
{
|
|
$result = $this->client->perform(new CreateCommand($name));
|
|
|
|
if (!$result->isOk()) {
|
|
throw new ImapException('Failed to create mailbox: ' . $name);
|
|
}
|
|
|
|
// Attempt to refetch the new mailbox from the server
|
|
$mailbox = $this->collectionFetch($name);
|
|
if ($mailbox === null) {
|
|
throw new ImapException('Failed to create mailbox: ' . $name);
|
|
}
|
|
|
|
return $mailbox;
|
|
}
|
|
|
|
/**
|
|
* Rename a mailbox and return the updated resource.
|
|
*/
|
|
public function collectionRename(string $oldName, string $newName): Mailbox
|
|
{
|
|
$result = $this->client->perform(new RenameCommand($oldName, $newName));
|
|
|
|
if (!$result->isOk()) {
|
|
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
|
}
|
|
|
|
$mailbox = $this->collectionFetch($newName);
|
|
|
|
if ($mailbox === null) {
|
|
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
|
}
|
|
|
|
return $mailbox;
|
|
}
|
|
|
|
/**
|
|
* Delete a mailbox by its full name.
|
|
*/
|
|
public function collectionDestroy(string $name): bool
|
|
{
|
|
$result = $this->client->perform(new DeleteCommand($name));
|
|
|
|
if (!$result->isOk()) {
|
|
throw new ImapException('Failed to delete mailbox: ' . $name);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── Entity (message) operations ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Find UIDs of messages in a mailbox matching the given filter, sorted and paginated as requested.
|
|
*
|
|
* @return int[] list of UIDs matching the filter, sorted and paginated as requested
|
|
*/
|
|
public function entityFind(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array
|
|
{
|
|
$nativeFilter = $this->buildEntitySearchCriteria($filter);
|
|
$nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : [];
|
|
|
|
$this->client->perform(new SelectCommand($collection, true));
|
|
$rfc5258 = $this->client->hasCapability('SORT');
|
|
|
|
$uids = [];
|
|
if ($nativeSort !== [] && $rfc5258) {
|
|
$uids = $this->client->perform(new SortCommand(
|
|
$nativeSort,
|
|
$nativeFilter,
|
|
IdentifierMode::Uid,
|
|
))->matches();
|
|
} else {
|
|
$uids = $this->client->perform(new SearchCommand(
|
|
$nativeFilter,
|
|
IdentifierMode::Uid,
|
|
))->matches();
|
|
}
|
|
|
|
if ($uids === []) {
|
|
return [];
|
|
}
|
|
|
|
if ($sort !== null && $nativeSort === []) {
|
|
$uids = $this->entitySortClientSide($uids, $sort);
|
|
}
|
|
|
|
return $this->entityApplyRange($uids, $range);
|
|
}
|
|
|
|
/**
|
|
* Retrieve a list of messages in a mailbox matching the given filter, sorted and paginated as requested.
|
|
*
|
|
* @return Message[] list of messages matching the filter, sorted and paginated as requested
|
|
*/
|
|
public function entityList(string $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator
|
|
{
|
|
// find all the UIDs matching the filter
|
|
$uids = $this->entityFind($collection, $filter, $sort, $range);
|
|
if (empty($uids)) {
|
|
return [];
|
|
}
|
|
|
|
$options = FetchOptions::default()->withBodyText();
|
|
|
|
yield from $this->entityFetch($collection, $options, ...$uids);
|
|
}
|
|
|
|
/**
|
|
* Fetch one or more messages by UID and return EntityResource objects.
|
|
*
|
|
* @param int ...$uids
|
|
* @return Message[] keyed by UID
|
|
*/
|
|
public function entityFetch(string $collection, ?FetchOptions $options = null, int ...$uids): Generator
|
|
{
|
|
if (empty($uids)) {
|
|
return [];
|
|
}
|
|
|
|
$options ??= FetchOptions::default();
|
|
$this->client->perform(new SelectCommand($collection, true));
|
|
|
|
$request = new FetchManyCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
$options,
|
|
);
|
|
|
|
foreach ($this->client->perform($request) as $message) {
|
|
$uid = $message->uid() ?: $message->sequence();
|
|
yield $uid => $message;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append a raw RFC 822 message to a mailbox and return the assigned UID.
|
|
*
|
|
* @param string[] $flags optional initial flags, e.g. ['\\Seen']
|
|
*/
|
|
public function entityCreate(string $collection, string $rawMessage, array $flags = []): int
|
|
{
|
|
return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null);
|
|
}
|
|
|
|
/**
|
|
* Modify message flags for one or more messages.
|
|
*
|
|
* @param string $action '+' to add, '-' to remove, '' to replace
|
|
* @param string[] $flags e.g. ['\\Seen', '\\Flagged']
|
|
* @param int ...$uids
|
|
*/
|
|
public function entityModify(string $collection, string $action, array $flags, int ...$uids): void
|
|
{
|
|
if (empty($uids)) {
|
|
return;
|
|
}
|
|
|
|
$this->client->perform(new SelectCommand($collection, false));
|
|
$this->client->perform(new StoreCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
$flags,
|
|
$action,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Permanently delete one or more messages by UID.
|
|
*/
|
|
public function entityDestroy(string $collection, int ...$uids): array
|
|
{
|
|
if (empty($uids)) {
|
|
return [];
|
|
}
|
|
|
|
$target = FetchTarget::uid(SequenceSet::items(...array_values($uids)));
|
|
|
|
$this->client->perform(new SelectCommand($collection, false));
|
|
$this->client->perform(new StoreCommand($target, ['\\Deleted'], '+'));
|
|
$this->client->perform(new ExpungeCommand($target));
|
|
|
|
// TODO: find a way to determine which actual UID's were deleted
|
|
return array_fill_keys($uids, true);
|
|
}
|
|
|
|
public function entityPatch(string $collection, array $flagsToAdd = [], array $flagsToRemove = [], int ...$uids): void
|
|
{
|
|
if (empty($uids)) {
|
|
return;
|
|
}
|
|
|
|
$this->client->perform(new SelectCommand($collection, false));
|
|
|
|
if (!empty($flagsToAdd)) {
|
|
$this->client->perform(new StoreCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
$flagsToAdd,
|
|
'+',
|
|
));
|
|
}
|
|
|
|
if (!empty($flagsToRemove)) {
|
|
$this->client->perform(new StoreCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
$flagsToRemove,
|
|
'-',
|
|
));
|
|
}
|
|
}
|
|
|
|
public function entityMove(string $targetCollection, string $sourceCollection, int ...$uids): array
|
|
{
|
|
if (empty($uids)) {
|
|
return [];
|
|
}
|
|
|
|
$rfc6851 = $this->client->hasCapability('MOVE');
|
|
|
|
// if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE
|
|
if ($rfc6851) {
|
|
$this->client->perform(new SelectCommand($sourceCollection, false));
|
|
$response = $this->client->perform(new MoveCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
$targetCollection,
|
|
));
|
|
} else {
|
|
$this->client->perform(new SelectCommand($sourceCollection, false));
|
|
$response = $this->client->perform(new CopyCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
$targetCollection,
|
|
));
|
|
if ($response->isOk()) {
|
|
$this->client->perform(new StoreCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
['\\Deleted'],
|
|
'+',
|
|
));
|
|
$this->client->perform(new ExpungeCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
));
|
|
}
|
|
}
|
|
|
|
if (!$response->isOk()) {
|
|
throw new ImapException('Failed to move messages: ' . implode(', ', $response->responseCodes()));
|
|
}
|
|
|
|
// construct operation result as a map of source UID to boolean or destination UID, depending on server support
|
|
$map = $response->copyUidMap();
|
|
if ($map === []) {
|
|
$result = array_fill_keys(array_map('strval', $uids), true);
|
|
} else {
|
|
$result = array_fill_keys(array_map('strval', $uids), false);
|
|
foreach ($uids as $uid) {
|
|
$result[$uid] = $map[$uid] ?? false;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
|
|
}
|
|
|
|
private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder
|
|
{
|
|
if ($filter === null || $filter->conditions() === []) {
|
|
return SearchCriteriaBuilder::create()->all();
|
|
}
|
|
|
|
$expression = null;
|
|
|
|
foreach ($filter->conditions() as $condition) {
|
|
$operand = $this->entityFilterOperand($condition);
|
|
if ($operand === null) {
|
|
continue;
|
|
}
|
|
|
|
if ($expression === null) {
|
|
$expression = $operand;
|
|
continue;
|
|
}
|
|
|
|
$expression = (($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR)
|
|
? SearchCriteriaBuilder::create()->or($expression, $operand)
|
|
: SearchCriteriaBuilder::create()->group($expression)->group($operand);
|
|
}
|
|
|
|
if ($expression === null) {
|
|
return SearchCriteriaBuilder::create()->all();
|
|
}
|
|
|
|
return $expression instanceof SearchCriteriaBuilder
|
|
? $expression
|
|
: SearchCriteriaBuilder::create()->group($expression);
|
|
}
|
|
|
|
/**
|
|
* @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition
|
|
*/
|
|
private function entityFilterOperand(array $condition): SearchCriteriaBuilder|array|string|null
|
|
{
|
|
$attribute = $condition['attribute'] ?? '';
|
|
$value = $condition['value'] ?? null;
|
|
$comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ;
|
|
|
|
return match ($attribute) {
|
|
'*', 'all' => $this->entityStringCriteria('text', $value, $comparator),
|
|
'from', 'to', 'cc', 'bcc', 'subject', 'body' => $this->entityStringCriteria($attribute, $value, $comparator),
|
|
'before' => $this->entityDateCriteria('before', $value, $comparator),
|
|
'after' => $this->entityDateCriteria('after', $value, $comparator),
|
|
'min' => $this->entitySizeCriteria('min', $value, $comparator),
|
|
'max' => $this->entitySizeCriteria('max', $value, $comparator),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function entityStringCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null
|
|
{
|
|
$values = is_array($value) ? array_values($value) : [$value];
|
|
$values = array_values(array_filter(array_map(
|
|
static fn (mixed $item): string => trim((string) $item),
|
|
$values,
|
|
), static fn (string $item): bool => $item !== ''));
|
|
|
|
if ($values === []) {
|
|
return null;
|
|
}
|
|
|
|
$mapper = fn (string $item): SearchCriteriaBuilder => $this->entityStringCriterion($attribute, $item);
|
|
|
|
return match ($comparator) {
|
|
FilterComparisonOperator::EQ, FilterComparisonOperator::LIKE => count($values) === 1
|
|
? $mapper($values[0])
|
|
: $this->entityOrCriteria(array_map($mapper, $values)),
|
|
FilterComparisonOperator::NEQ, FilterComparisonOperator::NLIKE => count($values) === 1
|
|
? SearchCriteriaBuilder::create()->not($mapper($values[0]))
|
|
: SearchCriteriaBuilder::create()->group($this->entityAndCriteria(array_map(
|
|
static fn (string $item): SearchCriteriaBuilder => SearchCriteriaBuilder::create()->not($mapper($item)),
|
|
$values,
|
|
))),
|
|
FilterComparisonOperator::IN => $this->entityOrCriteria(array_map($mapper, $values)),
|
|
FilterComparisonOperator::NIN => $this->entityAndCriteria(array_map(
|
|
static fn (string $item): SearchCriteriaBuilder => SearchCriteriaBuilder::create()->not($mapper($item)),
|
|
$values,
|
|
)),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function entityStringCriterion(string $attribute, string $value): SearchCriteriaBuilder
|
|
{
|
|
return match ($attribute) {
|
|
'from' => SearchCriteriaBuilder::create()->from($value),
|
|
'to' => SearchCriteriaBuilder::create()->to($value),
|
|
'cc' => SearchCriteriaBuilder::create()->cc($value),
|
|
'bcc' => SearchCriteriaBuilder::create()->bcc($value),
|
|
'subject' => SearchCriteriaBuilder::create()->subject($value),
|
|
'body' => SearchCriteriaBuilder::create()->body($value),
|
|
default => SearchCriteriaBuilder::create()->text($value),
|
|
};
|
|
}
|
|
|
|
private function entityDateCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
$date = $this->normalizeImapDate($value);
|
|
|
|
return match ($attribute) {
|
|
'before' => match ($comparator) {
|
|
FilterComparisonOperator::EQ => SearchCriteriaBuilder::create()->on($date),
|
|
FilterComparisonOperator::NEQ => SearchCriteriaBuilder::create()->not(SearchCriteriaBuilder::create()->on($date)),
|
|
FilterComparisonOperator::LT, FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->before($date),
|
|
default => null,
|
|
},
|
|
'after' => match ($comparator) {
|
|
FilterComparisonOperator::EQ => SearchCriteriaBuilder::create()->on($date),
|
|
FilterComparisonOperator::NEQ => SearchCriteriaBuilder::create()->not(SearchCriteriaBuilder::create()->on($date)),
|
|
FilterComparisonOperator::GT, FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->since($date),
|
|
default => null,
|
|
},
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function entitySizeCriteria(string $attribute, mixed $value, FilterComparisonOperator $comparator): SearchCriteriaBuilder|array|string|null
|
|
{
|
|
if (!is_int($value) && !is_numeric($value)) {
|
|
return null;
|
|
}
|
|
|
|
$size = max(0, (int) $value);
|
|
|
|
return match ($attribute) {
|
|
'min' => match ($comparator) {
|
|
FilterComparisonOperator::EQ, FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->larger(max(0, $size - 1)),
|
|
FilterComparisonOperator::GT => SearchCriteriaBuilder::create()->larger($size),
|
|
FilterComparisonOperator::LT => SearchCriteriaBuilder::create()->smaller($size),
|
|
FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->smaller($size + 1),
|
|
FilterComparisonOperator::NEQ => $this->entityOrCriteria([
|
|
SearchCriteriaBuilder::create()->smaller($size),
|
|
SearchCriteriaBuilder::create()->larger($size),
|
|
]),
|
|
default => null,
|
|
},
|
|
'max' => match ($comparator) {
|
|
FilterComparisonOperator::EQ, FilterComparisonOperator::LTE => SearchCriteriaBuilder::create()->smaller($size + 1),
|
|
FilterComparisonOperator::LT => SearchCriteriaBuilder::create()->smaller($size),
|
|
FilterComparisonOperator::GT => SearchCriteriaBuilder::create()->larger($size),
|
|
FilterComparisonOperator::GTE => SearchCriteriaBuilder::create()->larger(max(0, $size - 1)),
|
|
FilterComparisonOperator::NEQ => $this->entityOrCriteria([
|
|
SearchCriteriaBuilder::create()->smaller($size),
|
|
SearchCriteriaBuilder::create()->larger($size),
|
|
]),
|
|
default => null,
|
|
},
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function entitySortCriteria(ISort $sort): array
|
|
{
|
|
$criteria = [];
|
|
|
|
foreach ($sort->conditions() as $condition) {
|
|
$attribute = $condition['attribute'] ?? '';
|
|
$key = match ($attribute) {
|
|
'from' => 'FROM',
|
|
'to' => 'TO',
|
|
'subject' => 'SUBJECT',
|
|
'received' => 'ARRIVAL',
|
|
'sent' => 'DATE',
|
|
'size' => 'SIZE',
|
|
default => null,
|
|
};
|
|
|
|
if ($key === null) {
|
|
return [];
|
|
}
|
|
|
|
$criteria[] = !($condition['direction'] ?? true) ? 'REVERSE ' . $key : $key;
|
|
}
|
|
|
|
return $criteria;
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $uids
|
|
* @return list<int>
|
|
*/
|
|
private function entitySortClientSide(array $uids, ISort $sort): array
|
|
{
|
|
$options = FetchOptions::summary();
|
|
foreach ($sort->conditions() as $condition) {
|
|
if (in_array($condition['attribute'] ?? '', ['from', 'to', 'subject', 'sent'], true)) {
|
|
$options = $options->withEnvelope();
|
|
break;
|
|
}
|
|
}
|
|
|
|
$messages = iterator_to_array($this->client->perform(new FetchManyCommand(
|
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
|
$options,
|
|
)));
|
|
|
|
usort($messages, function (Message $left, Message $right) use ($sort): int {
|
|
foreach ($sort->conditions() as $condition) {
|
|
$direction = ($condition['direction'] ?? true) ? 1 : -1;
|
|
$comparison = $this->entityCompareMessages($left, $right, $condition['attribute'] ?? '');
|
|
if ($comparison !== 0) {
|
|
return $comparison * $direction;
|
|
}
|
|
}
|
|
|
|
return $left->uid() <=> $right->uid();
|
|
});
|
|
|
|
return array_values(array_map(
|
|
static fn (Message $message): int => $message->uid(),
|
|
$messages,
|
|
));
|
|
}
|
|
|
|
private function entityCompareMessages(Message $left, Message $right, string $attribute): int
|
|
{
|
|
return match ($attribute) {
|
|
'from' => $this->entityPrimaryAddressValue($left->from()) <=> $this->entityPrimaryAddressValue($right->from()),
|
|
'to' => $this->entityPrimaryAddressValue($left->to()) <=> $this->entityPrimaryAddressValue($right->to()),
|
|
'subject' => $this->entityScalarValue($left->subject()) <=> $this->entityScalarValue($right->subject()),
|
|
'received' => $this->entityTimestampValue($left->internalDate()) <=> $this->entityTimestampValue($right->internalDate()),
|
|
'sent' => $this->entityTimestampValue($left->sentAt()) <=> $this->entityTimestampValue($right->sentAt()),
|
|
'size' => $left->size() <=> $right->size(),
|
|
default => 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param list<int> $uids
|
|
* @return list<int>
|
|
*/
|
|
private function entityApplyRange(array $uids, ?IRange $range): array
|
|
{
|
|
if (!$range instanceof IRangeTally) {
|
|
return array_values($uids);
|
|
}
|
|
|
|
$tally = max(0, $range->getTally());
|
|
if ($tally === 0) {
|
|
return [];
|
|
}
|
|
|
|
$start = 0;
|
|
if ($range->getAnchor() === RangeAnchorType::ABSOLUTE) {
|
|
$start = max(0, (int) $range->getPosition());
|
|
} else {
|
|
$anchor = (int) $range->getPosition();
|
|
$index = array_search($anchor, $uids, true);
|
|
$start = $index === false ? 0 : $index;
|
|
}
|
|
|
|
return array_values(array_slice($uids, $start, $tally));
|
|
}
|
|
|
|
/**
|
|
* @param list<SearchCriteriaBuilder> $criteria
|
|
*/
|
|
private function entityOrCriteria(array $criteria): SearchCriteriaBuilder
|
|
{
|
|
$expression = array_shift($criteria);
|
|
if ($expression === null) {
|
|
return SearchCriteriaBuilder::create()->all();
|
|
}
|
|
|
|
foreach ($criteria as $criterion) {
|
|
$expression = SearchCriteriaBuilder::create()->or($expression, $criterion);
|
|
}
|
|
|
|
return $expression;
|
|
}
|
|
|
|
/**
|
|
* @param list<SearchCriteriaBuilder> $criteria
|
|
*/
|
|
private function entityAndCriteria(array $criteria): SearchCriteriaBuilder
|
|
{
|
|
$expression = SearchCriteriaBuilder::create();
|
|
|
|
foreach ($criteria as $criterion) {
|
|
$expression->group($criterion);
|
|
}
|
|
|
|
return $expression;
|
|
}
|
|
|
|
private function normalizeImapDate(mixed $value): string
|
|
{
|
|
if ($value instanceof DateTimeImmutable) {
|
|
return $value->format('d-M-Y');
|
|
}
|
|
|
|
$stringValue = trim((string) $value);
|
|
if ($stringValue === '') {
|
|
throw new ImapException('Date filter values must not be empty.');
|
|
}
|
|
|
|
try {
|
|
return (new DateTimeImmutable($stringValue))->format('d-M-Y');
|
|
} catch (\Exception) {
|
|
return $stringValue;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param list<MessageAddress> $addresses
|
|
*/
|
|
private function entityPrimaryAddressValue(array $addresses): string
|
|
{
|
|
$address = $addresses[0] ?? null;
|
|
if ($address === null) {
|
|
return '';
|
|
}
|
|
|
|
return strtolower(trim((string) ($address->email() ?? $address->name() ?? '')));
|
|
}
|
|
|
|
private function entityScalarValue(?string $value): string
|
|
{
|
|
return strtolower(trim((string) $value));
|
|
}
|
|
|
|
private function entityTimestampValue(?string $value): int
|
|
{
|
|
if ($value === null || trim($value) === '') {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
return (new DateTimeImmutable($value))->getTimestamp();
|
|
} catch (\Exception) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private function mailboxFilter(Mailbox $mailbox, IFilter $filter): bool
|
|
{
|
|
$result = null;
|
|
|
|
foreach ($filter->conditions() as $condition) {
|
|
$attribute = $condition['attribute'] ?? '';
|
|
if (!in_array($attribute, self::COLLECTION_FILTER_OPTIONS, true)) {
|
|
continue;
|
|
}
|
|
|
|
$matches = match ($condition['attribute'] ?? '') {
|
|
'name' => $this->mailboxFilterByName($mailbox, $condition),
|
|
'role' => $this->mailboxFilterByRole($mailbox, $condition),
|
|
'subscription' => $this->mailboxFilterBySubscription($mailbox, $condition),
|
|
default => false,
|
|
};
|
|
if ($result === null) {
|
|
$result = $matches;
|
|
continue;
|
|
}
|
|
|
|
$result = ($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR
|
|
? ($result || $matches)
|
|
: ($result && $matches);
|
|
}
|
|
|
|
return $result ?? true;
|
|
}
|
|
|
|
/**
|
|
* @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition
|
|
*/
|
|
private function mailboxFilterByName(Mailbox $mailbox, array $condition): bool
|
|
{
|
|
$actualValue = $mailbox->name();
|
|
$expectedValue = $condition['value'];
|
|
$comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ;
|
|
|
|
return match ($comparator) {
|
|
FilterComparisonOperator::EQ => $actualValue === $expectedValue,
|
|
FilterComparisonOperator::NEQ => $actualValue !== $expectedValue,
|
|
FilterComparisonOperator::IN => is_array($expectedValue) && in_array($actualValue, $expectedValue, true),
|
|
FilterComparisonOperator::NIN => is_array($expectedValue) && !in_array($actualValue, $expectedValue, true),
|
|
FilterComparisonOperator::LIKE => preg_match('/' . preg_quote((string) $expectedValue, '/') . '/i', $actualValue) === 1,
|
|
FilterComparisonOperator::NLIKE => preg_match('/' . preg_quote((string) $expectedValue, '/') . '/i', $actualValue) !== 1,
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition
|
|
*/
|
|
private function mailboxFilterByRole(Mailbox $mailbox, array $condition): bool
|
|
{
|
|
$actualValue = $this->mailboxRole($mailbox);
|
|
$expectedValue = $condition['value'];
|
|
$comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ;
|
|
|
|
return match ($comparator) {
|
|
FilterComparisonOperator::EQ => $actualValue === $expectedValue,
|
|
FilterComparisonOperator::NEQ => $actualValue !== $expectedValue,
|
|
FilterComparisonOperator::IN => is_array($expectedValue) && in_array($actualValue, $expectedValue, true),
|
|
FilterComparisonOperator::NIN => is_array($expectedValue) && !in_array($actualValue, $expectedValue, true),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition
|
|
*/
|
|
private function mailboxFilterBySubscription(Mailbox $mailbox, array $condition): bool
|
|
{
|
|
$actualValue = in_array('\\SUBSCRIBED', $mailbox->attributes(), true) || in_array('\\Subscribed', $mailbox->attributes(), true);
|
|
$expectedValue = $condition['value'];
|
|
$comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ;
|
|
|
|
return match ($comparator) {
|
|
FilterComparisonOperator::EQ => $actualValue === $expectedValue,
|
|
FilterComparisonOperator::NEQ => $actualValue !== $expectedValue,
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
private function mailboxRole(Mailbox $mailbox): string
|
|
{
|
|
foreach ($mailbox->attributes() as $attribute) {
|
|
$role = match (strtolower($attribute)) {
|
|
'\\sent' => CollectionRoles::Sent,
|
|
'\\trash' => CollectionRoles::Trash,
|
|
'\\drafts' => CollectionRoles::Drafts,
|
|
'\\junk' => CollectionRoles::Junk,
|
|
'\\archive' => CollectionRoles::Archive,
|
|
default => null,
|
|
};
|
|
|
|
if ($role !== null) {
|
|
return $role->value;
|
|
}
|
|
}
|
|
|
|
return CollectionRoles::None->value;
|
|
}
|
|
}
|