363 lines
11 KiB
PHP
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;
|
|
}
|
|
|
|
}
|