Initial commit

This commit is contained in:
root
2025-12-21 10:11:02 -05:00
committed by Sebastian Krupinski
commit b163df6bf7
7 changed files with 2446 additions and 0 deletions

73
lib/Module.php Normal file
View File

@@ -0,0 +1,73 @@
<?php
namespace KTXM\ProviderMailSystem;
use KTXC\Resource\ProviderManager;
use KTXF\Module\ModuleBrowserInterface;
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(
private readonly ProviderManager $providerManager,
) {}
public function handle(): string
{
return 'provider_mail_system';
}
public function label(): string
{
return 'System Mail Provider';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'System mail provider module for Ktrix - provides outbound mail delivery via SMTP';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [
'provider_mail_system' => [
'label' => 'Access System Mail Provider',
'description' => 'View and access the System mail provider module',
'group' => 'Mail Providers'
],
];
}
public function boot(): void
{
$this->providerManager->register(ProviderInterface::TYPE_MAIL, 'system', Provider::class);
}
public function registerBI(): array {
return [
'handle' => $this->handle(),
'namespace' => 'ProviderMailSystem',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description()
];
}
}

236
lib/Providers/Provider.php Normal file
View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderMailSystem\Providers;
use KTXF\Mail\Provider\ProviderBaseInterface;
use KTXF\Mail\Selector\ServiceSelector;
use KTXF\Mail\Service\IServiceBase;
use KTXF\Mail\Service\ServiceScope;
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
*/
class Provider implements ProviderBaseInterface {
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';
private array $capabilities = [
self::CAPABILITY_SERVICE_LIST => true,
self::CAPABILITY_SERVICE_FETCH => true,
self::CAPABILITY_SERVICE_EXTANT => true,
];
public function __construct(
private LoggerInterface $logger,
private ServiceStore $serviceStore,
) {}
/**
* @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 {
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(),
];
}
/**
* Apply selector filters to services
*/
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;
}
if ($selector->getOwner() !== null && $service->getOwner() !== $selector->getOwner()) {
return false;
}
if ($selector->getAddress() !== null && !$service->handlesAddress($selector->getAddress())) {
return false;
}
if ($selector->getCapabilities() !== null) {
foreach ($selector->getCapabilities() as $cap) {
if (!$service->capable($cap)) {
return false;
}
}
}
if ($selector->getEnabled() !== null && $service->getEnabled() !== $selector->getEnabled()) {
return false;
}
return true;
});
}
}

653
lib/Providers/Service.php Normal file
View File

