generated from Nodarx/template
feat: initial version
Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
This commit was merged in pull request #1.
This commit is contained in:
284
lib/Service/Discovery.php
Normal file
284
lib/Service/Discovery.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user