Files
provider_system_mail/lib/Stores/ServiceStore.php
2026-02-10 20:34:26 -05:00

363 lines
11 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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<string|int, Service>
*/
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;
}
}