* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImapMail\Console; use KTXM\ProviderImapMail\Providers\Provider; use KTXM\ProviderImapMail\Providers\Service; use KTXM\ProviderImapMail\Providers\ServiceIdentityBasic; use KTXM\ProviderImapMail\Providers\ServiceLocation; use KTXM\ProviderImapMail\Service\Discovery; use KTXM\ProviderImapMail\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 ServiceDiscoverCommand 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; } }