* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Service\Cache; use KTXM\ProviderImap\Providers\CollectionResource; use KTXM\ProviderImap\Providers\Service; use KTXM\ProviderImap\Service\Live\LiveMailService; use KTXM\ProviderImap\Stores\MailboxStore; use KTXM\ProviderImap\Stores\MessageFileStore; use KTXM\ProviderImap\Stores\MessageStore; use LogicException; /** * Brings the cache of one service in line with its IMAP server. * * Created by the container without a service; call for() to bind one. Only ever * talks to LiveMailService, never to the cache it maintains. */ class HarmonizationService { private ?Service $service = null; private ?LiveMailService $live = null; public function __construct( private readonly MailboxStore $mailboxStore, private readonly MessageStore $messageStore, private readonly MessageFileStore $fileStore, ) {} /** * Bind a service; returns a new instance so the shared one stays unbound. */ public function for(Service $service, ?LiveMailService $live = null): static { $bound = clone $this; $bound->service = $service; $bound->live = $live ?? new LiveMailService($service); return $bound; } /** * Harmonize the mailbox list of the service with the server. * * New mailboxes are added (not yet harmonized), existing ones updated, and * mailboxes gone from the server purged with their messages. A rename shows * up as one mailbox gone and one new. * * @return array{added: string[], updated: string[], removed: string[]} */ public function harmonizeMailboxes(): array { [$service, $live] = $this->bound(); $tenantId = (string) $service->tenantIdentifier(); $serviceId = (string) $service->identifier(); $remote = []; foreach ($live->collectionList() as $name => $mailbox) { $remote[(string) $name] = (new CollectionResource($service->provider(), $serviceId))->fromImap($mailbox); } $cached = $this->mailboxStore->list($serviceId); $result = ['added' => [], 'updated' => [], 'removed' => []]; foreach ($remote as $name => $collection) { $this->mailboxStore->upsert($tenantId, $serviceId, $collection); $result[isset($cached[$name]) ? 'updated' : 'added'][] = $name; } // every IMAP account has an INBOX; a list without one is incomplete, so nothing is purged if (!self::containsInbox(array_keys($remote))) { return $result; } foreach (array_keys(array_diff_key($cached, $remote)) as $name) { $this->purgeMailbox($tenantId, $serviceId, (string) $name); $result['removed'][] = (string) $name; } return $result; } /** * Remove a mailbox and everything cached for it: meta documents first, then files, then the mailbox. */ private function purgeMailbox(string $tenantId, string $serviceId, string $name): void { $this->messageStore->deleteByMailbox($serviceId, $name); $this->fileStore->deleteByMailbox($tenantId, $serviceId, $name); $this->mailboxStore->delete($serviceId, $name); } /** * @return array{0: Service, 1: LiveMailService} */ private function bound(): array { if ($this->service === null || $this->live === null) { throw new LogicException('HarmonizationService is not bound to a service; call for() first'); } return [$this->service, $this->live]; } /** * @param string[] $names */ private static function containsInbox(array $names): bool { foreach ($names as $name) { if (strcasecmp($name, 'INBOX') === 0) { return true; } } return false; } }