Files
provider_jmapc/lib/Service/Discovery.php
2026-02-10 20:33:10 -05:00

290 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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.<domain>)
* 2. Well-known URI (https://<host>/.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.<domain> 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;
}
}