feat: use mail manager and mail providers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+5
-9
@@ -8,11 +8,6 @@ use KTXF\Module\ModuleInstanceAbstract;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXM\ProviderMailSystem\Providers\Provider;
|
||||
|
||||
/**
|
||||
* SMTP Mail Provider Module
|
||||
*
|
||||
* Provides outbound-only mail service via SMTP using Symfony Mailer.
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
{
|
||||
public function __construct(
|
||||
@@ -36,12 +31,12 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'System mail provider module for Ktrix - provides outbound mail delivery via SMTP';
|
||||
return 'System mail provider module for Ktrix - routes system-generated mail to configured mail services';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return '0.0.1';
|
||||
return '0.0.2';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
@@ -54,7 +49,7 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'system', Provider::class);
|
||||
@@ -67,7 +62,8 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
||||
'version' => $this->version(),
|
||||
'label' => $this->label(),
|
||||
'author' => $this->author(),
|
||||
'description' => $this->description()
|
||||
'description' => $this->description(),
|
||||
'boot' => 'static/module.mjs',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+226
-200
@@ -9,228 +9,254 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderMailSystem\Providers;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXF\Mail\Provider\ProviderBaseInterface;
|
||||
use KTXF\Mail\Selector\ServiceSelector;
|
||||
use KTXF\Mail\Service\IServiceBase;
|
||||
use KTXF\Mail\Service\ServiceScope;
|
||||
use KTXF\Mail\Provider\ProviderServiceMutateInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
||||
use KTXM\ProviderMailSystem\Stores\ServiceStore;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* SMTP Mail Provider
|
||||
*
|
||||
* Provider for SMTP-based mail services. Supports multiple configured
|
||||
* services per tenant with system and user scopes.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
* System Mail Provider
|
||||
*
|
||||
* Rule-based routing provider for system-generated mail. Owns the reserved
|
||||
* "@system" address namespace: each service is a route that maps a logical
|
||||
* system address (e.g. authentication@system) to a backing service on a real
|
||||
* mail provider, with the sender identity rewritten on submission. A
|
||||
* "*@system" route acts as the tenant-wide default (catch-all).
|
||||
*
|
||||
* Routes are tenant scoped and resolvable only under the reserved system
|
||||
* user context.
|
||||
*
|
||||
* @since 2026.07.01
|
||||
*/
|
||||
class Provider implements ProviderBaseInterface {
|
||||
class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface
|
||||
{
|
||||
|
||||
private const PROVIDER_ID = 'system';
|
||||
private const PROVIDER_LABEL = 'System Mail Provider';
|
||||
private const PROVIDER_DESCRIPTION = 'Outbound-only System mail provider for system notifications';
|
||||
private const PROVIDER_ICON = 'fa-envelope';
|
||||
protected const PROVIDER_IDENTIFIER = 'system';
|
||||
protected const PROVIDER_LABEL = 'System Mail Provider';
|
||||
protected const PROVIDER_DESCRIPTION = 'Routes system-generated mail to configured mail services';
|
||||
protected const PROVIDER_ICON = 'mdi-email-lock';
|
||||
|
||||
private array $capabilities = [
|
||||
self::CAPABILITY_SERVICE_LIST => true,
|
||||
self::CAPABILITY_SERVICE_FETCH => true,
|
||||
self::CAPABILITY_SERVICE_EXTANT => true,
|
||||
protected const ADDRESS_DOMAIN_SUFFIX = '@system';
|
||||
|
||||
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,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private ServiceStore $serviceStore,
|
||||
private readonly ServiceStore $serviceStore,
|
||||
private readonly ProviderManager $providerManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function capable(string $value): bool {
|
||||
return $this->capabilities[$value] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function capabilities(): array {
|
||||
return $this->capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function id(): string {
|
||||
return self::PROVIDER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function label(): string {
|
||||
return self::PROVIDER_LABEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function type(): string {
|
||||
return self::TYPE_MAIL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function identifier(): string {
|
||||
return self::PROVIDER_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function description(): string {
|
||||
return self::PROVIDER_DESCRIPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function icon(): string {
|
||||
return self::PROVIDER_ICON;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceList(string $tenantId, string $userId, ?ServiceSelector $selector = null): array {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceExtant(string $tenantId, ?string $userId, string|int ...$identifiers): array {
|
||||
$result = [];
|
||||
|
||||
foreach ($identifiers as $id) {
|
||||
$result[$id] = $this->serviceFetch($tenantId, $userId, $id) !== null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceFetch(string $tenantId, ?string $userId, string|int $identifier): ?IServiceBase {
|
||||
$identifier = (string)$identifier;
|
||||
if ($identifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only handle @system addresses for this provider
|
||||
if (!str_ends_with(strtolower($identifier), '@system')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch by primary address (which is the sid)
|
||||
$service = $this->serviceStore->getService($tenantId, $identifier);
|
||||
if ($service === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enforce scope visibility
|
||||
if ($service->getScope() === ServiceScope::System) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
if ($service->getScope() === ServiceScope::User) {
|
||||
if ($userId !== null && $service->getOwner() === $userId) {
|
||||
return $service;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function serviceFindByAddress(string $tenantId, ?string $userId, string $address): ?IServiceBase {
|
||||
$address = strtolower(trim($address));
|
||||
if ($address === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only handle @system addresses for this provider
|
||||
if (!str_ends_with($address, '@system')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use store's findServiceByAddress which checks primary, secondary, and catch-all patterns
|
||||
$service = $this->serviceStore->findServiceByAddress($tenantId, $address);
|
||||
if ($service === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Enforce scope visibility
|
||||
if ($service->getScope() === ServiceScope::System) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
if ($service->getScope() === ServiceScope::User) {
|
||||
if ($userId !== null && $service->getOwner() === $userId) {
|
||||
return $service;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonSerialize(): array {
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
||||
self::JSON_PROPERTY_ID => $this->id(),
|
||||
self::JSON_PROPERTY_LABEL => $this->label(),
|
||||
self::JSON_PROPERTY_CAPABILITIES => $this->capabilities(),
|
||||
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||
self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
|
||||
self::PROPERTY_LABEL => self::PROVIDER_LABEL,
|
||||
self::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
|
||||
{
|
||||
// routes are tenant scoped and only visible in the system user context,
|
||||
// keeping them out of regular user account listings
|
||||
if ($userId !== self::USER_SYSTEM) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($this->serviceStore->list($tenantId, $filter ?: null) as $serviceData) {
|
||||
$serviceInstance = $this->serviceFresh()->fromStore($serviceData);
|
||||
$list[$serviceInstance->identifier()] = $serviceInstance;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?Service
|
||||
{
|
||||
if ($userId !== self::USER_SYSTEM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$serviceData = $this->serviceStore->fetch($tenantId, $identifier);
|
||||
if ($serviceData === null) {
|
||||
return null;
|
||||
}
|
||||
return $this->serviceFresh()->fromStore($serviceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply selector filters to services
|
||||
* Finds the route that handles a logical system address
|
||||
*
|
||||
* Only addresses in the reserved "@system" namespace are considered, and
|
||||
* only when asked in the reserved system user context — real users can
|
||||
* not resolve (and therefore not send through) system routes. An exact
|
||||
* address match always wins over the "*@system" catch-all.
|
||||
*/
|
||||
private function applySelector(array $services, ServiceSelector $selector): array {
|
||||
return array_filter($services, function(IServiceBase $service) use ($selector) {
|
||||
if ($selector->getScope() !== null && $service->getScope() !== $selector->getScope()) {
|
||||
return false;
|
||||
public function serviceFindByAddress(string $tenantId, string $userId, string $address): ?Service
|
||||
{
|
||||
$address = strtolower(trim($address));
|
||||
if (!str_ends_with($address, self::ADDRESS_DOMAIN_SUFFIX)) {
|
||||
return null;
|
||||
}
|
||||
if ($userId !== self::USER_SYSTEM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$catchAll = null;
|
||||
/** @var Service $service */
|
||||
foreach ($this->serviceList($tenantId, $userId) as $service) {
|
||||
if (!$service->getEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($selector->getOwner() !== null && $service->getOwner() !== $selector->getOwner()) {
|
||||
return false;
|
||||
if ($service->hasAddressExact($address)) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
if ($selector->getAddress() !== null && !$service->handlesAddress($selector->getAddress())) {
|
||||
return false;
|
||||
if ($catchAll === null && $service->hasAddressPattern($address)) {
|
||||
$catchAll = $service;
|
||||
}
|
||||
|
||||
if ($selector->getCapabilities() !== null) {
|
||||
foreach ($selector->getCapabilities() as $cap) {
|
||||
if (!$service->capable($cap)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $catchAll;
|
||||
}
|
||||
|
||||
public function serviceExtant(string $tenantId, string $userId, string|int ...$identifiers): array
|
||||
{
|
||||
if ($userId !== self::USER_SYSTEM) {
|
||||
return array_fill_keys(array_map('strval', $identifiers), false);
|
||||
}
|
||||
|
||||
return $this->serviceStore->extant($tenantId, $identifiers);
|
||||
}
|
||||
|
||||
public function serviceFresh(): Service
|
||||
{
|
||||
return new Service($this->providerManager);
|
||||
}
|
||||
|
||||
public function serviceCreate(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
|
||||
{
|
||||
$this->assertSystemContext($userId);
|
||||
if (!($service instanceof Service)) {
|
||||
throw new InvalidArgumentException('Service must be an instance of System Mail Service');
|
||||
}
|
||||
$this->validateRoute($service);
|
||||
|
||||
$created = $this->serviceStore->create($tenantId, $service);
|
||||
return (string)$created['sid'];
|
||||
}
|
||||
|
||||
public function serviceModify(string $tenantId, string $userId, ResourceServiceMutateInterface $service): string
|
||||
{
|
||||
$this->assertSystemContext($userId);
|
||||
if (!($service instanceof Service)) {
|
||||
throw new InvalidArgumentException('Service must be an instance of System Mail Service');
|
||||
}
|
||||
$this->validateRoute($service);
|
||||
|
||||
$this->serviceStore->modify($tenantId, $service);
|
||||
return (string)$service->identifier();
|
||||
}
|
||||
|
||||
public function serviceDestroy(string $tenantId, string $userId, ResourceServiceMutateInterface $service): bool
|
||||
{
|
||||
$this->assertSystemContext($userId);
|
||||
if (!($service instanceof Service)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->serviceStore->delete($tenantId, $service->identifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the operation runs in the reserved system user context
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function assertSystemContext(string $userId): void
|
||||
{
|
||||
if ($userId !== self::USER_SYSTEM) {
|
||||
throw new InvalidArgumentException('System mail routes can only be managed in the system user context');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates route configuration before persisting
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateRoute(Service $service): void
|
||||
{
|
||||
$address = strtolower(trim($service->getPrimaryAddress()->getAddress()));
|
||||
if ($address === '' || !str_ends_with($address, self::ADDRESS_DOMAIN_SUFFIX)) {
|
||||
throw new InvalidArgumentException('Route address must be within the reserved "@system" namespace (e.g. authentication@system or *@system)');
|
||||
}
|
||||
foreach ($service->getSecondaryAddresses() as $secondary) {
|
||||
$secondaryAddress = strtolower(trim($secondary->getAddress()));
|
||||
if ($secondaryAddress === '' || !str_ends_with($secondaryAddress, self::ADDRESS_DOMAIN_SUFFIX)) {
|
||||
throw new InvalidArgumentException('Route alias addresses must be within the reserved "@system" namespace');
|
||||
}
|
||||
|
||||
if ($selector->getEnabled() !== null && $service->getEnabled() !== $selector->getEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if ($service->getTargetProvider() === '' || $service->getTargetService() === '') {
|
||||
throw new InvalidArgumentException('Route requires a delivery target (provider and service)');
|
||||
}
|
||||
if ($service->getTargetProvider() === self::PROVIDER_IDENTIFIER) {
|
||||
throw new InvalidArgumentException('Route cannot target the system provider itself');
|
||||
}
|
||||
|
||||
$fromAddress = $service->getFromAddress();
|
||||
if ($fromAddress === '' || filter_var($fromAddress, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw new InvalidArgumentException('Route requires a valid sender (from) address');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+462
-599
File diff suppressed because it is too large
Load Diff
+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