@@ -0,0 +1,653 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\ProviderMailSystem\Providers;
use KTXF\Mail\Entity\Address;
use KTXF\Mail\Entity\IAddress;
use KTXF\Mail\Entity\IMessageMutable;
use KTXF\Mail\Entity\Message;
use KTXF\Mail\Exception\SendException;
use KTXF\Mail\Service\IServiceIdentity;
use KTXF\Mail\Service\IServiceLocation;
use KTXF\Mail\Service\IServiceSend;
use KTXF\Mail\Service\ServiceScope;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
/**
* SMTP Mail Service
*
* Outbound-only mail service using Symfony Mailer for SMTP delivery.
*
* @since 2025.05.01
*/
class Service implements IServiceSend {
private array $capabilities = [
self::CAPABILITY_SEND => true,
];
/** @var array<int, IAddress> */
private array $secondaryAddresses = [];
private ?Mailer $mailer = null;
/**
* @param string $providerId Provider identifier
* @param string|int $id Service identifier
* @param string $label Human-friendly label
* @param ServiceScope $scope Service scope
* @param string|null $owner Owner user ID (for User scope)
* @param bool $enabled Whether service is enabled
* @param IAddress|null $primaryAddress Primary sending address
* @param IServiceLocation|null $location Connection location
* @param IServiceIdentity|null $identity Authentication credentials
*/
public function __construct(
private string $providerId,
private string|int $id,
private string $label,
private ServiceScope $scope = ServiceScope::System,
private ?string $owner = null,
private bool $enabled = true,
private ?IAddress $primaryAddress = null,
private ?IServiceLocation $location = null,
private ?IServiceIdentity $identity = null,
) {}
/**
* @inheritDoc
*/
public function capable(string $value): bool {
return $this->capabilities[$value] ?? false;
}
/**
* @inheritDoc
*/
public function capabilities(): array {
return $this->capabilities;
}
/**
* @inheritDoc
*/
public function in(): string {
return $this->providerId;
}
/**
* @inheritDoc
*/
public function id(): string|int {
return $this->id;
}
/**
* @inheritDoc
*/
public function getLabel(): string {
return $this->label;
}
/**
* Sets the service label
*
* @param string $label
*
* @return self
*/
public function setLabel(string $label): self {
$this->label = $label;
return $this;
}
/**
* @inheritDoc
*/
public function getScope(): ServiceScope {
return $this->scope;
}
/**
* Sets the service scope
*
* @param ServiceScope $scope
*
* @return self
*/
public function setScope(ServiceScope $scope): self {
$this->scope = $scope;
return $this;
}
/**
* @inheritDoc
*/
public function getOwner(): ?string {
return $this->owner;
}
/**
* Sets the service owner
*
* @param string|null $owner
*
* @return self
*/
public function setOwner(?string $owner): self {
$this->owner = $owner;
return $this;
}
/**
* @inheritDoc
*/
public function getEnabled(): bool {
return $this->enabled;
}
/**
* Sets the enabled status
*
* @param bool $enabled
*
* @return self
*/
public function setEnabled(bool $enabled): self {
$this->enabled = $enabled;
return $this;
}
/**
* @inheritDoc
*/
public function getPrimaryAddress(): IAddress {
return $this->primaryAddress ?? new Address('');
}
/**
* Sets the primary address
*
* @param IAddress $address
*
* @return self
*/
public function setPrimaryAddress(IAddress $address): self {
$this->primaryAddress = $address;
return $this;
}
/**
* @inheritDoc
*/
public function getSecondaryAddresses(): array {
return $this->secondaryAddresses;
}
/**
* Sets the secondary addresses
*
* @param array<int, IAddress> $addresses
*
* @return self
*/
public function setSecondaryAddresses(array $addresses): self {
$this->secondaryAddresses = $addresses;
return $this;
}
/**
* Adds a secondary address
*
* @param IAddress $address
*
* @return self
*/
public function addSecondaryAddress(IAddress $address): self {
$this->secondaryAddresses[] = $address;
return $this;
}
/**
* Gets the service location
*
* @return IServiceLocation|null
*/
public function getLocation(): ?IServiceLocation {
return $this->location;
}
/**
* Sets the service location
*
* @param IServiceLocation $location
*
* @return self
*/
public function setLocation(IServiceLocation $location): self {
$this->location = $location;
$this->mailer = null; // Reset mailer when location changes
return $this;
}
/**
* Gets the service identity
*
* @return IServiceIdentity|null
*/
public function getIdentity(): ?IServiceIdentity {
return $this->identity;
}
/**
* Sets the service identity
*
* @param IServiceIdentity $identity
*
* @return self
*/
public function setIdentity(IServiceIdentity $identity): self {
$this->identity = $identity;
$this->mailer = null; // Reset mailer when identity changes
return $this;
}
/**
* @inheritDoc
*/
public function handlesAddress(string $address): bool {
$address = strtolower(trim($address));
// Check primary address (exact match or catch-all)
if ($this->primaryAddress !== null) {
if ($this->matchesAddress($this->primaryAddress->getAddress(), $address)) {
return true;
}
}
// Check secondary addresses (exact match or catch-all)
foreach ($this->secondaryAddresses as $secondary) {
if ($this->matchesAddress($secondary->getAddress(), $address)) {
return true;
}
}
return false;
}
/**
* Check if a pattern matches an address (supports catch-all with *)
*
* @param string $pattern Pattern like "noreply@system" or "*@system"
* @param string $address Address to check
*
* @return bool
*/
private function matchesAddress(string $pattern, string $address): bool {
$pattern = strtolower(trim($pattern));
$address = strtolower(trim($address));
// Exact match
if ($pattern === $address) {
return true;
}
// Catch-all pattern (e.g., *@system)
if (str_starts_with($pattern, '*@')) {
$domain = substr($pattern, 2);
return str_ends_with($address, '@' . $domain);
}
return false;
}
/**
* @inheritDoc
*/
public function messageFresh(): IMessageMutable {
$message = new Message();
// Pre-populate from address if available
if ($this->primaryAddress !== null) {
$message->setFrom($this->primaryAddress);
}
return $message;
}
/**
* @inheritDoc
*/
public function messageSend(IMessageMutable $message): string {
if (!$this->enabled) {
throw SendException::permanent('Service is disabled');
}
if ($this->location === null) {
throw SendException::permanent('Service location not configured');
}
if (!$message->hasRecipients()) {
throw SendException::permanent('Message has no recipients');
}
if (!$message->hasBody()) {
throw SendException::permanent('Message has no body content');
}
// Build Symfony Email
$email = $this->buildSymfonyEmail($message);
// Get or create mailer
$mailer = $this->getMailer();
try {
$mailer->send($email);
// Return message ID (generate one if not available)
return $email->getHeaders()->get('Message-ID')?->getBodyAsString()
?? $this->generateMessageId();
} catch (TransportExceptionInterface $e) {
// Determine if this is a permanent or temporary failure
$message = $e->getMessage();
$isPermanent = $this->isPermanentFailure($e);
throw new SendException(
"SMTP delivery failed: $message",
$e->getCode(),
$e,
null,
$isPermanent
);
}
}
// Collection operations - SMTP is outbound-only, these are not supported
public function collectionList(?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array {
return [];
}
public function collectionListFilter(): \KTXF\Resource\Filter\IFilter {
throw new \BadMethodCallException('SMTP service does not support collection operations');
}
public function collectionListSort(): \KTXF\Resource\Sort\ISort {
throw new \BadMethodCallException('SMTP service does not support collection operations');
}
public function collectionExtant(string|int ...$identifiers): array {
return [];
}
public function collectionFetch(string|int $identifier): ?\KTXF\Mail\Collection\ICollectionBase {
return null;
}
// Message operations - SMTP is outbound-only, these are not supported
public function messageList(string|int $collection, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null, ?\KTXF\Resource\Range\IRange $range = null, ?array $properties = null): array {
return [];
}
public function messageListFilter(): \KTXF\Resource\Filter\IFilter {
throw new \BadMethodCallException('SMTP service does not support message listing');
}
public function messageListSort(): \KTXF\Resource\Sort\ISort {
throw new \BadMethodCallException('SMTP service does not support message listing');
}
public function messageListRange(\KTXF\Resource\Range\RangeType $type): \KTXF\Resource\Range\IRange {
throw new \BadMethodCallException('SMTP service does not support message listing');
}
public function messageDelta(string|int $collection, string $signature, string $detail = 'ids'): array {
return ['signature' => $signature, 'added' => [], 'modified' => [], 'removed' => []];
}
public function messageExtant(string|int $collection, string|int ...$identifiers): array {
return [];
}
public function messageFetch(string|int $collection, string|int ...$identifiers): array {
return [];
}
public function messageSearch(string $query, ?array $collections = null, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null, ?\KTXF\Resource\Range\IRange $range = null): array {
return [];
}
/**
* @inheritDoc
*/
public function jsonSerialize(): array {
return [
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
self::JSON_PROPERTY_PROVIDER => $this->providerId,
self::JSON_PROPERTY_ID => $this->id,
self::JSON_PROPERTY_LABEL => $this->label,
self::JSON_PROPERTY_SCOPE => $this->scope->value,
self::JSON_PROPERTY_OWNER => $this->owner,
self::JSON_PROPERTY_ENABLED => $this->enabled,
self::JSON_PROPERTY_CAPABILITIES => $this->capabilities,
self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
];
}
/**
* Build a Symfony Email from IMessageMutable
*/
private function buildSymfonyEmail(IMessageMutable $message): Email {
$email = new Email();
// From
$from = $message->getFrom() ?? $this->primaryAddress;
if ($from !== null) {
$email->from(new \Symfony\Component\Mime\Address(
$from->getAddress(),
$from->getName() ?? ''
));
}
// Reply-To
$replyTo = $message->getReplyTo();
if ($replyTo !== null) {
$email->replyTo(new \Symfony\Component\Mime\Address(
$replyTo->getAddress(),
$replyTo->getName() ?? ''
));
}
// To
foreach ($message->getTo() as $to) {
$email->addTo(new \Symfony\Component\Mime\Address(
$to->getAddress(),
$to->getName() ?? ''
));
}
// CC
foreach ($message->getCc() as $cc) {
$email->addCc(new \Symfony\Component\Mime\Address(
$cc->getAddress(),
$cc->getName() ?? ''
));
}
// BCC
foreach ($message->getBcc() as $bcc) {
$email->addBcc(new \Symfony\Component\Mime\Address(
$bcc->getAddress(),
$bcc->getName() ?? ''
));
}
// Subject
$email->subject($message->getSubject());
// Body
if ($message->getBodyText() !== null) {
$email->text($message->getBodyText());
}
if ($message->getBodyHtml() !== null) {
$email->html($message->getBodyHtml());
}
// Attachments
foreach ($message->getAttachments() as $attachment) {
if ($attachment->isInline()) {
$email->embed(
$attachment->getContent(),
$attachment->getName(),
$attachment->getMimeType()
);
} else {
$email->attach(
$attachment->getContent(),
$attachment->getName(),
$attachment->getMimeType()
);
}
}
// Custom headers
foreach ($message->getHeaders() as $name => $value) {
$email->getHeaders()->addTextHeader($name, $value);
}
return $email;
}
/**
* Get or create the Symfony Mailer instance
*/
private function getMailer(): Mailer {
if ($this->mailer === null) {
$dsn = $this->buildDsn();
$transport = Transport::fromDsn($dsn);
$this->mailer = new Mailer($transport);
}
return $this->mailer;
}
/**
* Build the DSN string for Symfony Mailer
*/
private function buildDsn(): string {
if ($this->location === null) {
throw new \RuntimeException('Service location not configured');
}
$host = $this->location->getOutboundHost() ?? $this->location->getInboundHost();
$port = $this->location->getOutboundPort() ?? $this->location->getInboundPort();
$security = $this->location->getOutboundSecurity() ?? $this->location->getInboundSecurity();
if ($host === null) {
throw new \RuntimeException('SMTP host not configured');
}
// Determine scheme based on security setting
$scheme = match($security) {
IServiceLocation::SECURITY_SSL => 'smtps',
IServiceLocation::SECURITY_TLS, IServiceLocation::SECURITY_STARTTLS => 'smtp',
default => 'smtp',
};
// Build DSN
$dsn = $scheme . '://';
// Add credentials if available
if ($this->identity !== null) {
$username = $this->identity->getUsername ?? null;
$password = $this->identity->getPassword ?? null;
if (method_exists($this->identity, 'getUsername')) {
$username = $this->identity->getUsername();
}
if (method_exists($this->identity, 'getPassword')) {
$password = $this->identity->getPassword();
}
if ($username !== null) {
$dsn .= urlencode($username);
if ($password !== null) {
$dsn .= ':' . urlencode($password);
}
$dsn .= '@';
}
}
$dsn .= $host;
if ($port !== null) {
$dsn .= ':' . $port;
}
// Disable certificate verification
$dsn .= '?verify_peer=0';
return $dsn;
}
/**
* Generate a unique message ID
*/
private function generateMessageId(): string {
$domain = 'ktrix.local';
if ($this->primaryAddress !== null) {
$parts = explode('@', $this->primaryAddress->getAddress());
if (count($parts) === 2) {
$domain = $parts[1];
}
}
return sprintf('<%s.%s@%s>',
bin2hex(random_bytes(8)),
time(),
$domain
);
}
/**
* Determine if an exception represents a permanent failure
*/
private function isPermanentFailure(TransportExceptionInterface $e): bool {
$message = strtolower($e->getMessage());
// Common permanent failure indicators
$permanentPatterns = [
'user unknown',
'mailbox not found',
'invalid recipient',
'relay access denied',
'authentication failed',
'bad recipient',
'550 ',
'551 ',
'552 ',
'553 ',
'554 ',
];
foreach ($permanentPatterns as $pattern) {
if (str_contains($message, $pattern)) {
return true;
}
}
return false;
}
}

362
lib/Stores/ServiceStore.php Normal file
View File

@@ -0,0 +1,362 @@
<?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;
}
}