Files
provider_imap/lib/Service/Cache/HarmonizationService.php
T
2026-10-05 20:34:02 -04:00

269 lines
10 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\Service\Cache;
use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions;
use KTXM\ProviderImap\Providers\CollectionResource;
use KTXM\ProviderImap\Providers\EntityResource;
use KTXM\ProviderImap\Providers\MessageProperties;
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;
use RuntimeException;
/**
* 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
{
/** Messages fetched per UID FETCH when ingesting; each is cached as it streams in */
public const INGEST_BATCH_SIZE = 200;
/** Lifetime of a mailbox harmonization lock in seconds; long enough for a large first run */
public const LOCK_TTL = 900;
private ?Service $service = null;
private ?LiveMailService $live = null;
public function __construct(
private readonly MailboxStore $mailboxStore,
private readonly MessageStore $messageStore,
private readonly MessageFileStore $fileStore,
private readonly MessageIngestor $ingestor,
) {}
/**
* 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;
}
/**
* Harmonize the messages of one mailbox with the server.
*
* Ingests messages that are not cached yet (newest first), updates changed
* flags and removes messages expunged on the server. A changed UIDVALIDITY
* discards the mailbox's cache first. Never waits: when another run holds the
* mailbox lock, nothing is done.
*
* @return array{status: 'harmonized'|'skipped', reset: bool, added: int, updated: int, removed: int, complete: bool}
*/
public function harmonizeMessages(string $mailbox): array
{
[$service, $live] = $this->bound();
$tenantId = (string) $service->tenantIdentifier();
$serviceId = (string) $service->identifier();
$result = ['status' => 'skipped', 'reset' => false, 'added' => 0, 'updated' => 0, 'removed' => 0, 'complete' => false];
// the lock lives on the mailbox document, so it has to exist first
if ($this->mailboxStore->fetch($serviceId, $mailbox) === null) {
$this->harmonizeMailboxes();
if ($this->mailboxStore->fetch($serviceId, $mailbox) === null) {
throw new RuntimeException("Mailbox not found on server: {$mailbox}");
}
}
$owner = bin2hex(random_bytes(8));
if (!$this->mailboxStore->acquireLock($serviceId, $mailbox, $owner, self::LOCK_TTL)) {
return $result;
}
try {
$state = $this->mailboxStore->state($serviceId, $mailbox);
$selected = $live->collectionFetch($mailbox)
?? throw new RuntimeException("Mailbox not found on server: {$mailbox}");
$uidValidity = $selected->uidValidity()
?? throw new RuntimeException("Server did not report UIDVALIDITY for mailbox: {$mailbox}");
// a new UIDVALIDITY invalidates every cached UID of the mailbox
if ($state['uidValidity'] !== null && (int) $state['uidValidity'] !== $uidValidity) {
$this->messageStore->deleteByMailbox($serviceId, $mailbox);
$this->fileStore->deleteByMailbox($tenantId, $serviceId, $mailbox);
$result['reset'] = true;
}
$cachedFlags = $this->messageStore->flags($serviceId, $mailbox, $uidValidity);
// stream every UID and its flags from the server: cached messages get their flags
// compared (and are dropped from $cachedFlags), unknown UIDs are collected as new.
// No other IMAP command may run until the stream is consumed.
$new = [];
foreach ($live->entityFlags($mailbox) as $uid => $flags) {
if (!isset($cachedFlags[$uid])) {
$new[] = $uid;
continue;
}
$flags = MessageProperties::normalizeFlags($flags);
if (!self::sameFlags($flags, $cachedFlags[$uid])) {
$this->messageStore->updateFlags($serviceId, $mailbox, $uidValidity, $uid, $flags);
$result['updated']++;
}
unset($cachedFlags[$uid]);
}
// new: newest first so recent mail is available soonest
rsort($new);
$missing = [];
foreach (array_chunk($new, self::INGEST_BATCH_SIZE) as $batch) {
$ingested = $this->ingestBatch($tenantId, $serviceId, $mailbox, $uidValidity, $batch);
$result['added'] += count($ingested);
array_push($missing, ...array_diff($batch, $ingested));
}
// expunged: cached UIDs the server did not list; meta documents first, then files
$expunged = array_keys($cachedFlags);
if ($expunged !== []) {
$this->messageStore->delete($serviceId, $mailbox, $uidValidity, ...$expunged);
$this->fileStore->delete($tenantId, $serviceId, $mailbox, $uidValidity, ...$expunged);
$result['removed'] = count($expunged);
}
// UIDs that could not be fetched (e.g. expunged meanwhile) are retried next run
$result['complete'] = $missing === [];
$result['status'] = 'harmonized';
$this->mailboxStore->updateState($serviceId, $mailbox, [
'uidValidity' => $uidValidity,
'uidNext' => $selected->uidNext(),
'highestModSeq' => $selected->highestModSeq(),
'harmonizedAt' => time(),
'harmonizationComplete' => $result['complete'],
]);
} finally {
$this->mailboxStore->releaseLock($serviceId, $mailbox, $owner);
}
return $result;
}
/**
* Fetch a batch of messages and cache each one as it streams in.
*
* @param int[] $uids
* @return int[] UIDs that were cached
*/
private function ingestBatch(string $tenantId, string $serviceId, string $mailbox, int $uidValidity, array $uids): array
{
[$service, $live] = $this->bound();
$options = FetchOptions::message()->withBodyText(MessageIngestor::BODY_TEXT_LIMIT);
$ingested = [];
foreach ($live->entityFetch($mailbox, $options, ...$uids) as $message) {
$entity = (new EntityResource($service->provider(), $serviceId))->fromImap($message, $mailbox);
array_push($ingested, ...$this->ingestor->ingest($tenantId, $serviceId, $uidValidity, $entity));
}
return $ingested;
}
/**
* @param string[] $left
* @param string[] $right
*/
private static function sameFlags(array $left, array $right): bool
{
sort($left);
sort($right);
return $left === $right;
}
/**
* 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;
}
}