diff --git a/lib/Console/ConnectCommand.php b/lib/Console/ConnectCommand.php new file mode 100644 index 0000000..fa1340b --- /dev/null +++ b/lib/Console/ConnectCommand.php @@ -0,0 +1,202 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Console; + +use KTXM\ProviderImap\Providers\Provider; +use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceIdentityBasic; +use KTXM\ProviderImap\Providers\ServiceLocation; +use KTXC\SessionTenant; +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; + +/** + * Manual IMAP service connection wizard. + * + * Interactively prompts for all connection details (host, port, encryption, + * username, password), runs a live connection test, then optionally persists + * the service to the store. + * + * Usage: + * bin/console provider_imap_mail:service:connect + * bin/console provider_imap_mail:service:connect --tenant=t1 --user=u1 + */ +#[AsCommand( + name: 'provider_imap_mail:service:connect', + description: 'Manually configure and connect an IMAP service', +)] +class ConnectCommand extends Command +{ + public function __construct( + private readonly Provider $provider, + private readonly SessionTenant $sessionTenant, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'IMAP server hostname') + ->addOption('port', null, InputOption::VALUE_REQUIRED, 'IMAP port (default: 993)') + ->addOption('encryption', null, InputOption::VALUE_REQUIRED, 'Encryption: ssl | starttls | none (default: ssl)') + ->addOption('username', null, InputOption::VALUE_REQUIRED, 'IMAP username / e-mail') + ->addOption('no-verify', null, InputOption::VALUE_NONE, 'Disable TLS certificate verification') + ->addOption('no-save', null, InputOption::VALUE_NONE, 'Test connection only; do not persist') + ->setHelp(<<<'HELP' + The provider_imap_mail:service:connect command walks you through + manually configuring an IMAP account. All prompts can be pre-filled via + options to support non-interactive / scripted usage. + + Examples: + + Fully interactive: + bin/console provider_imap_mail:service:connect + + Pre-fill common options: + bin/console provider_imap_mail:service:connect \ + --host=mail.example.com --username=user@example.com \ + --tenant=t1 --user=u1 + HELP); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + $io->title('IMAP Service — Manual Configuration'); + + 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->getOption('host')) { + $host = $io->ask('IMAP server hostname'); + if ($host) $input->setOption('host', $host); + } + + if (!$input->getOption('port')) { + $port = $io->ask('Port', '993'); + if ($port) $input->setOption('port', $port); + } + + if (!$input->getOption('encryption')) { + $enc = $io->choice('Encryption', ['ssl', 'starttls', 'none'], 'ssl'); + $input->setOption('encryption', $enc); + } + + if (!$input->getOption('username')) { + $username = $io->ask('Username / e-mail address'); + if ($username) $input->setOption('username', $username); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + $host = (string) ($input->getOption('host') ?? ''); + $port = (int) ($input->getOption('port') ?? 993); + $encryption = (string) ($input->getOption('encryption') ?? 'ssl'); + $username = (string) ($input->getOption('username') ?? ''); + $noVerify = (bool) $input->getOption('no-verify'); + $noSave = (bool) $input->getOption('no-save'); + + // ── Validate required fields ───────────────────────────────────────── + + $errors = []; + if ($host === '') $errors[] = 'Hostname is required.'; + if ($username === '') $errors[] = 'Username is required.'; + if (!$noSave) { + if ($tenantId === '') $errors[] = 'Tenant ID is required (or pass --no-save to test only).'; + if ($userId === '') $errors[] = 'User ID is required (or pass --no-save to test only).'; + } + + if (!empty($errors)) { + $io->error($errors); + return Command::FAILURE; + } + + // ── Prompt for password (always interactive — never passed via option) ─ + + $password = $io->askHidden('Password'); + + if (!$password) { + $io->error('Password is required.'); + return Command::FAILURE; + } + + // ── Build service object ──────────────────────────────────────────── + + $location = new ServiceLocation( + host: $host, + port: $port > 0 ? $port : 993, + encryption: $encryption, + verifyPeer: !$noVerify, + verifyPeerName: !$noVerify, + allowSelfSigned: $noVerify, + ); + + $identity = (new ServiceIdentityBasic())->jsonDeserialize([ + 'identity' => $username, + 'secret' => $password, + ]); + + $service = new Service(); + $service->setLocation($location); + $service->setIdentity($identity); + + // ── Test connection ────────────────────────────────────────────────── + + $io->text('Testing connection to ' . $host . ':' . $port . '…'); + + $result = $this->provider->serviceTest($service); + + if (!$result['success']) { + $io->error('Connection test failed: ' . $result['message']); + return Command::FAILURE; + } + + $io->success($result['message']); + + if ($noSave) { + $io->note('Connection test passed. Service not saved (--no-save).'); + return Command::SUCCESS; + } + + // ── Persist ────────────────────────────────────────────────────────── + + $this->sessionTenant->configureById($tenantId); + + $label = $io->ask('Service label', $username); + if ($label) { + $service->setLabel($label); + } + + $id = $this->provider->serviceCreate($tenantId, $userId, $service); + $io->success("Service saved with ID: {$id}"); + + return Command::SUCCESS; + } +} diff --git a/lib/Console/DisconnectCommand.php b/lib/Console/DisconnectCommand.php new file mode 100644 index 0000000..7314654 --- /dev/null +++ b/lib/Console/DisconnectCommand.php @@ -0,0 +1,177 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Console; + +use KTXM\ProviderImap\Providers\Provider; +use KTXM\ProviderImap\Providers\Service; +use KTXC\SessionTenant; +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; + +/** + * Remove a stored IMAP service connection. + * + * Looks up the service by its ID (or prompts for one), confirms with the + * operator, then permanently deletes the service document from the store. + * + * Usage: + * bin/console provider_imap_mail:service:disconnect --tenant=t1 --user=u1 + */ +#[AsCommand( + name: 'provider_imap_mail:service:disconnect', + description: 'Remove a stored IMAP service connection', +)] +class DisconnectCommand 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, 'Service ID to remove') + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt') + ->setHelp(<<<'HELP' + The provider_imap_mail:service:disconnect command permanently removes + a stored IMAP service configuration from the store. + + Examples: + + Interactive (lists services and prompts for ID): + bin/console provider_imap_mail:service:disconnect --tenant=t1 --user=u1 + + Direct, with confirmation: + bin/console provider_imap_mail:service:disconnect abc123 --tenant=t1 --user=u1 + + Skip confirmation: + bin/console provider_imap_mail:service:disconnect abc123 --tenant=t1 --user=u1 --force + HELP); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + 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 no service ID given, list available services and let the operator pick + if (!$input->getArgument('service-id')) { + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + + if ($tenantId !== '' && $userId !== '') { + $this->sessionTenant->configureById($tenantId); + $services = $this->provider->serviceList($tenantId, $userId); + + if (empty($services)) { + // nothing to select — let execute() handle the error + return; + } + + $choices = []; + foreach ($services as $id => $service) { + $label = $service instanceof Service ? ($service->getLabel() ?? $id) : $id; + $choices[$id] = "{$label} [{$id}]"; + } + + $chosen = $io->choice('Select service to disconnect', array_values($choices)); + // resolve the chosen label back to its key + $serviceId = (string) array_search($chosen, $choices, true); + if ($serviceId !== '') { + $input->setArgument('service-id', $serviceId); + } + } + } + } + + 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') ?? ''); + $force = (bool) $input->getOption('force'); + + $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 (!empty($errors)) { + $io->error($errors); + return Command::FAILURE; + } + + // ── Fetch service for display ──────────────────────────────────────── + $this->sessionTenant->configureById($tenantId); + $service = $this->provider->serviceFetch($tenantId, $userId, $serviceId); + + if ($service === null) { + $io->error("Service '{$serviceId}' not found."); + return Command::FAILURE; + } + + $label = $service->getLabel() ?? $serviceId; + $host = $service->getLocation()?->getHost() ?? 'unknown'; + + $io->title('Disconnect IMAP Service'); + $io->definitionList( + ['ID' => $serviceId], + ['Label' => $label], + ['Host' => $host], + ); + + // ── Confirmation ───────────────────────────────────────────────────── + + if (!$force) { + $confirm = $io->confirm( + "Permanently remove service {$label} ({$serviceId})?", + false + ); + if (!$confirm) { + $io->note('Aborted.'); + return Command::SUCCESS; + } + } + + // ── Destroy ────────────────────────────────────────────────────────── + + $deleted = $this->provider->serviceDestroy($tenantId, $userId, $service); + + if (!$deleted) { + $io->error("Failed to remove service '{$serviceId}'."); + return Command::FAILURE; + } + + $io->success("Service '{$label}' ({$serviceId}) has been removed."); + + return Command::SUCCESS; + } +} diff --git a/lib/Console/DiscoverCommand.php b/lib/Console/DiscoverCommand.php new file mode 100644 index 0000000..1b51f42 --- /dev/null +++ b/lib/Console/DiscoverCommand.php @@ -0,0 +1,226 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Console; + +use KTXM\ProviderImap\Providers\Provider; +use KTXM\ProviderImap\Providers\Service; +use KTXM\ProviderImap\Providers\ServiceIdentityBasic; +use KTXM\ProviderImap\Providers\ServiceLocation; +use KTXM\ProviderImap\Service\Discovery; +use KTXM\ProviderImap\Service\Remote\RemoteService; +use KTXC\SessionTenant; +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; + +/** + * Automated IMAP service discovery. + * + * Probes DNS SRV records and common hostnames to determine the correct IMAP + * server settings for a given e-mail address, then optionally persists the + * resulting service to the store. + * + * Usage: + * bin/console provider_imap_mail:service:discover user@example.com + * bin/console provider_imap_mail:service:discover user@example.com --tenant=t1 --user=u1 --save + */ +#[AsCommand( + name: 'provider_imap_mail:service:discover', + description: 'Auto-discover IMAP server settings from an e-mail address', +)] +class DiscoverCommand extends Command +{ + public function __construct( + private readonly Provider $provider, + private readonly Discovery $discovery, + private readonly SessionTenant $sessionTenant, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('address', InputArgument::OPTIONAL, 'E-mail address to discover settings for') + ->addOption('tenant', 't', InputOption::VALUE_REQUIRED, 'Tenant ID (required when --save is set)') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'User ID (required when --save is set)') + ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Explicit hostname to probe instead of DNS lookup') + ->addOption('no-verify', null, InputOption::VALUE_NONE, 'Disable TLS certificate verification') + ->addOption('save', null, InputOption::VALUE_NONE, 'Persist the discovered service after a successful test') + ->setHelp(<<<'HELP' + The provider_imap_mail:service:discover command auto-discovers IMAP + server settings by probing DNS SRV records (_imaps._tcp / _imap._tcp) and + common hostname conventions (mail., imap., …). + + Examples: + + Dry-run discovery (no persistence): + bin/console provider_imap_mail:service:discover user@example.com + + Discover and save under a specific tenant/user pair: + bin/console provider_imap_mail:service:discover user@example.com --tenant=t1 --user=u1 --save + + Probe an explicit host: + bin/console provider_imap_mail:service:discover user@example.com --host=mail.example.com + HELP); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + if (!$input->getArgument('address')) { + $address = $io->ask('E-mail address'); + if ($address) { + $input->setArgument('address', $address); + } + } + + if ($input->getOption('save')) { + 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); + } + } + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $address = (string) $input->getArgument('address'); + $host = $input->getOption('host'); + $noVerify = (bool) $input->getOption('no-verify'); + $save = (bool) $input->getOption('save'); + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $userId = (string) ($input->getOption('user') ?? ''); + + if ($address === '') { + $io->error('An e-mail address is required.'); + return Command::FAILURE; + } + + if ($save && ($tenantId === '' || $userId === '')) { + $io->error('--tenant and --user are required when --save is set.'); + return Command::FAILURE; + } + + $io->title('IMAP Service Discovery'); + $io->text("Discovering settings for {$address}…"); + + // ── Discovery ──────────────────────────────────────────────────────── + + $candidates = $this->discovery->discoverAll( + identity: $address, + location: $host, + verifySSL: !$noVerify, + ); + + if ($candidates === []) { + $io->error('Discovery failed — no reachable IMAP server found.'); + return Command::FAILURE; + } + + // ── Build labelled choice list ──────────────────────────────────────── + + $encLabel = static fn (string $e): string => match ($e) { + 'ssl' => 'SSL/TLS', + 'starttls' => 'STARTTLS', + default => 'None (plain)', + }; + + /** @var array $choiceMap */ + $choiceMap = []; + foreach ($candidates as $c) { + $label = sprintf('%s : %d [%s]', $c->getHost(), $c->getPort(), $encLabel($c->getEncryption())); + $choiceMap[$label] = $c; + } + + $io->text(sprintf('%d location(s) found:', count($candidates))); + $io->newLine(); + + if (count($candidates) === 1) { + $chosenLabel = array_key_first($choiceMap); + } else { + $chosenLabel = $io->choice('Select which server to use', array_keys($choiceMap), array_key_first($choiceMap)); + } + + $location = $choiceMap[$chosenLabel]; + + // ── Display chosen result ───────────────────────────────────────────── + + $io->success('Server selected:'); + $io->definitionList( + ['Host' => $location->getHost()], + ['Port' => (string) $location->getPort()], + ['Encryption' => $encLabel($location->getEncryption())], + ); + + if (!$save) { + $io->note('Run with --save --tenant= --user= to persist this service.'); + return Command::SUCCESS; + } + + // ── Interactive credential prompt ──────────────────────────────────── + + $username = $io->ask('Username', $address); + $password = $io->askHidden('Password'); + + if (!$username || !$password) { + $io->error('Username and password are required to save the service.'); + return Command::FAILURE; + } + + // ── Test before saving ─────────────────────────────────────────────── + + $service = new Service(); + $service->setLocation($location); + $service->setIdentity((new ServiceIdentityBasic())->jsonDeserialize([ + 'identity' => $username, + 'secret' => $password, + ])); + + $io->text('Testing connection…'); + $result = $this->provider->serviceTest($service); + + if (!$result['success']) { + $io->error('Connection test failed: ' . $result['message']); + return Command::FAILURE; + } + + $io->success($result['message']); + + // ── Persist ────────────────────────────────────────────────────────── + + $this->sessionTenant->configureById($tenantId); + + $label = $io->ask('Service label', $address); + if ($label) { + $service->setLabel($label); + } + + $id = $this->provider->serviceCreate($tenantId, $userId, $service); + $io->success("Service saved with ID: {$id}"); + + return Command::SUCCESS; + } +} diff --git a/lib/Console/TestCommand.php b/lib/Console/TestCommand.php new file mode 100644 index 0000000..807dd92 --- /dev/null +++ b/lib/Console/TestCommand.php @@ -0,0 +1,362 @@ + + * 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]); + } +} \ No newline at end of file diff --git a/lib/Module.php b/lib/Module.php index 6576b05..7384e1a 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -14,10 +14,10 @@ use KTXF\Module\ModuleBrowserInterface; use KTXF\Module\ModuleConsoleInterface; use KTXF\Module\ModuleInstanceAbstract; use KTXF\Resource\Provider\ProviderInterface; -use KTXM\ProviderImap\Console\ServiceConnectCommand; -use KTXM\ProviderImap\Console\ServiceDiscoverCommand; -use KTXM\ProviderImap\Console\ServiceDisconnectCommand; -use KTXM\ProviderImap\Console\ServiceTestCommand; +use KTXM\ProviderImap\Console\ConnectCommand; +use KTXM\ProviderImap\Console\DiscoverCommand; +use KTXM\ProviderImap\Console\DisconnectCommand; +use KTXM\ProviderImap\Console\TestCommand; use KTXM\ProviderImap\Providers\Provider as MailProvider; /** @@ -75,10 +75,10 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M public function registerCI(): array { return [ - ServiceDiscoverCommand::class, - ServiceConnectCommand::class, - ServiceDisconnectCommand::class, - ServiceTestCommand::class, + DiscoverCommand::class, + ConnectCommand::class, + DisconnectCommand::class, + TestCommand::class, ]; }