feat: implement meta and content cache

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-09-29 19:45:00 -04:00
parent e5e76351a5
commit d28800a04e
8 changed files with 715 additions and 22 deletions
+5 -5
View File
@@ -16,8 +16,8 @@ use KTXF\Mail\Collection\CollectionRoles;
/**
* IMAP Mail Collection Properties
*
* Backed by the same internal $data shape as the JMAP provider so that cache
* documents are interchangeable with fromStore() / toStore().
* Backed by the same internal $data shape as the JMAP provider so that
* cache documents are interchangeable with fromCacheMeta() / toCacheMeta().
*/
class CollectionProperties extends CollectionPropertiesMutableAbstract
{
@@ -50,14 +50,14 @@ class CollectionProperties extends CollectionPropertiesMutableAbstract
return $this;
}
// ── Store (MongoDB cache) ────────────────────────────────────────────────
// ── Cache (meta store) ───────────────────────────────────────────────────
public function toStore(): array
public function toCacheMeta(): array
{
return $this->data;
}
public function fromStore(array $data): static
public function fromCacheMeta(array $data): static
{
$this->data = $data;
return $this;
+23 -14
View File
@@ -54,29 +54,38 @@ class CollectionResource extends CollectionMutableAbstract
return $this;
}
// ── Store (MongoDB cache) ────────────────────────────────────────────────
// ── Cache (meta store) ───────────────────────────────────────────────────
/**
* Serialise to a MongoDB document.
* Serialise to a meta store document.
*
* The caller must inject the service UUID as `sid` before persisting.
* The store adds the key fields it owns (sid, tid).
*/
public function toStore(): array
public function toCacheMeta(): array
{
return array_merge(
$this->data,
[
'name' => $this->data['identifier'],
'properties' => $this->getProperties()->toStore(),
],
);
return [
'name' => (string) $this->data[static::PROPERTY_IDENTIFIER],
'parent' => $this->data[static::PROPERTY_COLLECTION] ?? null,
'signature' => $this->data[static::PROPERTY_SIGNATURE] ?? null,
'properties' => $this->getProperties()->toCacheMeta(),
];
}
public function fromStore(array $data): static
/**
* Restore from a meta store document.
*
* Only the resource's own fields are read; store keys and harmonization state
* on the same document are ignored.
*/
public function fromCacheMeta(array $data): static
{
$this->data = $data;
$this->data[static::PROPERTY_IDENTIFIER] = (string) $data['name'];
$this->data[static::PROPERTY_COLLECTION] = $data['parent'] ?? null;
if (isset($data['signature'])) {
$this->data[static::PROPERTY_SIGNATURE] = $data['signature'];
}
if (isset($data['properties'])) {
$this->getProperties()->fromStore($data['properties']);
$this->getProperties()->fromCacheMeta((array) $data['properties']);
}
return $this;
}
+66
View File
@@ -18,6 +18,9 @@ use KTXF\Mail\Object\MessagePropertiesMutableInterface;
*/
class EntityResource extends EntityMutableAbstract {
/** Version of the meta store / content store formats; bump on any change to either */
public const CACHE_SCHEMA_VERSION = 1;
public function __construct(
string $provider = 'imap',
string|int|null $service = null,
@@ -63,6 +66,69 @@ class EntityResource extends EntityMutableAbstract {
return $this;
}
// ── Cache (meta store / content store) ───────────────────────────────────
/**
* Serialise to a meta store document.
*
* The store adds the key fields it owns (sid, tid, uidValidity).
*/
public function toCacheMeta(): array
{
return [
'mailbox' => (string) $this->data['collection'],
'uid' => (int) $this->data['identifier'],
'created' => $this->data['created'] ?? null,
'schemaVersion' => self::CACHE_SCHEMA_VERSION,
...$this->getProperties()->toCacheMeta(),
];
}
/**
* Restore identity and mutable fields (flags) from a meta store document.
*/
public function fromCacheMeta(array $document): static
{
$this->data['collection'] = (string) $document['mailbox'];
$this->data['identifier'] = (int) $document['uid'];
$this->getProperties()->fromCacheMeta($document);
return $this;
}
/**
* Serialise the immutable message content for the content store (message.json).
*/
public function toCacheContent(): array
{
return [
'schemaVersion' => self::CACHE_SCHEMA_VERSION,
'created' => $this->data['created'] ?? null,
'properties' => $this->getProperties()->toCacheContent(),
];
}
/**
* Restore the immutable message content from the content store (message.json).
*
* @throws \UnexpectedValueException when the content was written with a different schema version
*/
public function fromCacheContent(array $content): static
{
if (($content['schemaVersion'] ?? null) !== self::CACHE_SCHEMA_VERSION) {
throw new \UnexpectedValueException('Cached message content has an unsupported schema version');
}
if (isset($content['created'])) {
$this->data['created'] = $content['created'];
}
$this->getProperties()->fromCacheContent($content['properties'] ?? []);
return $this;
}
/**
* @inheritDoc
*/
+104 -3
View File
@@ -35,9 +35,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
$this->data[static::PROPERTY_IN_REPLY_TO] = $message->inReplyTo();
}
//if ($message->references() !== []) {
// $this->data[static::PROPERTY_REFERENCES] = $message->references();
//}
if ($message->references() !== []) {
$this->data[static::PROPERTY_REFERENCES] = $message->references();
}
$receivedAt = $message->receivedAt() ?? $message->internalDate();
if ($receivedAt !== null) {
@@ -109,6 +109,107 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
return $this;
}
// ── Cache (meta store / content store) ───────────────────────────────────
/**
* Serialise the fields the meta store needs for list, filter and sort.
*
* Dates are normalised to UTC so they sort correctly as strings; the original
* values (with their offsets) are kept in the content store.
*/
public function toCacheMeta(): array
{
return [
'received' => self::cacheDate($this->data[static::PROPERTY_RECEIVED] ?? null),
'sent' => self::cacheDate($this->data[static::PROPERTY_SENT] ?? null),
'size' => $this->data[static::PROPERTY_SIZE] ?? 0,
'subject' => $this->data[static::PROPERTY_SUBJECT] ?? '',
'from' => $this->data[static::PROPERTY_FROM] ?? null,
'to' => $this->data[static::PROPERTY_TO] ?? [],
'cc' => $this->data[static::PROPERTY_CC] ?? [],
'urid' => $this->data[static::PROPERTY_URID] ?? null,
'inReplyTo' => $this->data[static::PROPERTY_IN_REPLY_TO] ?? null,
'references' => $this->data[static::PROPERTY_REFERENCES] ?? [],
// list of set flags, not a map: keywords such as $Forwarded are not valid field names
'flags' => array_keys(array_filter($this->data[static::PROPERTY_FLAGS] ?? [])),
'hasAttachments' => ($this->data[static::PROPERTY_ATTACHMENTS] ?? []) !== [],
'preview' => $this->cachePreview(),
];
}
/**
* Restore the mutable fields from a meta store document.
*
* Only flags are taken from the meta store; everything else comes from the
* content store, which holds the original values.
*/
public function fromCacheMeta(array $document): static
{
$this->data[static::PROPERTY_FLAGS] = array_fill_keys(
array_map('strval', (array) ($document['flags'] ?? [])),
true,
);
return $this;
}
/**
* Serialise the immutable message content for the content store.
*/
public function toCacheContent(): array
{
$content = $this->data;
unset($content[static::PROPERTY_FLAGS]);
return $content;
}
/**
* Restore the immutable message content from the content store, keeping any flags already set.
*/
public function fromCacheContent(array $content): static
{
unset($content[static::PROPERTY_FLAGS]);
$flags = $this->data[static::PROPERTY_FLAGS] ?? null;
$this->data = $content;
if ($flags !== null) {
$this->data[static::PROPERTY_FLAGS] = $flags;
}
return $this;
}
private function cachePreview(int $length = 200): string
{
$text = $this->getBodyTextPlain();
if ($text === null) {
$html = $this->getBodyTextHtml();
$text = $html !== null
? html_entity_decode(strip_tags((string) preg_replace('/<(style|script)\b[^>]*>.*?<\/\1>/is', ' ', $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')
: '';
}
$text = trim((string) preg_replace('/\s+/u', ' ', $text));
return mb_substr($text, 0, $length);
}
private static function cacheDate(?string $value): ?string
{
if ($value === null || $value === '') {
return null;
}
try {
return (new DateTimeImmutable($value))
->setTimezone(new \DateTimeZone('UTC'))
->format('Y-m-d\TH:i:s\Z');
} catch (\Exception) {
return null;
}
}
/**
* Recursively collect attachment parts from body structure
*/