Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Frontend development
|
||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.cache/
|
||||||
|
.vite/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
/static/
|
||||||
|
|
||||||
|
# Backend development
|
||||||
|
/lib/vendor/
|
||||||
|
coverage/
|
||||||
|
phpunit.xml.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
.phpstan.cache
|
||||||
|
.phpactor/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
27
composer.json
Normal file
27
composer.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "ktxm/provider-mail-system",
|
||||||
|
"type": "project",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Sebastian Krupinski",
|
||||||
|
"email": "krupinski01@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"platform": {
|
||||||
|
"php": "8.2"
|
||||||
|
},
|
||||||
|
"autoloader-suffix": "ProviderMailSystem",
|
||||||
|
"vendor-dir": "lib/vendor"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2 <=8.5",
|
||||||
|
"symfony/mailer": "^7.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXM\\ProviderMailSystem\\": "lib/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1066
composer.lock
generated
Normal file
1066
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
lib/Module.php
Normal file
73
lib/Module.php
Normal 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
236
lib/Providers/Provider.php
Normal 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
653
lib/Providers/Service.php
Normal 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
362
lib/Stores/ServiceStore.php
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user