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,
];
}