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