* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Service\Cache; use KTXM\ProviderImap\Providers\CollectionResource; use KTXM\ProviderImap\Providers\EntityResource; use KTXM\ProviderImap\Service\Remote\RemoteMailService; use KTXM\ProviderImap\Stores\MessageStore; /** * Cache Service — IMAP Sync Orchestrator * * Keeps the local MongoDB cache in sync with a remote IMAP server. * * Strategy (UID-based): * 1. Fetch the complete remote UID set. * 2. Compare against cached UIDs. * 3. Fetch and store new UIDs; remove stale UIDs from the cache. * * This is always a full-range UID comparison. A CONDSTORE/HIGHESTMODSEQ * strategy can be layered on later for servers that support RFC 7162. */ class CacheService { public function __construct( private readonly RemoteMailService $remoteMailService, private readonly MessageStore $messageStore, private readonly string $provider, private readonly string|int $service, ) {} // ── Mailbox sync ────────────────────────────────────────────────────────── /** * Synchronise the mailbox list for this service. * * Inserts/updates every selectable mailbox returned by the remote server * and removes cached mailboxes that no longer exist remotely. * * @return array{added: string[], removed: string[]} */ public function syncMailboxes(): array { $remoteCollections = $this->remoteMailService->collectionList(); $added = []; $removed = []; // Upsert every remote mailbox foreach ($remoteCollections as $name => $resource) { $doc = array_merge( $resource->toStore(), ['sid' => (string) $this->service], ); $this->messageStore->upsertMailbox($doc); $added[] = $name; } // Remove cached mailboxes that no longer exist on the server $cached = $this->messageStore->listMailboxes((string) $this->service); foreach ($cached as $cachedDoc) { $name = $cachedDoc['name'] ?? ($cachedDoc['identifier'] ?? null); if ($name === null) { continue; } if (!isset($remoteCollections[$name])) { $this->messageStore->deleteMailbox((string) $this->service, $name); $removed[] = $name; } } return ['added' => $added, 'removed' => $removed]; } // ── Message sync ────────────────────────────────────────────────────────── /** * Synchronise all messages in one mailbox. * * Fetches new UIDs from the remote server, stores them in MongoDB, and * removes UIDs from the cache that no longer exist on the server. * * @return array{added: int[], removed: int[]} */ public function syncMessages(string $mailbox): array { $remoteUids = $this->remoteMailService->entityList($mailbox); $cachedUids = $this->messageStore->listUids((string) $this->service, $mailbox); $remoteSet = array_fill_keys($remoteUids, true); $cachedSet = array_fill_keys($cachedUids, true); $newUids = array_keys(array_diff_key($remoteSet, $cachedSet)); $removedUids = array_keys(array_diff_key($cachedSet, $remoteSet)); // Stream-fetch new messages one at a time and store each foreach ($this->remoteMailService->entitySyncStream($mailbox, $newUids) as $uid => $resource) { /** @var EntityResource $resource */ $doc = array_merge( $resource->toStore(), [ 'sid' => (string) $this->service, 'mailbox' => $mailbox, 'uid' => $uid, ], ); $this->messageStore->upsertMessage($doc); } // Purge stale UIDs from cache if (!empty($removedUids)) { $this->messageStore->deleteMessages((string) $this->service, $mailbox, $removedUids); } return [ 'added' => $newUids, 'removed' => $removedUids, ]; } /** * Perform a full sync: mailboxes first, then all messages in each selectable mailbox. * * @return array{mailboxes: array, messages: array} */ public function syncAll(): array { $mailboxResult = $this->syncMailboxes(); $messagesResults = []; foreach ($this->remoteMailService->collectionList() as $name => $collection) { try { $messagesResults[$name] = $this->syncMessages($name); } catch (\Throwable $e) { $messagesResults[$name] = ['error' => $e->getMessage()]; } } return [ 'mailboxes' => $mailboxResult, 'messages' => $messagesResults, ]; } // ── Partial helpers ─────────────────────────────────────────────────────── /** * Sync only the flags of already-cached messages in a mailbox. * * This is cheaper than a full message sync: it fetches FLAGS only from * the remote server and updates cached documents in place. */ public function syncFlags(string $mailbox): void { $cachedUids = $this->messageStore->listUids((string) $this->service, $mailbox); if (empty($cachedUids)) { return; } // Fetch only FLAGS + UID from remote $items = ['FLAGS', 'UID']; foreach ($this->remoteMailService->entitySyncStream($mailbox, $cachedUids, $items) as $uid => $resource) { /** @var EntityResource $resource */ $existing = $this->messageStore->fetchMessage((string) $this->service, $mailbox, $uid); if ($existing === null) { continue; } // Merge updated flags into the cached document $existingProperties = $existing['properties'] ?? []; $updatedProperties = $resource->toStore()['properties'] ?? []; // Only overwrite flag-related fields foreach (['read', 'flagged', 'answered', 'draft', 'deleted', 'junk'] as $field) { if (isset($updatedProperties[$field])) { $existingProperties[$field] = $updatedProperties[$field]; } } $existing['properties'] = $existingProperties; $this->messageStore->upsertMessage($existing); } } }