feat: use mail manager and mail providers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+109
-313
@@ -9,354 +9,150 @@ declare(strict_types=1);
|
||||
|
||||
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;
|
||||
use KTXF\Utile\UUID;
|
||||
use KTXM\ProviderMailSystem\Providers\Service;
|
||||
|
||||
/**
|
||||
* Service Store
|
||||
*
|
||||
* @since 2025.05.01
|
||||
* System Mail Route Store
|
||||
*
|
||||
* Persists system mail routes (one MongoDB document per route) in the
|
||||
* collection `provider_system_mail_services`. Routes are tenant scoped —
|
||||
* they carry no user association and contain no transport credentials,
|
||||
* only the mapping from a "@system" address to a backing provider/service.
|
||||
*
|
||||
* @since 2026.07.01
|
||||
*/
|
||||
class ServiceStore {
|
||||
class ServiceStore
|
||||
{
|
||||
protected const COLLECTION_NAME = 'provider_system_mail_services';
|
||||
|
||||
public function __construct(
|
||||
private DataStore $store,
|
||||
private LoggerInterface $logger,
|
||||
protected readonly DataStore $dataStore,
|
||||
) {}
|
||||
|
||||
private string $serviceCollection = 'mail_provider_smtp_service';
|
||||
/**
|
||||
* List routes for a tenant, optionally filtered by route IDs
|
||||
*/
|
||||
public function list(string $tenantId, ?array $filter = null): array
|
||||
{
|
||||
$filterCondition = [
|
||||
'tid' => $tenantId,
|
||||
];
|
||||
|
||||
if (!empty($filter)) {
|
||||
$filterCondition['sid'] = ['$in' => array_map('strval', $filter)];
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filterCondition);
|
||||
|
||||
$list = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$list[$entry['sid']] = $entry;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all services for a tenant
|
||||
*
|
||||
* @param string $tenantId
|
||||
*
|
||||
* @return array<string|int, Service>
|
||||
* Check existence of routes by IDs for a tenant
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
public function extant(string $tenantId, array $identifiers): array
|
||||
{
|
||||
if (empty($identifiers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(
|
||||
[
|
||||
'tid' => $tenantId,
|
||||
'sid' => ['$in' => array_map('strval', $identifiers)]
|
||||
],
|
||||
['projection' => ['sid' => 1]]
|
||||
);
|
||||
|
||||
$existingIds = [];
|
||||
foreach ($cursor as $document) {
|
||||
$existingIds[] = $document['sid'];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($identifiers as $id) {
|
||||
$result[(string)$id] = in_array((string)$id, $existingIds, true);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Retrieve a single route by ID
|
||||
*/
|
||||
public function findServiceByAddress(string $tenantId, string $address): ?Service {
|
||||
$address = strtolower(trim($address));
|
||||
if ($address === '') {
|
||||
public function fetch(string $tenantId, string|int $serviceId): ?array
|
||||
{
|
||||
$document = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne([
|
||||
'tid' => $tenantId,
|
||||
'sid' => (string)$serviceId,
|
||||
]);
|
||||
|
||||
if (!$document) {
|
||||
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;
|
||||
}
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific service
|
||||
*
|
||||
* @param string $tenantId
|
||||
* @param string|int $serviceId
|
||||
*
|
||||
* @return Service|null
|
||||
* Create a new route
|
||||
*/
|
||||
public function getService(string $tenantId, string|int $serviceId): ?Service {
|
||||
$serviceId = (string)$serviceId;
|
||||
public function create(string $tenantId, Service $service): array
|
||||
{
|
||||
$document = $service->toStore();
|
||||
|
||||
$document['tid'] = $tenantId;
|
||||
$document['sid'] = UUID::v4();
|
||||
$document['createdOn'] = new \MongoDB\BSON\UTCDateTime();
|
||||
$document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime();
|
||||
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify an existing route
|
||||
*/
|
||||
public function modify(string $tenantId, Service $service): array
|
||||
{
|
||||
$serviceId = (string)$service->identifier();
|
||||
if ($serviceId === '') {
|
||||
return null;
|
||||
throw new \InvalidArgumentException('Service ID is required for update');
|
||||
}
|
||||
|
||||
try {
|
||||
$entry = $this->store->selectCollection($this->serviceCollection)->findOne([
|
||||
$document = $service->toStore();
|
||||
$document['modifiedOn'] = new \MongoDB\BSON\UTCDateTime();
|
||||
unset($document['sid'], $document['tid'], $document['createdOn']);
|
||||
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||
[
|
||||
'tid' => $tenantId,
|
||||
'sid' => $serviceId,
|
||||
]);
|
||||
],
|
||||
['$set' => $document]
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new service
|
||||
*
|
||||
* @param string $tenantId
|
||||
* @param IServiceBase $service
|
||||
*
|
||||
* @return string|int Service ID
|
||||
* Delete a route
|
||||
*/
|
||||
public function createService(string $tenantId, IServiceBase $service): string|int {
|
||||
$id = (string)$service->id();
|
||||
if ($id === '') {
|
||||
$id = $this->generateServiceId($tenantId);
|
||||
}
|
||||
public function delete(string $tenantId, string|int $serviceId): bool
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([
|
||||
'tid' => $tenantId,
|
||||
'sid' => (string)$serviceId,
|
||||
]);
|
||||
|
||||
$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;
|
||||
return $result->getDeletedCount() > 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user