diff --git a/lib/Console/HarmonizeCommand.php b/lib/Console/HarmonizeCommand.php new file mode 100644 index 0000000..aa80f9c --- /dev/null +++ b/lib/Console/HarmonizeCommand.php @@ -0,0 +1,181 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Console; + +use KTXC\Context\TenantContext; +use KTXM\ProviderImap\Providers\Provider; +use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Service\Cache\HarmonizationService; +use KTXM\ProviderImap\Stores\MailboxStore; +use KTXM\ProviderImap\Stores\MessageStore; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Harmonize the cache of a stored IMAP service with its server. + * + * Read-only towards the server (EXAMINE, BODY.PEEK); safe to run repeatedly. + */ +#[AsCommand( + name: 'provider_imap_mail:cache:harmonize', + description: 'Harmonize the message cache of an IMAP service with its server', +)] +class HarmonizeCommand extends Command +{ + public function __construct( + private readonly Provider $provider, + private readonly TenantContext $tenantContext, + private readonly HarmonizationService $harmonizer, + private readonly MailboxStore $mailboxStore, + private readonly MessageStore $messageStore, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('service-id', InputArgument::REQUIRED, 'Stored service ID') + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') + ->addOption('mailbox', 'm', InputOption::VALUE_REQUIRED, 'Mailbox to harmonize (default: INBOX)') + ->addOption('all', 'a', InputOption::VALUE_NONE, 'Harmonize every selectable mailbox') + ->setHelp(<<<'HELP' + The provider_imap_mail:cache:harmonize command brings the cache of a + stored IMAP service in line with its server. + + It will: + 1. Create the cache indexes (idempotent) + 2. Harmonize the mailbox list + 3. Harmonize the messages of one mailbox, or of every selectable mailbox with --all + + Server access is read-only: nothing is marked as read. + + Examples: + + Harmonize INBOX: + provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1 + + Harmonize a specific mailbox: + provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1 --mailbox=Sent + + Harmonize every mailbox: + provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1 --all + HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + $serviceId = (string) $input->getArgument('service-id'); + $mailbox = trim((string) ($input->getOption('mailbox') ?? 'INBOX')); + $all = (bool) $input->getOption('all'); + + $errors = []; + if ($tenantId === '') { + $errors[] = 'Tenant ID is required (--tenant).'; + } + if ($userId === '') { + $errors[] = 'User ID is required (--user).'; + } + if ($all && $input->getOption('mailbox') !== null) { + $errors[] = 'Use either --mailbox or --all, not both.'; + } + if ($errors !== []) { + $io->error($errors); + return Command::FAILURE; + } + + $this->tenantContext->resolveIdentifier($tenantId); + $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); + if (!$service instanceof Service) { + $io->error(sprintf("Service '%s' not found.", $serviceId)); + return Command::FAILURE; + } + + $this->mailboxStore->ensureIndexes(); + $this->messageStore->ensureIndexes(); + + $harmonizer = $this->harmonizer->for($service); + $startedAt = microtime(true); + + try { + if ($all) { + $result = $harmonizer->harmonizeAll(); + $mailboxes = $result['mailboxes']; + $messages = $result['messages']; + } else { + $mailboxes = $harmonizer->harmonizeMailboxes(); + $messages = [$mailbox => $harmonizer->harmonizeMessages($mailbox)]; + } + } catch (\Throwable $e) { + $io->error('Harmonization failed: ' . $e->getMessage()); + return Command::FAILURE; + } + + $io->section('Mailboxes'); + $io->definitionList( + ['Added' => self::names($mailboxes['added'])], + ['Updated' => count($mailboxes['updated'])], + ['Removed' => self::names($mailboxes['removed'])], + ); + + $io->section('Messages'); + $rows = []; + $failed = 0; + foreach ($messages as $name => $outcome) { + if (is_string($outcome)) { + $failed++; + $rows[] = [$name, 'failed', '', '', '', '', $outcome]; + continue; + } + $rows[] = [ + $name, + $outcome['status'], + $outcome['added'], + $outcome['updated'], + $outcome['removed'], + $outcome['complete'] ? 'yes' : 'no', + $outcome['reset'] ? 'UIDVALIDITY changed, cache reset' : '', + ]; + } + $io->table(['Mailbox', 'Status', 'Added', 'Updated', 'Removed', 'Complete', 'Note'], $rows); + + $io->writeln(sprintf( + 'Cache: storage/%s/provider_imap/%s, MongoDB provider_imap_mail_mailboxes / provider_imap_mail_messages', + $tenantId, + $serviceId, + )); + $io->writeln(sprintf('Completed in %.2fs', microtime(true) - $startedAt)); + + if ($failed > 0) { + $io->warning(sprintf('%d mailbox(es) failed.', $failed)); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + /** + * @param string[] $names + */ + private static function names(array $names): string + { + return $names === [] ? '-' : implode(', ', $names); + } +} diff --git a/lib/Module.php b/lib/Module.php index bb1d1e8..c69ec02 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -22,6 +22,7 @@ use KTXF\Resource\Provider\ProviderInterface; use KTXM\ProviderImap\Console\ConnectCommand; use KTXM\ProviderImap\Console\DiscoverCommand; use KTXM\ProviderImap\Console\DisconnectCommand; +use KTXM\ProviderImap\Console\HarmonizeCommand; use KTXM\ProviderImap\Console\TestCommand; use KTXM\ProviderImap\Listeners\UserEventListener; use KTXM\ProviderImap\Providers\Provider as MailProvider; @@ -109,6 +110,7 @@ class Module extends ModuleInstanceAbstract ConnectCommand::class, DisconnectCommand::class, TestCommand::class, + HarmonizeCommand::class, ] as $command) { $context->registerCommand($command); } diff --git a/lib/Service/Cache/HarmonizationService.php b/lib/Service/Cache/HarmonizationService.php index cf5f93c..a78b45d 100644 --- a/lib/Service/Cache/HarmonizationService.php +++ b/lib/Service/Cache/HarmonizationService.php @@ -98,6 +98,33 @@ class HarmonizationService return $result; } + /** + * Harmonize the mailbox list, then the messages of every selectable mailbox. + * + * A failing mailbox does not stop the others; its error is reported instead. + * + * @return array{mailboxes: array{added: string[], updated: string[], removed: string[]}, messages: array} + */ + public function harmonizeAll(): array + { + [$service] = $this->bound(); + $mailboxes = $this->harmonizeMailboxes(); + + $messages = []; + foreach ($this->mailboxStore->list((string) $service->identifier()) as $name => $document) { + if (!self::isSelectable($document)) { + continue; + } + try { + $messages[$name] = $this->harmonizeMessages((string) $name); + } catch (\Throwable $e) { + $messages[$name] = $e->getMessage(); + } + } + + return ['mailboxes' => $mailboxes, 'messages' => $messages]; + } + /** * Harmonize the messages of one mailbox with the server. * @@ -252,6 +279,20 @@ class HarmonizationService return [$this->service, $this->live]; } + /** + * Whether a cached mailbox document describes a mailbox that can be selected. + */ + private static function isSelectable(array $document): bool + { + foreach ((array) ($document['properties']['attributes'] ?? []) as $attribute) { + if (in_array(strtolower((string) $attribute), ['\\noselect', '\\nonexistent'], true)) { + return false; + } + } + + return true; + } + /** * @param string[] $names */ diff --git a/tests/php/Unit/MessageHarmonizationTest.php b/tests/php/Unit/MessageHarmonizationTest.php index 8cc3e35..c87256a 100644 --- a/tests/php/Unit/MessageHarmonizationTest.php +++ b/tests/php/Unit/MessageHarmonizationTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace KTXT\ProviderImap\Tests\Unit; use Generator; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Sort\ISort; use KTXC\Db\DataStore; use KTXM\ProviderImap\Client\Mailbox; use KTXM\ProviderImap\Client\Protocol\Command\Argument\FetchOptions; @@ -138,6 +140,25 @@ final class MessageHarmonizationTest extends TestCase $this->assertSame(1, $this->live->fetchBatches[2][49]); } + public function testHarmonizeAllSkipsUnselectableAndIsolatesFailures(): void + { + $this->live->flags = [1 => []]; + $this->live->mailboxList = [ + new Mailbox('INBOX', '/', []), + new Mailbox('Archive', '/', ['\\Noselect']), + new Mailbox('Broken', '/', []), + ]; + $this->live->failing = ['Broken']; + + $result = $this->harmonizer->harmonizeAll(); + + $this->assertSame(['Archive', 'Broken'], $result['mailboxes']['added']); + $this->assertSame(['INBOX', 'Broken'], array_keys($result['messages'])); + $this->assertSame('harmonized', $result['messages']['INBOX']['status']); + $this->assertSame('Mailbox not found on server: Broken', $result['messages']['Broken']); + $this->assertNull($this->mailboxes->lockOwner); + } + public function testLockIsReleasedWhenTheRunFails(): void { $this->live->uidValidity = null; @@ -163,9 +184,23 @@ final class MessageHarmonizationLiveStub extends LiveMailService public array $fetchBatches = []; public int $selects = 0; public ?int $bodyTextLimit = null; + /** @var Mailbox[] */ + public array $mailboxList = []; + /** @var string[] */ + public array $failing = []; + + public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, string $depth = '*'): Generator + { + foreach ($this->mailboxList as $mailbox) { + yield $mailbox->name() => $mailbox; + } + } public function collectionFetch(string $identifier): ?Mailbox { + if (in_array($identifier, $this->failing, true)) { + return null; + } $this->selects++; return new Mailbox($identifier, '/', [], count($this->flags), 0, $this->uidValidity, 0, [], true, $this->uidNext); } @@ -201,7 +236,10 @@ final class FakeMailboxStore extends MailboxStore public function upsert(string $tenantId, string $serviceId, CollectionResource $collection): void { $name = (string) $collection->identifier(); - $this->documents[$name] = ($this->documents[$name] ?? self::STATE_DEFAULTS) + ['name' => $name]; + $this->documents[$name] = [ + ...($this->documents[$name] ?? self::STATE_DEFAULTS), + ...$collection->toCacheMeta(), + ]; } public function fetch(string $serviceId, string $name): ?array