Files
provider_imap/lib/Service/Remote/RemoteMailService.php
2026-05-28 23:24:23 -04:00

1035 lines
38 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\FetchOneCommand;
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 KTXF\Resource\BinaryResource;
/**
* 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 = $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 = $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
{
$options = FetchOptions::message()->withBodyText();
// fast path: fetch all messages without filtering, sorting or pagination
if ($filter === null && $sort === null && $range === null) {
$mailbox = $this->client->perform(new SelectCommand($collection, true));
if ($mailbox === null) {
return [];
}
yield from $this->client->perform(new FetchManyCommand(
FetchTarget::all(),
$options,
));
return;
}
// find all the UIDs matching the filter
$uids = $this->entityFind($collection, $filter, $sort, $range);
if (empty($uids)) {
return [];
}
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::message()->withBodyText();
$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;
}
}
/**
* Stream the raw bytes of a message or a specific MIME part without buffering.
*
* When $partId is given, first fetches BODYSTRUCTURE to determine the
* correct filename and MIME type, then starts the streaming body fetch.
*
* @param string $collection Mailbox name
* @param int $uid Message UID
* @param string|null $partId MIME section (e.g. '1', '1.2'); null = full RFC 822
*/
public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource
{
$this->client->perform(new SelectCommand($collection, true));
$encoding = null;
if ($partId === null) {
$filename = 'message.eml';
$mimeType = 'message/rfc822';
} else {
// Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred)
$message = $this->client->perform(new FetchOneCommand(
FetchTarget::uid(SequenceSet::items($uid)),
FetchOptions::of('BODYSTRUCTURE'),
));
$bodyStructure = $message->bodyStructure();
$part = $bodyStructure !== null ? $this->findBodyPart($bodyStructure, $partId) : null;
$mimeType = $part?->mimeType() ?? 'application/octet-stream';
$partData = $part?->toArray() ?? [];
$filename = isset($partData['name']) && $partData['name'] !== ''
? $partData['name']
: "attachment-{$partId}";
$encoding = $part?->encoding();
}
// Start download stream
$stream = $this->decodeStream(
$this->client->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''),
$encoding
);
return new BinaryResource($filename, $mimeType, $stream);
}
private function findBodyPart(MessagePart $root, string $partId): ?MessagePart
{
if ($root->partId() === $partId) {
return $root;
}
foreach ($root->parts() as $child) {
$found = $this->findBodyPart($child, $partId);
if ($found !== null) {
return $found;
}
}
return null;
}
/**
* Wraps a raw IMAP body stream with a transfer-encoding decoder.
*
* IMAP BODY[n] literals are delivered in the transfer encoding declared
* by BODYSTRUCTURE (typically base64 or quoted-printable for attachments).
* 7bit / 8bit / binary sections pass through unchanged.
*/
private function decodeStream(\Generator $stream, ?string $encoding): \Generator
{
return match (strtolower($encoding ?? '')) {
'base64' => $this->decodeBase64Stream($stream),
'quoted-printable' => $this->decodeQpStream($stream),
default => $stream,
};
}
private function decodeBase64Stream(\Generator $source): \Generator
{
$buffer = '';
foreach ($source as $chunk) {
// IMAP folds base64 at 76 chars with CRLF — strip all whitespace
$buffer .= preg_replace('/\s+/', '', $chunk);
// Decode complete 4-character groups; keep any partial tail
$remainder = strlen($buffer) % 4;
$complete = strlen($buffer) - $remainder;
if ($complete > 0) {
yield base64_decode(substr($buffer, 0, $complete), true);
$buffer = substr($buffer, $complete);
}
}
// Flush remainder (handles padded or stripped trailing '=')
if ($buffer !== '') {
yield base64_decode($buffer, true);
}
}
private function decodeQpStream(\Generator $source): \Generator
{
// Buffer until we have complete lines so soft-line-breaks are intact
$buffer = '';
foreach ($source as $chunk) {
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n")) !== false) {
yield quoted_printable_decode(substr($buffer, 0, $pos + 1));
$buffer = substr($buffer, $pos + 1);
}
}
if ($buffer !== '') {
yield quoted_printable_decode($buffer);
}
}
/**
* 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));
$flagsToAdd = $this->normalizeFlags($flagsToAdd);
$flagsToRemove = $this->normalizeFlags($flagsToRemove);
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;
}
public function entityCopy(string $targetCollection, string $sourceCollection, int ...$uids): array
{
if (empty($uids)) {
return [];
}
$this->client->perform(new SelectCommand($sourceCollection, false));
$response = $this->client->perform(new CopyCommand(
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
$targetCollection,
));
if (!$response->isOk()) {
throw new ImapException('Failed to copy 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::default();
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->receivedAt() ?? $left->internalDate()) <=> $this->entityTimestampValue($right->receivedAt() ?? $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;
}
private function normalizeFlags(array $flags): array
{
$map = [
'read' => '\\Seen',
'answered' => '\\Answered',
'flagged' => '\\Flagged',
'deleted' => '\\Deleted',
'draft' => '\\Draft',
];
$normalized = [];
foreach ($flags as $flag) {
$flag = strtolower(trim($flag));
if (isset($map[$flag])) {
$normalized[] = $map[$flag];
}
}
return $normalized;
}
}