generated from Nodarx/template
285 lines
8.9 KiB
PHP
285 lines
8.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXM\ProviderImapMail\Service;
|
|
|
|
use KTXM\ProviderImapMail\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;
|
|
}
|
|
}
|