Files
provider_imap/lib/Service/Discovery.php
2026-03-28 12:43:42 -04:00

285 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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.<domain> — implicit-TLS IMAP (port 993)
* 2. _imap._tcp.<domain> — 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;
}
}