Files
2026-10-05 19:43:47 -04:00

198 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderImap\Stores;
use KTXC\Db\DataStore;
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,
'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<string, 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, 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],
);
}
/**
* 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]);
}
}