* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Service; use KTXM\ProviderImap\Providers\ServiceLocation; /** * IMAP Service Discovery * * Implements RFC 6186 service discovery via DNS SRV records: * 1. _imaps._tcp. — implicit-TLS IMAP (port 993) * 2. _imap._tcp. — STARTTLS / plain IMAP (port 143) * * Falls back to probing the common default hostnames when SRV fails. */ class Discovery { private const DEFAULT_PORT_IMAPS = 993; private const DEFAULT_PORT_IMAP = 143; private const CONNECT_TIMEOUT = 5; /** * Discover an IMAP service location from an e-mail address or domain name. * * Returns the best (highest-priority) reachable location, or null. * * @param string $identity E-mail address or bare domain * @param string|null $location Optional explicit hostname to test directly * @param string|null $secret Ignored (IMAP discovery is connection-based) * @param bool $verifySSL Whether to verify TLS certificates * @return ServiceLocation|null */ public function discover( string $identity, ?string $location = null, ?string $secret = null, bool $verifySSL = true, ): ?ServiceLocation { $all = $this->discoverAll($identity, $location, $secret, $verifySSL); return $all[0] ?? null; } /** * Discover ALL reachable IMAP service locations for an e-mail address or domain. * * Probes standard ports/encryptions and returns every location that accepted * a TCP connection, in priority order: * ssl:993 > ssl:143 > starttls:143 > none:143 * * @param string $identity E-mail address or bare domain * @param string|null $location Optional explicit hostname to test directly * @param string|null $secret Ignored (IMAP discovery is connection-based) * @param bool $verifySSL Whether to verify TLS certificates * @return ServiceLocation[] All reachable locations (may be empty) */ public function discoverAll( string $identity, ?string $location = null, ?string $secret = null, bool $verifySSL = true, ): array { // If an explicit host was given, probe it on both standard ports. if ($location !== null && $location !== '') { $host = $this->extractHost($location); if ($host !== null) { return $this->probeHostAll($host, $verifySSL); } return []; } $domain = $this->extractDomain($identity); if ($domain === null) { return []; } $results = []; // 1. RFC 6186 — _imaps._tcp SRV (implicit TLS, port 993) $srv = $this->srvLookup("_imaps._tcp.{$domain}"); if ($srv !== null) { $loc = $this->testPort($srv['host'], $srv['port'], 'ssl', $verifySSL); if ($loc !== null) { $results[] = $loc; } } // 2. RFC 6186 — _imap._tcp SRV (STARTTLS / plain, port 143) $srv = $this->srvLookup("_imap._tcp.{$domain}"); if ($srv !== null) { foreach (['starttls', 'none'] as $enc) { $loc = $this->testPort($srv['host'], $srv['port'], $enc, $verifySSL); if ($loc !== null) { $results[] = $loc; } } } // 3. Fallback — common IMAP host naming conventions $candidates = [ "mail.{$domain}", "imap.{$domain}", $domain, ]; $seen = []; foreach ($results as $r) { $seen[$r->getHost()] = true; } foreach ($candidates as $host) { if (isset($seen[$host])) { continue; } $locs = $this->probeHostAll($host, $verifySSL); foreach ($locs as $loc) { $results[] = $loc; } if ($locs !== []) { $seen[$host] = true; } } return $results; } // ── Private helpers ─────────────────────────────────────────────────────── /** * Probe a host on IMAPS (993) and plain IMAP (143), returning the first * reachable ServiceLocation. */ private function probeHost(string $host, bool $verifySSL): ?ServiceLocation { return $this->probeHostAll($host, $verifySSL)[0] ?? null; } /** * Probe a host on all standard IMAP ports and encryptions, returning every * reachable ServiceLocation in priority order: * ssl:993 > ssl:143 > starttls:143 > none:143 * * @return ServiceLocation[] */ private function probeHostAll(string $host, bool $verifySSL): array { $results = []; $probes = [ [self::DEFAULT_PORT_IMAPS, 'ssl'], [self::DEFAULT_PORT_IMAP, 'ssl'], [self::DEFAULT_PORT_IMAP, 'starttls'], [self::DEFAULT_PORT_IMAP, 'none'], ]; foreach ($probes as [$port, $enc]) { $loc = $this->testPort($host, $port, $enc, $verifySSL); if ($loc !== null) { $results[] = $loc; } } return $results; } /** * Try to open a TCP connection to $host:$port within the timeout. * * Returns a ServiceLocation on success, null on failure. */ private function testPort(string $host, int $port, string $encryption, bool $verifySSL): ?ServiceLocation { $transport = match ($encryption) { 'ssl' => 'ssl', 'starttls' => 'tcp', default => 'tcp', }; $context = stream_context_create([ 'ssl' => [ 'verify_peer' => $verifySSL, 'verify_peer_name' => $verifySSL, 'allow_self_signed' => !$verifySSL, ], ]); try { $socket = @stream_socket_client( "{$transport}://{$host}:{$port}", $errno, $errstr, self::CONNECT_TIMEOUT, STREAM_CLIENT_CONNECT, $context, ); if ($socket === false) { return null; } fclose($socket); $loc = new ServiceLocation(); $loc->jsonDeserialize([ 'host' => $host, 'port' => $port, 'encryption' => $encryption, 'verifyPeer' => $verifySSL, ]); return $loc; } catch (\Throwable) { return null; } } /** * Look up a DNS SRV record and return the host/port, or null. * * @return array{host: string, port: int}|null */ private function srvLookup(string $srvName): ?array { try { $records = @dns_get_record($srvName, DNS_SRV); if ($records === false || empty($records)) { return null; } // Sort by priority (lowest first), then weight (highest first) usort($records, static function (array $a, array $b): int { $prio = ($a['pri'] ?? 0) <=> ($b['pri'] ?? 0); return $prio !== 0 ? $prio : ($b['weight'] ?? 0) <=> ($a['weight'] ?? 0); }); $record = $records[0]; if (!isset($record['target'], $record['port'])) { return null; } // RFC: target '.' means service unavailable $target = rtrim((string) $record['target'], '.'); if ($target === '' || $target === '.') { return null; } return ['host' => $target, 'port' => (int) $record['port']]; } catch (\Throwable) { return null; } } /** * Extract the bare domain from an e-mail address. * * Returns null when the input cannot be resolved to a domain. */ private function extractDomain(string $identity): ?string { $identity = trim($identity); if (str_contains($identity, '@')) { [, $domain] = explode('@', $identity, 2); return strtolower(trim($domain)) ?: null; } // Treat as a bare domain / URL return $this->extractHost($identity); } /** * Strip scheme and path from a host-or-URL string. */ private function extractHost(string $value): ?string { $value = trim($value); $value = preg_replace('#^[a-zA-Z][a-zA-Z0-9+\-.]*://#', '', $value); $value = explode('/', $value)[0]; $value = strtolower($value); return $value !== '' ? $value : null; } }