* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Stores; use KTXC\Db\DataStore; use MongoDB\Operation\FindOneAndUpdate; use RuntimeException; use KTXM\ProviderImap\Providers\CollectionResource; /** * IMAP Mailbox Meta Store * * One MongoDB document per cached mailbox in `provider_imap_mail_mailboxes`, * keyed by (sid, name). The document holds the mailbox itself and its * harmonization state. */ class MailboxStore { protected const COLLECTION_NAME = 'provider_imap_mail_mailboxes'; /** Harmonization state fields and their defaults for a mailbox that was never harmonized */ public const STATE_DEFAULTS = [ 'uidValidity' => null, 'uidNext' => null, 'highestModSeq' => null, 'changeSeq' => 0, 'purgedSeq' => 0, 'harmonizedAt' => null, 'harmonizationComplete' => false, ]; public function __construct( protected readonly DataStore $dataStore, ) {} /** * Create the collection indexes. * * MongoDB createIndex is idempotent when the name and specification match. * * @return string[] */ public function ensureIndexes(): array { $collection = $this->dataStore->selectCollection(self::COLLECTION_NAME); return [ $collection->createIndex( ['sid' => 1, 'name' => 1], ['name' => 'mailboxes_key', 'unique' => true] ), $collection->createIndex( ['tid' => 1, 'sid' => 1], ['name' => 'mailboxes_by_tenant_service'] ), ]; } /** * Insert or update the mailbox fields of a document. * * Harmonization state on an existing document is left untouched; a new document * starts with the default (never harmonized) state. */ public function upsert(string $tenantId, string $serviceId, CollectionResource $collection): void { $document = $collection->toCacheMeta(); $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( ['sid' => $serviceId, 'name' => $document['name']], [ '$set' => ['tid' => $tenantId, 'sid' => $serviceId, ...$document], '$setOnInsert' => self::STATE_DEFAULTS, ], ['upsert' => true], ); } /** * Retrieve the document of a mailbox. */ public function fetch(string $serviceId, string $name): ?array { return $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([ 'sid' => $serviceId, 'name' => $name, ]); } /** * List the documents of all mailboxes of a service. * * @return array keyed by mailbox name */ public function list(string $serviceId): array { $cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['sid' => $serviceId]); $list = []; foreach ($cursor as $document) { $list[(string) $document['name']] = $document; } return $list; } // ── Harmonization state ────────────────────────────────────────────────── /** * Harmonization state of a mailbox; defaults when the mailbox is not cached. * * @return array{uidValidity: ?int, uidNext: ?int, highestModSeq: ?int, changeSeq: int, purgedSeq: int, harmonizedAt: ?int, harmonizationComplete: bool} */ public function state(string $serviceId, string $name): array { $document = $this->fetch($serviceId, $name) ?? []; return array_replace(self::STATE_DEFAULTS, array_intersect_key($document, self::STATE_DEFAULTS)); } /** * Update harmonization state fields of a mailbox; keys outside the state are ignored. */ public function updateState(string $serviceId, string $name, array $state): void { $state = array_intersect_key($state, self::STATE_DEFAULTS); if ($state === []) { return; } $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( ['sid' => $serviceId, 'name' => $name], ['$set' => $state], ); } /** * Reserve a range of change sequence numbers for a mailbox. * * Atomically advances the mailbox's changeSeq by $count and returns the first * number of the reserved range (range = first .. first + count - 1). * * @throws RuntimeException when the mailbox is not cached */ public function reserveSequence(string $serviceId, string $name, int $count = 1): int { $count = max(1, $count); $document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->getMongoCollection()->findOneAndUpdate( ['sid' => $serviceId, 'name' => $name], ['$inc' => ['changeSeq' => $count]], [ 'projection' => ['changeSeq' => 1], 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, ], ); if ($document === null) { throw new RuntimeException("Mailbox is not cached: {$name}"); } return (int) ((array) $document)['changeSeq'] - $count + 1; } /** * Take the harmonization lock of a mailbox when it is free or expired. * * Never waits: returns false when another owner holds a valid lock or the * mailbox is not cached. */ public function acquireLock(string $serviceId, string $name, string $owner, int $ttl): bool { $now = time(); $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( [ 'sid' => $serviceId, 'name' => $name, '$or' => [ ['lock' => null], ['lock.expiresAt' => ['$lte' => $now]], ['lock.owner' => $owner], ], ], ['$set' => ['lock' => ['owner' => $owner, 'expiresAt' => $now + $ttl]]], ); return $result->getMatchedCount() === 1; } /** * Release the harmonization lock of a mailbox, if still held by the owner. */ public function releaseLock(string $serviceId, string $name, string $owner): void { $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne( ['sid' => $serviceId, 'name' => $name, 'lock.owner' => $owner], ['$unset' => ['lock' => '']], ); } // ── Deletion ───────────────────────────────────────────────────────────── /** * Delete the document of a mailbox. */ public function delete(string $serviceId, string $name): void { $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ 'sid' => $serviceId, 'name' => $name, ]); } /** * Delete the documents of all mailboxes of a service. */ public function deleteByService(string $serviceId): void { $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteMany(['sid' => $serviceId]); } }