feat: add cache harmonize command

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-10-07 21:03:52 -04:00
parent 65fdb23fa4
commit c20dde6633
4 changed files with 263 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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 <info>provider_imap_mail:cache:harmonize</info> 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:
<info>provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1</info>
Harmonize a specific mailbox:
<info>provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1 --mailbox=Sent</info>
Harmonize every mailbox:
<info>provider_imap_mail:cache:harmonize abc123 --tenant=t1 --user=u1 --all</info>
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, '<error>failed</error>', '', '', '', '', $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: <info>storage/%s/provider_imap/%s</info>, MongoDB <info>provider_imap_mail_mailboxes</info> / <info>provider_imap_mail_messages</info>',
$tenantId,
$serviceId,
));
$io->writeln(sprintf('Completed in <info>%.2fs</info>', 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);
}
}
+2
View File
@@ -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);
}
@@ -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<string, array|string>}
*/
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
*/