* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Service\Cache; use DateTimeImmutable; use DateTimeZone; use KTXF\Resource\Filter\FilterComparisonOperator; use KTXF\Resource\Filter\FilterConjunctionOperator; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Sort\ISort; use MongoDB\BSON\Regex; /** * Translates entity list filters and sorts into meta store queries. * * Mirrors LiveMailService's IMAP SEARCH / SORT translation so a cached list matches a * live one: conditions are combined left to right with their conjunction, strings * match as case-insensitive substrings, dates compare whole (UTC) days on `received`, * sizes compare `size`. Conditions the cache cannot answer (body text, full text) * make the whole filter a server filter. */ class MessageQueryBuilder { /** Attributes that need the message body, which the meta store does not hold */ private const SERVER_ATTRIBUTES = ['body', '*', 'all']; /** Address fields: matched on address and display label */ private const ADDRESS_ATTRIBUTES = ['from', 'to', 'cc', 'bcc']; /** Sorts on text fields use a case-insensitive collation */ private const TEXT_SORTS = ['subject', 'from', 'to']; /** * Whether the filter has to be evaluated by the server (IMAP SEARCH). */ public function needsServer(?IFilter $filter): bool { foreach ($filter?->conditions() ?? [] as $condition) { if (in_array($condition['attribute'] ?? '', self::SERVER_ATTRIBUTES, true)) { return true; } } return false; } /** * Meta store filter for a list filter; [] matches everything. * * Only valid when needsServer() is false. */ public function filter(?IFilter $filter): array { $expression = null; foreach ($filter?->conditions() ?? [] as $condition) { $operand = $this->operand($condition); if ($operand === null) { continue; } if ($expression === null) { $expression = $operand; continue; } $expression = (($condition['conjunction'] ?? FilterConjunctionOperator::AND) === FilterConjunctionOperator::OR) ? ['$or' => [$expression, $operand]] : ['$and' => [$expression, $operand]]; } return $expression ?? []; } /** * Meta store sort for a list sort; always ends with uid so pages are stable. * * @return array{sort: array, collation: ?array} collation is set when a text field is sorted */ public function sort(?ISort $sort): array { $order = []; $text = false; foreach ($sort?->conditions() ?? [] as $condition) { $attribute = $condition['attribute'] ?? ''; $field = match ($attribute) { 'from' => 'from.address', 'to' => 'to.0.address', 'subject' => 'subject', 'received' => 'received', 'sent' => 'sent', 'size' => 'size', default => null, }; if ($field === null) { continue; } $order[$field] = ($condition['direction'] ?? true) ? 1 : -1; $text = $text || in_array($attribute, self::TEXT_SORTS, true); } $order['uid'] ??= 1; return ['sort' => $order, 'collation' => $text ? ['locale' => 'en', 'strength' => 2] : null]; } /** * @param array{attribute:string, value:mixed, comparator?:FilterComparisonOperator, conjunction?:FilterConjunctionOperator|null} $condition */ private function operand(array $condition): ?array { $attribute = $condition['attribute'] ?? ''; $value = $condition['value'] ?? null; $comparator = $condition['comparator'] ?? FilterComparisonOperator::EQ; return match (true) { $attribute === 'subject', in_array($attribute, self::ADDRESS_ATTRIBUTES, true) => $this->stringOperand($attribute, $value, $comparator), $attribute === 'before', $attribute === 'after' => $this->dateOperand($attribute, $value, $comparator), $attribute === 'min', $attribute === 'max' => $this->sizeOperand($attribute, $value, $comparator), default => null, }; } private function stringOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array { $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; } $matches = array_map(fn (string $item): array => $this->stringMatch($attribute, $item), $values); return match ($comparator) { FilterComparisonOperator::EQ, FilterComparisonOperator::LIKE, FilterComparisonOperator::IN => count($matches) === 1 ? $matches[0] : ['$or' => $matches], FilterComparisonOperator::NEQ, FilterComparisonOperator::NLIKE, FilterComparisonOperator::NIN => ['$nor' => $matches], default => null, }; } private function stringMatch(string $attribute, string $value): array { $regex = new Regex(preg_quote($value, '/'), 'i'); if ($attribute === 'subject') { return ['subject' => $regex]; } return ['$or' => [[$attribute . '.address' => $regex], [$attribute . '.label' => $regex]]]; } private function dateOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array { $day = $this->day($value); if ($day === null) { return null; } $start = $day->format('Y-m-d\TH:i:s\Z'); $end = $day->modify('+1 day')->format('Y-m-d\TH:i:s\Z'); $on = ['received' => ['$gte' => $start, '$lt' => $end]]; return match ($comparator) { FilterComparisonOperator::EQ => $on, FilterComparisonOperator::NEQ => ['$nor' => [$on]], FilterComparisonOperator::LT, FilterComparisonOperator::LTE => $attribute === 'before' ? ['received' => ['$lt' => $start]] : null, FilterComparisonOperator::GT, FilterComparisonOperator::GTE => $attribute === 'after' ? ['received' => ['$gte' => $start]] : null, default => null, }; } private function sizeOperand(string $attribute, mixed $value, FilterComparisonOperator $comparator): ?array { if (!is_int($value) && !is_numeric($value)) { return null; } $size = max(0, (int) $value); return match ($attribute) { 'min' => match ($comparator) { FilterComparisonOperator::EQ, FilterComparisonOperator::GTE => ['size' => ['$gte' => $size]], FilterComparisonOperator::GT => ['size' => ['$gt' => $size]], FilterComparisonOperator::LT => ['size' => ['$lt' => $size]], FilterComparisonOperator::LTE => ['size' => ['$lte' => $size]], FilterComparisonOperator::NEQ => ['size' => ['$ne' => $size]], default => null, }, 'max' => match ($comparator) { FilterComparisonOperator::EQ, FilterComparisonOperator::LTE => ['size' => ['$lte' => $size]], FilterComparisonOperator::LT => ['size' => ['$lt' => $size]], FilterComparisonOperator::GT => ['size' => ['$gt' => $size]], FilterComparisonOperator::GTE => ['size' => ['$gte' => $size]], FilterComparisonOperator::NEQ => ['size' => ['$ne' => $size]], default => null, }, default => null, }; } /** * Start of the (UTC) day a filter value falls on. */ private function day(mixed $value): ?DateTimeImmutable { if ($value === null || $value === '') { return null; } try { $date = $value instanceof DateTimeImmutable ? $value : new DateTimeImmutable(trim((string) $value)); } catch (\Exception) { return null; } return new DateTimeImmutable($date->format('Y-m-d'), new DateTimeZone('UTC')); } }