feat: serve message lists from the cache

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-10-07 22:15:34 -04:00
parent 852ead324f
commit b1e92e5b07
9 changed files with 800 additions and 5 deletions
+233
View File
@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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<string, int>, 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'));
}
}