290 lines
9.1 KiB
PHP
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;
|
|
}
|
|
}
|