* 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); } }