* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\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\Mail\Service\ServiceMutableInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Provider\ResourceServiceMutateInterface; use KTXM\ProviderImap\Service\Discovery; use KTXM\ProviderImap\Service\Remote\RemoteService; use KTXM\ProviderImap\Stores\ServiceStore; /** * IMAP Mail Provider */ class Provider implements ProviderBaseInterface, 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, ) {} 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; } public function serviceList(string $tenantId, string $userId, array $filter = []): array { $list = $this->serviceStore->list($tenantId, $userId, $filter); foreach ($list as $serviceData) { $serviceInstance = $this->serviceFresh()->fromStore($serviceData); $list[$serviceInstance->identifier()] = $serviceInstance; } return $list; } public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service { $serviceData = $this->serviceStore->fetch($tenantId, $userId, $identifier); if ($serviceData === null) { return null; } $serviceInstance = $this->serviceFresh()->fromStore($serviceData); return $serviceInstance; } 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(): Service { return new Service(); } public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { if (!($service instanceof Service)) { throw new \InvalidArgumentException('Service must be instance of IMAP Service'); } $created = $this->serviceStore->create($tenantId, $userId, $service); return (string) $created['sid']; } public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string { if (!($service instanceof Service)) { throw new \InvalidArgumentException('Service must be instance of IMAP Service'); } $updated = $this->serviceStore->modify($tenantId, $userId, $service); return (string) $updated['sid']; } public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool { if (!($service instanceof Service)) { return false; } return $this->serviceStore->delete($tenantId, $userId, $service->identifier()); } public function serviceDiscover( string $tenantId, string $userId, string $identity, ?string $location = null, ?string $secret = null ): ResourceServiceLocationInterface|null { $discovery = new Discovery(); $verifySSL = true; return $discovery->discover($identity, $location, $secret, $verifySSL); } public function serviceTest(ServiceBaseInterface|ServiceMutableInterface $service, array $options = []): array { $startTime = microtime(true); try { if (!($service instanceof Service)) { throw new \InvalidArgumentException('Service must be an instance of IMAP Service'); } // augment the service with any provided test options (e.g. override location or credentials) $service->fromStore(['sid' => 'test']); // Attempt to authenticate and list mailboxes as a connectivity check $client = RemoteService::freshClient($service); $service = RemoteService::mailService($service, $client); $mailboxes = $service->collectionList(); $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, ), ]; } } }