* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderJmapc\Service; use KTXM\ProviderJmapc\Providers\ServiceLocation; /** * JMAP Service Discovery * * Implements RFC 8620 service discovery via: * 1. DNS SRV records (_jmap._tcp.) * 2. Well-known URI (https:///.well-known/jmap) */ class Discovery { private const WELL_KNOWN_PATH = '/.well-known/jmap'; private const DEFAULT_PORT_HTTPS = 443; private const DEFAULT_PORT_HTTP = 80; private const CONNECTION_TIMEOUT = 10; private const MAX_REDIRECTS = 3; /** * Discover JMAP service location from email address or domain * * @param string $identity Email address or domain * @param string|null $location Optional hostname to test directly (bypasses DNS SRV) * @param string|null $secret Optional password/token to validate the service * @param bool $verifySSL Whether to verify SSL certificates * @return ServiceLocation|null Discovered service location or null if not found */ public function discover( string $identity, ?string $location = null, ?string $secret = null, bool $verifySSL = true ): ?ServiceLocation { // If location is provided, test it directly if ($location !== null && $location !== '') { $host = $this->extractDomain($location); if ($host !== null) { $result = $this->testWellKnownUri($host, self::DEFAULT_PORT_HTTPS, $verifySSL, 'https', $identity, $secret); if ($result !== null) { return $result; } // Try HTTP if HTTPS failed $result = $this->testWellKnownUri($host, self::DEFAULT_PORT_HTTP, $verifySSL, 'http', $identity, $secret); if ($result !== null) { return $result; } } return null; } // Extract domain from email address if needed $domain = $this->extractDomain($identity); if ($domain === null) { return null; } // Try DNS SRV lookup first (RFC 8620 recommended method) $srvResult = $this->discoverViaSRV($domain); if ($srvResult !== null) { $result = $this->testWellKnownUri( $srvResult['host'], $srvResult['port'], $verifySSL, 'https', $identity, $secret ); if ($result !== null) { return $result; } } // Fallback: Try well-known URI directly on domain with HTTPS $result = $this->testWellKnownUri($domain, self::DEFAULT_PORT_HTTPS, $verifySSL, 'https', $identity, $secret); if ($result !== null) { return $result; } // Last resort: Try HTTP (not recommended, but some servers may use it) $result = $this->testWellKnownUri($domain, self::DEFAULT_PORT_HTTP, $verifySSL, 'http', $identity, $secret); if ($result !== null) { return $result; } return null; } /** * Extract domain from email address or return as-is if already a domain */ private function extractDomain(string $identity): ?string { $identity = trim($identity); // If it contains @, extract domain part if (str_contains($identity, '@')) { $parts = explode('@', $identity); return strtolower(trim($parts[1] ?? '')); } // Otherwise treat as domain $domain = strtolower($identity); // Remove protocol if present $domain = preg_replace('#^https?://#i', '', $domain); // Remove path if present $domain = explode('/', $domain)[0]; return $domain !== '' ? $domain : null; } /** * Discover JMAP service via DNS SRV record * * Queries for _jmap._tcp. SRV record * * @return array{host: string, port: int}|null */ private function discoverViaSRV(string $domain): ?array { $srvRecord = "_jmap._tcp.{$domain}"; try { $records = @dns_get_record($srvRecord, DNS_SRV); if ($records === false || empty($records)) { return null; } // Use first record (they can be prioritized, but we'll keep it simple) $record = $records[0]; if (isset($record['target']) && isset($record['port'])) { return [ 'host' => rtrim($record['target'], '.'), 'port' => (int)$record['port'], ]; } } catch (\Exception $e) { // DNS lookup failed, silently continue to fallback methods } return null; } /** * Test well-known JMAP URI and validate response * * Optionally validates with credentials if secret is provided * * @return ServiceLocation|null */ private function testWellKnownUri( string $host, int $port, bool $verifySSL, string $scheme = 'https', ?string $identity = null, ?string $secret = null ): ?ServiceLocation { $url = $this->buildWellKnownUrl($host, $port, $scheme); try { $ch = curl_init($url); if ($ch === false) { return null; } $curlOptions = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => self::MAX_REDIRECTS, CURLOPT_TIMEOUT => self::CONNECTION_TIMEOUT, CURLOPT_SSL_VERIFYPEER => $verifySSL, CURLOPT_SSL_VERIFYHOST => $verifySSL ? 2 : 0, CURLOPT_HTTPHEADER => [ 'Accept: application/json', ], ]; // Add basic auth if credentials provided if ($identity !== null && $secret !== null) { $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC; $curlOptions[CURLOPT_USERPWD] = "{$identity}:{$secret}"; } curl_setopt_array($ch, $curlOptions); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // Must be 200 OK (or 401 if we didn't provide auth - still proves service exists) if ($httpCode === 401 && ($identity === null || $secret === null)) { // Service exists but requires auth - that's fine for discovery return new ServiceLocation( host: $host, port: $port, scheme: $scheme, path: self::WELL_KNOWN_PATH, verifyPeer: $verifySSL, verifyHost: $verifySSL, ); } if ($httpCode !== 200 || $response === false) { return null; } // Parse and validate JMAP session response $data = json_decode($response, true); if (!$this->isValidJmapSession($data)) { return null; } // Create ServiceLocation with discovered settings return new ServiceLocation( host: $host, port: $port, scheme: $scheme, path: self::WELL_KNOWN_PATH, verifyPeer: $verifySSL, verifyHost: $verifySSL, ); } catch (\Exception $e) { return null; } } /** * Build well-known JMAP URL */ private function buildWellKnownUrl(string $host, int $port, string $scheme): string { $url = "{$scheme}://{$host}"; // Add port if non-standard if (($scheme === 'https' && $port !== self::DEFAULT_PORT_HTTPS) || ($scheme === 'http' && $port !== self::DEFAULT_PORT_HTTP)) { $url .= ":{$port}"; } $url .= self::WELL_KNOWN_PATH; return $url; } /** * Validate that response is a proper JMAP session object * * According to RFC 8620, session must contain at minimum: * - apiUrl: The URL to use for JMAP API requests * - capabilities: Object describing server capabilities */ private function isValidJmapSession(mixed $data): bool { if (!is_array($data)) { return false; } // Must have apiUrl if (!isset($data['apiUrl']) || !is_string($data['apiUrl'])) { return false; } // Must have capabilities object if (!isset($data['capabilities']) || !is_array($data['capabilities'])) { return false; } // Should have mail capability for our use case // But we'll be lenient and just check the basics above return true; } }