* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImapMail\Providers; use KTXF\Mail\Provider\ProviderBaseInterface; use KTXF\Mail\Provider\ProviderServiceDiscoverInterface; use KTXF\Mail\Provider\ProviderServiceMutateInterface; use KTXF\Mail\Provider\ProviderServiceTestInterface; use KTXF\Mail\Service\ServiceBaseInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Provider\ResourceServiceMutateInterface; use KTXM\ProviderImapMail\Service\Discovery; use KTXM\ProviderImapMail\Service\Remote\RemoteService; use KTXM\ProviderImapMail\Stores\ServiceStore; /** * IMAP Mail Provider * * Registers IMAP as a mail provider and handles service lifecycle: * list / fetch / create / modify / destroy / discover / test. */ class Provider implements ProviderServiceMutateInterface, ProviderServiceDiscoverInterface, ProviderServiceTestInterface { public const JSON_TYPE = ProviderBaseInterface::JSON_TYPE; protected const PROVIDER_IDENTIFIER = 'imap'; protected const PROVIDER_LABEL = 'IMAP Mail Provider'; protected const PROVIDER_DESCRIPTION = 'Provides mail services via the IMAP protocol'; protected const PROVIDER_ICON = 'mdi-email'; protected array $providerAbilities = [ self::CAPABILITY_SERVICE_LIST => true, self::CAPABILITY_SERVICE_FETCH => true, self::CAPABILITY_SERVICE_EXTANT => true, self::CAPABILITY_SERVICE_CREATE => true, self::CAPABILITY_SERVICE_MODIFY => true, self::CAPABILITY_SERVICE_DESTROY => true, self::CAPABILITY_SERVICE_TEST => true, ]; public function __construct( private readonly ServiceStore $serviceStore, ) {} // ── ProviderBaseInterface ───────────────────────────────────────────────── public function jsonSerialize(): array { return [ self::JSON_PROPERTY_TYPE => self::JSON_TYPE, self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER, self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL, self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities, ]; } public function jsonDeserialize(array|string $data): static { return $this; } public function type(): string { return self::TYPE_MAIL; } public function identifier(): string { return self::PROVIDER_IDENTIFIER; } public function label(): string { return self::PROVIDER_LABEL; } public function description(): string { return self::PROVIDER_DESCRIPTION; } public function icon(): string { return self::PROVIDER_ICON; } public function capable(string $value): bool { return !empty($this->providerAbilities[$value]); } public function capabilities(): array { return $this->providerAbilities; } // ── ProviderServiceMutateInterface ──────────────────────────────────────── public function serviceList(string $tenantId, string $userId, array $filter = []): array { $list = $this->serviceStore->list($tenantId, $userId, $filter); $result = []; foreach ($list as $entry) { $service = new Service(); $service->fromStore($entry); $result[$service->identifier()] = $service; } return $result; } public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service { return $this->serviceStore->fetch($tenantId, $userId, $identifier); } public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service { /** @var Service[] $services */ $services = $this->serviceList($tenantId, $userId); foreach ($services as $service) { if ($service->hasAddress($address)) { return $service; } } return null; } public function serviceExtant(string $tenantId, string $userId, string|int ...$identifiers): array { return $this->serviceStore->extant($tenantId, $userId, $identifiers); } public function serviceFresh(): ResourceServiceMutateInterface { return new Service(); } public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { if (!($service instanceof Service)) { throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); } $created = $this->serviceStore->create($tenantId, $userId, $service); return (string) $created->identifier(); } public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { if (!($service instanceof Service)) { throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); } $updated = $this->serviceStore->modify($tenantId, $userId, $service); return (string) $updated->identifier(); } public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool { if (!($service instanceof Service)) { return false; } return $this->serviceStore->delete($tenantId, $userId, $service->identifier()); } // ── ProviderServiceDiscoverInterface ────────────────────────────────────── public function serviceDiscover( string $tenantId, string $userId, string $identity, ?string $location = null, ?string $secret = null, ): ?ResourceServiceLocationInterface { $discovery = new Discovery(); // TODO: Make SSL verification configurable per-tenant $verifySSL = true; return $discovery->discover($identity, $location, $secret, $verifySSL); } // ── ProviderServiceTestInterface ────────────────────────────────────────── public function serviceTest(ServiceBaseInterface $service, array $options = []): array { $startTime = microtime(true); try { if (!($service instanceof Service)) { throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); } // Attempt to authenticate and list mailboxes as a connectivity check $wrapper = RemoteService::freshClient($service); $mailboxes = $wrapper->mailboxes(); $latency = (int) round((microtime(true) - $startTime) * 1000); return [ 'success' => true, 'message' => 'IMAP connection successful' . ' (Mailboxes: ' . count($mailboxes) . ')' . ' (Latency: ' . $latency . ' ms)', ]; } catch (\Throwable $e) { $latency = (int) round((microtime(true) - $startTime) * 1000); $location = ($service instanceof Service) ? $service->getLocation() : null; $target = $location ? $location->getEncryption() . '://' . $location->getHost() . ':' . $location->getPort() : 'unknown host'; // stream_socket_client errors are suppressed with @ in gricob — recover them $phpError = error_get_last(); $detail = $e->getMessage() !== '' ? $e->getMessage() : ($phpError['message'] ?? ''); if ($detail === '' && $location !== null) { $host = $location->getHost(); if ($host !== '' && gethostbyname($host) === $host) { $detail = "hostname '{$host}' could not be resolved"; } else { $detail = 'connection refused or timed out — check port and encryption settings'; } } elseif ($detail === '') { $detail = 'no details — check host, port, and encryption settings'; } return [ 'success' => false, 'message' => sprintf( 'Connection to %s failed (%s): %s', $target, (new \ReflectionClass($e))->getShortName(), $detail, ), ]; } } }