* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Console; use KTXC\SessionTenant; use KTXM\ProviderImap\Client\Command\SelectCommand; use KTXM\ProviderImap\Client\FetchOptions; use KTXM\ProviderImap\Client\FetchTarget; use KTXM\ProviderImap\Client\Message; use KTXM\ProviderImap\Client\SequenceSet; use KTXM\ProviderImap\Providers\Provider; use KTXM\ProviderImap\Providers\Service; use KTXM\ProviderImap\Service\Remote\RemoteService; 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; /** * Live IMAP service diagnostic. * * Loads an existing stored IMAP service, connects with its saved credentials, * shows a bounded list of mailboxes, then prints the most recent messages * from a selected mailbox. */ #[AsCommand( name: 'provider_imap_mail:service:test', description: 'Test an existing IMAP service and inspect mailboxes and messages', )] class TestCommand extends Command { public function __construct( private readonly Provider $provider, private readonly SessionTenant $sessionTenant, ) { parent::__construct(); } protected function configure(): void { $this ->addArgument('service-id', InputArgument::OPTIONAL, 'Stored service ID to test') ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') ->addOption('mailbox', 'm', InputOption::VALUE_REQUIRED, 'Specific mailbox to inspect (for example: INBOX)') ->addOption('mailbox-limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of mailboxes to inspect (default: 5, 0 = all)', '5') ->addOption('message-limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of recent messages to show (default: 5, 0 = none)', '5') ->setHelp(<<<'HELP' The provider_imap_mail:service:test command performs a live diagnostic against an already stored IMAP service. It will: 1. Load the saved service for a tenant/user pair 2. Connect and authenticate with the stored credentials 3. Show a bounded list of selectable mailboxes 4. Show the most recent messages from the selected mailbox Examples: Interactive service selection: provider_imap_mail:service:test --tenant=t1 --user=u1 Test a specific stored service: provider_imap_mail:service:test abc123 --tenant=t1 --user=u1 Show recent messages from INBOX: provider_imap_mail:service:test abc123 --tenant=t1 --user=u1 --mailbox=INBOX Show 10 mailboxes and 3 recent messages from Drafts: provider_imap_mail:service:test abc123 --tenant=t1 --user=u1 --mailbox=Drafts --mailbox-limit=10 --message-limit=3 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') ?? ''); $mailboxName = trim((string) ($input->getOption('mailbox') ?? 'INBOX')); $mailboxLimit = $this->normalizeLimit($input->getOption('mailbox-limit'), 5); $messageLimit = $this->normalizeLimit($input->getOption('message-limit'), 5); $errors = []; if ($tenantId === '') { $errors[] = 'Tenant ID is required (--tenant).'; } if ($userId === '') { $errors[] = 'User ID is required (--user).'; } if ($serviceId === '') { $errors[] = 'Service ID is required.'; } if ($errors !== []) { $io->error($errors); return Command::FAILURE; } $this->sessionTenant->configureById($tenantId); $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { $io->error(sprintf("Service '%s' not found.", $serviceId)); return Command::FAILURE; } $startedAt = microtime(true); try { $client = RemoteService::freshClient($service); $mailService = RemoteService::mailService($service, $client); $mailboxes = $mailService->collectionList(); } catch (\Throwable $e) { $io->error('IMAP diagnostic failed: ' . $e->getMessage()); return Command::FAILURE; } $mailboxNames = array_keys($mailboxes); sort($mailboxNames); if (!in_array($mailboxName, $mailboxNames, true)) { $io->error(sprintf("Mailbox '%s' not found on this service.", $mailboxName)); return Command::FAILURE; } $visibleMailboxNames = $mailboxLimit === 0 ? $mailboxNames : array_slice($mailboxNames, 0, $mailboxLimit); $io->section('Service'); $io->definitionList( ['ID' => $serviceId], ['Label' => $service->getLabel() ?? $serviceId], ['Target' => $this->formatTarget($service)], ['Username' => $service->getIdentity()?->getIdentity() ?? '-'], ['Mailbox target' => $mailboxName], ['Mailbox list size' => $mailboxLimit === 0 ? 'all' : (string) $mailboxLimit], ['Recent message count' => (string) $messageLimit], ); if ($mailboxNames === []) { $io->warning('Authenticated successfully, but no selectable mailboxes were returned.'); return Command::SUCCESS; } $io->section('Mailboxes'); $mailboxRows = []; foreach ($visibleMailboxNames as $name) { $mailbox = $mailboxes[$name]; $properties = $mailbox->getProperties()->jsonSerialize(); $mailboxRows[] = [ $name, (string) ($properties['role'] ?? 'custom'), (string) ($mailbox->collection() ?? '-'), (string) ($properties['delimiter'] ?? '/'), $this->formatMailboxAttributes($properties['attributes'] ?? []), ]; } $io->table( ['Mailbox', 'Role', 'Parent', 'Delimiter', 'Attributes'], $mailboxRows, ); if ($mailboxLimit !== 0 && count($visibleMailboxNames) < count($mailboxNames)) { $io->note(sprintf( 'Mailbox list truncated to the first %d selectable mailboxes. Increase --mailbox-limit or set it to 0 to inspect all mailboxes.', count($visibleMailboxNames), )); } $io->section(sprintf('Recent Messages: %s', $mailboxName)); try { $mailboxClient = RemoteService::freshClient($service); $mailboxService = RemoteService::mailService($service, $mailboxClient); $selectedMailbox = $mailboxClient->perform(new SelectCommand($mailboxName, true)); } catch (\Throwable $e) { $io->error('Mailbox inspection failed: ' . $e->getMessage()); return Command::FAILURE; } $totalMessages = $selectedMailbox->messages(); $io->text(sprintf('Total messages: %d', $totalMessages)); if ($totalMessages === 0) { $io->note('No messages found in this mailbox.'); return Command::SUCCESS; } if ($messageLimit === 0) { return Command::SUCCESS; } $sampleCount = min($totalMessages, $messageLimit); $startSequence = max(1, $totalMessages - $sampleCount + 1); $messageRows = []; try { foreach ($mailboxService->messageList( $mailboxName, FetchTarget::sequence(SequenceSet::range($startSequence, $totalMessages)), FetchOptions::message(), ) as $message) { $messageRows[] = [ (string) $message->uid(), $this->formatSubject($message), $this->formatSender($message), $message->internalDate() ?? '-', $this->formatFlags($message), $this->formatBytes($message->size()), ]; } } catch (\Throwable $e) { $io->error('Message listing failed: ' . $e->getMessage()); return Command::FAILURE; } $messageRows = array_reverse($messageRows); $io->table( ['UID', 'Subject', 'From', 'Date', 'Flags', 'Size'], $messageRows, ); $elapsed = (int) round((microtime(true) - $startedAt) * 1000); $io->text(sprintf('Elapsed: %d ms', $elapsed)); return Command::SUCCESS; } protected function interact(InputInterface $input, OutputInterface $output): void { $io = new SymfonyStyle($input, $output); $io->title('IMAP Service Diagnostic'); if (!$input->getOption('tenant')) { $tenant = $io->ask('Tenant ID'); if ($tenant) { $input->setOption('tenant', $tenant); } } if (!$input->getOption('user')) { $user = $io->ask('User ID'); if ($user) { $input->setOption('user', $user); } } if ($input->getArgument('service-id')) { return; } $tenantId = (string) ($input->getOption('tenant') ?? ''); $userId = (string) ($input->getOption('user') ?? ''); if ($tenantId === '' || $userId === '') { return; } $this->sessionTenant->configureById($tenantId); $services = $this->provider->serviceList($tenantId, $userId); if ($services === []) { return; } $choices = []; foreach ($services as $id => $service) { $label = $service->getLabel() ?? (string) $id; $choices[$id] = sprintf('%s [%s] %s', $label, $id, $this->formatTarget($service)); } $chosen = $io->choice('Select service to test', array_values($choices)); $serviceId = (string) array_search($chosen, $choices, true); if ($serviceId !== '') { $input->setArgument('service-id', $serviceId); } } private function normalizeLimit(mixed $value, int $default): int { if ($value === null || $value === '') { return $default; } $limit = (int) $value; return $limit >= 0 ? $limit : $default; } private function formatTarget(Service $service): string { $location = $service->getLocation(); if ($location === null) { return 'unknown'; } return sprintf('%s://%s:%d', $location->getEncryption(), $location->getHost(), $location->getPort()); } private function formatSender(Message $message): string { $sender = $message->from()[0] ?? $message->sender()[0] ?? null; if ($sender === null) { return '-'; } return $sender->name() ?: ($sender->email() ?? '-'); } private function formatSubject(Message $message): string { $subject = trim((string) ($message->subject() ?? '')); if ($subject === '') { return '(no subject)'; } return mb_strimwidth($subject, 0, 60, '...'); } private function formatFlags(Message $message): string { $flags = $message->flags(); return $flags === [] ? '-' : implode(', ', $flags); } private function formatMailboxAttributes(array $attributes): string { if ($attributes === []) { return '-'; } return implode(', ', $attributes); } private function formatBytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $size = (float) max(0, $bytes); $unitIndex = 0; while ($size >= 1024 && $unitIndex < count($units) - 1) { $size /= 1024; $unitIndex++; } return sprintf($unitIndex === 0 ? '%.0f %s' : '%.1f %s', $size, $units[$unitIndex]); } }