* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderMailSystem\Stores; use KTXF\Mail\Entity\Address; use KTXF\Mail\Service\IServiceBase; use KTXF\Mail\Service\ServiceIdentityBasic; use KTXF\Mail\Service\ServiceLocation; use KTXF\Mail\Service\ServiceScope; use KTXM\ProviderMailSystem\Providers\Service; use KTXC\Db\DataStore; use Psr\Log\LoggerInterface; /** * Service Store * * @since 2025.05.01 */ class ServiceStore { public function __construct( private DataStore $store, private LoggerInterface $logger, ) {} private string $serviceCollection = 'mail_provider_smtp_service'; /** * List all services for a tenant * * @param string $tenantId * * @return array */ public function listServices(string $tenantId): array { try { $cursor = $this->store->selectCollection($this->serviceCollection)->find([ 'tid' => $tenantId, ]); $services = []; foreach ($cursor as $entry) { $id = (string)($entry['sid'] ?? ''); if ($id === '') { continue; } $service = $this->hydrateService($id, is_array($entry) ? $entry : []); if ($service !== null) { $services[$id] = $service; } } return $services; } catch (\Throwable $e) { $this->logger->warning('Failed to list services', [ 'tenantId' => $tenantId, 'error' => $e->getMessage(), ]); return []; } } /** * Find a service by address (checks primary, secondary, and catch-all patterns) * * @param string $tenantId * @param string $address Address to search for * * @return Service|null */ public function findServiceByAddress(string $tenantId, string $address): ?Service { $address = strtolower(trim($address)); if ($address === '') { return null; } try { // Get all services for tenant $services = $this->listServices($tenantId); foreach ($services as $service) { if ($service->handlesAddress($address)) { return $service; } } return null; } catch (\Throwable $e) { $this->logger->warning('Failed to find service by address', [ 'tenantId' => $tenantId, 'address' => $address, 'error' => $e->getMessage(), ]); return null; } } /** * Get a specific service * * @param string $tenantId * @param string|int $serviceId * * @return Service|null */ public function getService(string $tenantId, string|int $serviceId): ?Service { $serviceId = (string)$serviceId; if ($serviceId === '') { return null; } try { $entry = $this->store->selectCollection($this->serviceCollection)->findOne([ 'tid' => $tenantId, 'sid' => $serviceId, ]); if ($entry === null) { return null; } return $this->hydrateService($serviceId, is_array($entry) ? $entry : []); } catch (\Throwable $e) { $this->logger->warning('Failed to fetch service', [ 'tenantId' => $tenantId, 'serviceId' => $serviceId, 'error' => $e->getMessage(), ]); return null; } } /** * Create a new service * * @param string $tenantId * @param IServiceBase $service * * @return string|int Service ID */ public function createService(string $tenantId, IServiceBase $service): string|int { $id = (string)$service->id(); if ($id === '') { $id = $this->generateServiceId($tenantId); } $now = date('c'); $data = $this->dehydrateService($service); try { $document = array_merge($data, [ 'tid' => $tenantId, 'sid' => $id, 'createdOn' => $now, 'modifiedOn' => $now, ]); $this->store->selectCollection($this->serviceCollection)->insertOne($document); return $id; } catch (\Throwable $e) { $this->logger->warning('Failed to create service', [ 'tenantId' => $tenantId, 'serviceId' => $id, 'error' => $e->getMessage(), ]); throw $e; } } /** * Update an existing service * * @param string $tenantId * @param IServiceBase $service * * @return string|int Service ID */ public function updateService(string $tenantId, IServiceBase $service): string|int { $id = (string)$service->id(); if ($id === '') { $id = $this->generateServiceId($tenantId); } $now = date('c'); $data = $this->dehydrateService($service); unset($data['tid'], $data['sid'], $data['createdOn'], $data['modifiedOn']); try { $this->store->selectCollection($this->serviceCollection)->updateOne( ['tid' => $tenantId, 'sid' => $id], [ '$set' => array_merge($data, ['modifiedOn' => $now]), '$setOnInsert' => ['tid' => $tenantId, 'sid' => $id, 'createdOn' => $now], ], ['upsert' => true] ); return $id; } catch (\Throwable $e) { $this->logger->warning('Failed to update service', [ 'tenantId' => $tenantId, 'serviceId' => $id, 'error' => $e->getMessage(), ]); throw $e; } } /** * Delete a service * * @param string $tenantId * @param string|int $serviceId * * @return bool */ public function deleteService(string $tenantId, string|int $serviceId): bool { $serviceId = (string)$serviceId; if ($serviceId === '') { return false; } try { $result = $this->store->selectCollection($this->serviceCollection)->deleteOne([ 'tid' => $tenantId, 'sid' => $serviceId, ]); return $result->getDeletedCount() === 1; } catch (\Throwable $e) { $this->logger->warning('Failed to delete service', [ 'tenantId' => $tenantId, 'serviceId' => $serviceId, 'error' => $e->getMessage(), ]); return false; } } /** * Generate a unique service ID */ private function generateServiceId(string $tenantId): string { // Try a few times to avoid collisions if a unique index is ever added. for ($attempt = 0; $attempt < 5; $attempt++) { $id = sprintf('%08x-%04x', time(), mt_rand(0, 0xffff)); try { $existing = $this->store->selectCollection($this->serviceCollection)->findOne([ 'tid' => $tenantId, 'sid' => $id, ], [ 'projection' => ['sid' => 1, '_id' => 0], ]); if ($existing === null) { return $id; } } catch (\Throwable) { // If the store is unavailable, fall back to generated id. return $id; } } return sprintf('%08x-%04x', time(), mt_rand(0, 0xffff)); } /** * Hydrate a Service from stored data */ private function hydrateService(string|int $id, array $data): ?Service { try { $service = new Service( providerId: 'smtp', id: $id, label: $data['label'] ?? '', scope: ServiceScope::tryFrom($data['scope'] ?? 'system') ?? ServiceScope::System, owner: $data['owner'] ?? null, enabled: $data['enabled'] ?? true, ); // Primary address if (isset($data['primaryAddress'])) { $service->setPrimaryAddress(Address::fromArray($data['primaryAddress'])); } // Secondary addresses if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) { foreach ($data['secondaryAddresses'] as $addrData) { $service->addSecondaryAddress(Address::fromArray($addrData)); } } // Location if (isset($data['location'])) { $service->setLocation(ServiceLocation::fromArray($data['location'])); } // Identity if (isset($data['identity'])) { $identityType = $data['identity']['type'] ?? 'basic'; if ($identityType === 'basic') { $service->setIdentity(ServiceIdentityBasic::fromArray($data['identity'])); } } return $service; } catch (\Throwable $e) { $this->logger->warning('Failed to hydrate service', [ 'id' => $id, 'error' => $e->getMessage(), ]); return null; } } /** * Dehydrate a Service to storable data */ private function dehydrateService(IServiceBase $service): array { $data = [ 'label' => $service->getLabel(), 'scope' => $service->getScope()->value, 'owner' => $service->getOwner(), 'enabled' => $service->getEnabled(), 'primaryAddress' => $service->getPrimaryAddress()->jsonSerialize(), 'secondaryAddresses' => array_map( fn($a) => $a->jsonSerialize(), $service->getSecondaryAddresses() ), ]; // Store location if it's a Service instance if ($service instanceof Service) { $location = $service->getLocation(); if ($location !== null) { $data['location'] = $location->jsonSerialize(); } $identity = $service->getIdentity(); if ($identity !== null) { $identityData = $identity->jsonSerialize(); // Include password for storage (it's excluded from default serialization) if ($identity instanceof ServiceIdentityBasic) { $identityData['password'] = $identity->getPassword(); } $data['identity'] = $identityData; } } return $data; } }